diff --git a/demo/85-template-document.ts b/demo/85-template-document.ts index f015dbe7cf..921a9befbb 100644 --- a/demo/85-template-document.ts +++ b/demo/85-template-document.ts @@ -3,6 +3,7 @@ import * as fs from "fs"; import { HeadingLevel, + ImageRun, Paragraph, patchDocument, PatchType, @@ -40,6 +41,10 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { type: PatchType.PARAGRAPH, children: [new TextRun("replaced just as well")], }, + image_test: { + type: PatchType.PARAGRAPH, + children: [new ImageRun({ data: fs.readFileSync("./demo/images/image1.jpeg"), transformation: { width: 100, height: 100 } })], + }, table: { type: PatchType.DOCUMENT, children: [ diff --git a/demo/assets/simple-template.docx b/demo/assets/simple-template.docx index 83f972c48a..a3e71cdf6f 100644 Binary files a/demo/assets/simple-template.docx and b/demo/assets/simple-template.docx differ diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index 46f72139c5..39e2dbeb8b 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -59,9 +59,8 @@ export class Compiler { } } - for (const data of file.Media.Array) { - const mediaData = data.stream; - zip.file(`word/media/${data.fileName}`, mediaData); + for (const { stream, fileName } of file.Media.Array) { + zip.file(`word/media/${fileName}`, stream); } return zip; diff --git a/src/file/media/media.ts b/src/file/media/media.ts index 6b16495809..3f9ef91938 100644 --- a/src/file/media/media.ts +++ b/src/file/media/media.ts @@ -20,6 +20,7 @@ export class Media { this.map = new Map(); } + // TODO: Unused public addMedia(data: Buffer | string | Uint8Array | ArrayBuffer, transformation: IMediaTransformation): IMediaData { const key = `${uniqueId()}.png`; diff --git a/src/patcher/content-types-manager.ts b/src/patcher/content-types-manager.ts new file mode 100644 index 0000000000..8032073bfc --- /dev/null +++ b/src/patcher/content-types-manager.ts @@ -0,0 +1,16 @@ +import { Element } from "xml-js"; + +import { getFirstLevelElements } from "./util"; + +export const appendContentType = (element: Element, contentType: string, extension: string): void => { + const relationshipElements = getFirstLevelElements(element, "Types"); + // eslint-disable-next-line functional/immutable-data + relationshipElements.push({ + attributes: { + ContentType: contentType, + Extension: extension, + }, + name: "Default", + type: "element", + }); +}; diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 535336e70d..339ba964ec 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -3,10 +3,17 @@ import { Element, js2xml } from "xml-js"; import { ParagraphChild } from "@file/paragraph"; import { FileChild } from "@file/file-child"; +import { IMediaData, Media } from "@file/media"; +import { IViewWrapper } from "@file/document-wrapper"; +import { File } from "@file/file"; +import { IContext } from "@file/xml-components"; +import { ImageReplacer } from "@export/packer/image-replacer"; import { replacer } from "./replacer"; import { findLocationOfText } from "./traverser"; import { toJson } from "./util"; +import { appendRelationship, getNextRelationshipIndex } from "./relationship-manager"; +import { appendContentType } from "./content-types-manager"; // eslint-disable-next-line functional/prefer-readonly-type type InputDataType = Buffer | string | number[] | Uint8Array | ArrayBuffer | Blob | NodeJS.ReadableStream; @@ -26,31 +33,94 @@ type FilePatch = { readonly children: readonly FileChild[]; }; +interface IRelationshipReplacement { + readonly key: string; + readonly mediaDatas: readonly IMediaData[]; +} + export type IPatch = ParagraphPatch | FilePatch; export interface PatchDocumentOptions { readonly patches: { readonly [key: string]: IPatch }; } +const imageReplacer = new ImageReplacer(); + export const patchDocument = async (data: InputDataType, options: PatchDocumentOptions): Promise => { const zipContent = await JSZip.loadAsync(data); + const context: IContext = { + file: { + Media: new Media(), + } as unknown as File, + viewWrapper: {} as unknown as IViewWrapper, + stack: [], + }; + const map = new Map(); + // eslint-disable-next-line functional/prefer-readonly-type + const relationshipReplacement: IRelationshipReplacement[] = []; + let hasMedia = false; + for (const [key, value] of Object.entries(zipContent.files)) { const json = toJson(await value.async("text")); - if (key.startsWith("word/")) { + if (key.startsWith("word/") && !key.endsWith(".xml.rels")) { for (const [patchKey, patchValue] of Object.entries(options.patches)) { const patchText = `{{${patchKey}}}`; const renderedParagraphs = findLocationOfText(json, patchText); // TODO: mutates json. Make it immutable - replacer(json, patchValue, patchText, renderedParagraphs); + replacer(json, patchValue, patchText, renderedParagraphs, context); + } + + const mediaDatas = imageReplacer.getMediaData(JSON.stringify(json), context.file.Media); + if (mediaDatas.length > 0) { + hasMedia = true; + // eslint-disable-next-line functional/immutable-data + relationshipReplacement.push({ + key, + mediaDatas, + }); } } map.set(key, json); } + for (const { key, mediaDatas } of relationshipReplacement) { + // eslint-disable-next-line functional/immutable-data + const relationshipsJson = map.get(`word/_rels/${key.split("/").pop()}.rels`); + + if (relationshipsJson) { + const index = getNextRelationshipIndex(relationshipsJson); + const newJson = imageReplacer.replace(JSON.stringify(map.get(key)), mediaDatas, index); + map.set(key, JSON.parse(newJson) as Element); + + for (const { fileName } of mediaDatas) { + appendRelationship( + relationshipsJson, + index, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + `media/${fileName}`, + ); + } + } + } + + if (hasMedia) { + const contentTypesJson = map.get("[Content_Types].xml"); + + if (!contentTypesJson) { + throw new Error("Could not find content types file"); + } + + appendContentType(contentTypesJson, "image/png", "png"); + appendContentType(contentTypesJson, "image/jpeg", "jpeg"); + appendContentType(contentTypesJson, "image/jpeg", "jpg"); + appendContentType(contentTypesJson, "image/bmp", "bmp"); + appendContentType(contentTypesJson, "image/gif", "gif"); + } + const zip = new JSZip(); for (const [key, value] of map) { @@ -59,13 +129,15 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO zip.file(key, output); } - const zipData = await zip.generateAsync({ + for (const { stream, fileName } of context.file.Media.Array) { + zip.file(`word/media/${fileName}`, stream); + } + + return zip.generateAsync({ type: "nodebuffer", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", compression: "DEFLATE", }); - - return zipData; }; const toXml = (jsonObj: Element): string => { diff --git a/src/patcher/relationship-manager.ts b/src/patcher/relationship-manager.ts new file mode 100644 index 0000000000..30f3bd425e --- /dev/null +++ b/src/patcher/relationship-manager.ts @@ -0,0 +1,30 @@ +import { Element } from "xml-js"; + +import { RelationshipType } from "@file/relationships/relationship/relationship"; +import { getFirstLevelElements } from "./util"; + +const getIdFromRelationshipId = (relationshipId: string): number => parseInt(relationshipId.substring(3), 10); + +export const getNextRelationshipIndex = (relationships: Element): number => { + const relationshipElements = getFirstLevelElements(relationships, "Relationships"); + + return ( + (relationshipElements + .map((e) => getIdFromRelationshipId(e.attributes?.Id?.toString() ?? "")) + .reduce((acc, curr) => Math.max(acc, curr), 0) ?? 0) + 1 + ); +}; + +export const appendRelationship = (relationships: Element, id: number, type: RelationshipType, target: string): void => { + const relationshipElements = getFirstLevelElements(relationships, "Relationships"); + // eslint-disable-next-line functional/immutable-data + relationshipElements.push({ + attributes: { + Id: `rId${id}`, + Type: type, + Target: target, + }, + name: "Relationship", + type: "element", + }); +}; diff --git a/src/patcher/replacer.ts b/src/patcher/replacer.ts index 488b59760d..45861de8e7 100644 --- a/src/patcher/replacer.ts +++ b/src/patcher/replacer.ts @@ -2,7 +2,7 @@ import { Element } from "xml-js"; import * as xml from "xml"; import { Formatter } from "@export/formatter"; -import { XmlComponent } from "@file/xml-components"; +import { IContext, XmlComponent } from "@file/xml-components"; import { IPatch, PatchType } from "./from-docx"; import { toJson } from "./util"; @@ -19,9 +19,13 @@ export const replacer = ( patch: IPatch, patchText: string, renderedParagraphs: readonly IRenderedParagraphNode[], + context: IContext, ): Element => { for (const renderedParagraph of renderedParagraphs) { - const textJson = patch.children.map((c) => toJson(xml(formatter.format(c as XmlComponent)))).map((c) => c.elements![0]); + const textJson = patch.children + // eslint-disable-next-line no-loop-func + .map((c) => toJson(xml(formatter.format(c as XmlComponent, context)))) + .map((c) => c.elements![0]); if (patch.type === PatchType.DOCUMENT) { const parentElement = goToParentElementFromPath(json, renderedParagraph.path); @@ -30,7 +34,6 @@ export const replacer = ( parentElement.elements?.splice(elementIndex, 1, ...textJson); } else if (patch.type === PatchType.PARAGRAPH) { const paragraphElement = goToElementFromPath(json, renderedParagraph.path); - replaceTokenInParagraphElement({ paragraphElement, renderedParagraph, diff --git a/src/patcher/util.ts b/src/patcher/util.ts index bdb79eb0d0..6f9ed57dd0 100644 --- a/src/patcher/util.ts +++ b/src/patcher/util.ts @@ -24,3 +24,7 @@ export const patchSpaceAttribute = (element: Element): Element => ({ "xml:space": "preserve", }, }); + +// eslint-disable-next-line functional/prefer-readonly-type +export const getFirstLevelElements = (relationships: Element, id: string): Element[] => + relationships.elements?.filter((e) => e.name === id)[0].elements ?? [];