diff --git a/demo/85-template-document.ts b/demo/85-template-document.ts index d3aca499ee..69015202f1 100644 --- a/demo/85-template-document.ts +++ b/demo/85-template-document.ts @@ -56,6 +56,7 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { ], link: "https://www.google.co.uk", }), + new ImageRun({ data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }), ], }), ], diff --git a/src/patcher/from-docx.spec.ts b/src/patcher/from-docx.spec.ts index 147bdd29d0..9fa8b45119 100644 --- a/src/patcher/from-docx.spec.ts +++ b/src/patcher/from-docx.spec.ts @@ -201,7 +201,7 @@ const MOCK_XML = ` describe("from-docx", () => { describe("patchDocument", () => { - before(() => { + beforeEach(() => { sinon.createStubInstance(JSZip, {}); // eslint-disable-next-line @typescript-eslint/no-explicit-any sinon.stub(JSZip, "loadAsync").callsFake( @@ -216,11 +216,11 @@ describe("from-docx", () => { ); }); - after(() => { + afterEach(() => { (JSZip.loadAsync as unknown as sinon.SinonStub).restore(); }); - it("should find the index of a run element with a token", async () => { + it("should patch the document", async () => { const output = await patchDocument(Buffer.from(""), { patches: { name: { @@ -256,6 +256,10 @@ describe("from-docx", () => { ], link: "https://www.google.co.uk", }), + new ImageRun({ + data: Buffer.from(""), + transformation: { width: 100, height: 100 }, + }), ], }), ], @@ -274,5 +278,75 @@ describe("from-docx", () => { }); expect(output).to.not.be.undefined; }); + + it("should patch the document", async () => { + const output = await patchDocument(Buffer.from(""), { + patches: {}, + }); + expect(output).to.not.be.undefined; + }); + + it("should use the relationships file rather than create one", () => { + (JSZip.loadAsync as unknown as sinon.SinonStub).restore(); + sinon.createStubInstance(JSZip, {}); + sinon.stub(JSZip, "loadAsync").callsFake( + () => + new Promise((resolve) => { + const zip = new JSZip(); + + zip.file("word/document.xml", MOCK_XML); + zip.file("word/_rels/document.xml.rels", ``); + zip.file("[Content_Types].xml", ``); + resolve(zip); + }), + ); + + const output = patchDocument(Buffer.from(""), { + patches: { + // eslint-disable-next-line @typescript-eslint/naming-convention + image_test: { + type: PatchType.PARAGRAPH, + children: [ + new ImageRun({ + data: Buffer.from(""), + transformation: { width: 100, height: 100 }, + }), + ], + }, + }, + }); + expect(output).to.not.be.undefined; + }); + + it("should throw an error if the content types is not found", () => { + (JSZip.loadAsync as unknown as sinon.SinonStub).restore(); + sinon.createStubInstance(JSZip, {}); + sinon.stub(JSZip, "loadAsync").callsFake( + () => + new Promise((resolve) => { + const zip = new JSZip(); + + zip.file("word/document.xml", MOCK_XML); + resolve(zip); + }), + ); + + expect(() => + patchDocument(Buffer.from(""), { + patches: { + // eslint-disable-next-line @typescript-eslint/naming-convention + image_test: { + type: PatchType.PARAGRAPH, + children: [ + new ImageRun({ + data: Buffer.from(""), + transformation: { width: 100, height: 100 }, + }), + ], + }, + }, + }), + ).to.throw(); + }); }); }); diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 3d27bd402d..675f72c5a5 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -140,25 +140,18 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO for (const { key, mediaDatas } of imageRelationshipAdditions) { // eslint-disable-next-line functional/immutable-data const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`; - - if (!map.has(relationshipKey)) { - map.set(relationshipKey, createRelationshipFile()); - } - - const relationshipsJson = map.get(relationshipKey); - - if (!relationshipsJson) { - throw new Error("Could not find relationships file"); - } + const relationshipsJson = map.get(relationshipKey) ?? createRelationshipFile(); + map.set(relationshipKey, 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) { + for (let i = 0; i < mediaDatas.length; i++) { + const { fileName } = mediaDatas[i]; appendRelationship( relationshipsJson, - index, + index + i, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", `media/${fileName}`, ); diff --git a/src/patcher/paragraph-split-inject.spec.ts b/src/patcher/paragraph-split-inject.spec.ts index bfb3cbdb0b..5228a88d2d 100644 --- a/src/patcher/paragraph-split-inject.spec.ts +++ b/src/patcher/paragraph-split-inject.spec.ts @@ -32,6 +32,60 @@ describe("paragraph-split-inject", () => { ); expect(output).to.deep.equal(0); }); + + it("should throw an exception when ran with empty elements", () => { + expect(() => + findRunElementIndexWithToken( + { + name: "w:p", + type: "element", + }, + "hello", + ), + ).to.throw(); + }); + + it("should throw an exception when ran with empty elements", () => { + expect(() => + findRunElementIndexWithToken( + { + name: "w:p", + type: "element", + elements: [ + { + name: "w:r", + type: "element", + }, + ], + }, + "hello", + ), + ).to.throw(); + }); + + it("should throw an exception when ran with empty elements", () => { + expect(() => + findRunElementIndexWithToken( + { + name: "w:p", + type: "element", + elements: [ + { + name: "w:r", + type: "element", + elements: [ + { + name: "w:t", + type: "element", + }, + ], + }, + ], + }, + "hello", + ), + ).to.throw(); + }); }); describe("splitRunElement", () => { @@ -51,6 +105,10 @@ describe("paragraph-split-inject", () => { }, ], }, + { + name: "w:x", + type: "element", + }, ], }, "*", @@ -91,11 +149,76 @@ describe("paragraph-split-inject", () => { name: "w:t", type: "element", }, + { + name: "w:x", + type: "element", + }, ], name: "w:r", type: "element", }, }); }); + + it("should try to split even if elements is empty for text", () => { + const output = splitRunElement( + { + name: "w:r", + type: "element", + elements: [ + { + name: "w:t", + type: "element", + }, + ], + }, + "*", + ); + + expect(output).to.deep.equal({ + left: { + elements: [ + { + attributes: { + "xml:space": "preserve", + }, + elements: [], + name: "w:t", + type: "element", + }, + ], + name: "w:r", + type: "element", + }, + right: { + elements: [], + name: "w:r", + type: "element", + }, + }); + }); + + it("should return empty elements", () => { + const output = splitRunElement( + { + name: "w:r", + type: "element", + }, + "*", + ); + + expect(output).to.deep.equal({ + left: { + elements: [], + name: "w:r", + type: "element", + }, + right: { + elements: [], + name: "w:r", + type: "element", + }, + }); + }); }); }); diff --git a/src/patcher/paragraph-split-inject.ts b/src/patcher/paragraph-split-inject.ts index 1a2f06ad06..a32cea407f 100644 --- a/src/patcher/paragraph-split-inject.ts +++ b/src/patcher/paragraph-split-inject.ts @@ -25,7 +25,7 @@ export const splitRunElement = (runElement: Element, token: string): { readonly runElement.elements ?.map((e, i) => { if (e.type === "element" && e.name === "w:t") { - const text = e.elements?.[0].text as string; + const text = (e.elements?.[0].text as string) ?? ""; const splitText = text.split(token); const newElements = splitText.map((t) => ({ ...e, diff --git a/src/patcher/paragraph-token-replacer.spec.ts b/src/patcher/paragraph-token-replacer.spec.ts index 1ce88b1b80..bcfc72e75c 100644 --- a/src/patcher/paragraph-token-replacer.spec.ts +++ b/src/patcher/paragraph-token-replacer.spec.ts @@ -70,5 +70,96 @@ describe("paragraph-token-replacer", () => { name: "w:p", }); }); + + // Try to fill rest of test coverage + // it("should replace token in paragraph", () => { + // const output = replaceTokenInParagraphElement({ + // paragraphElement: { + // name: "w:p", + // elements: [ + // { + // name: "w:r", + // elements: [ + // { + // name: "w:t", + // elements: [ + // { + // type: "text", + // text: "test ", + // }, + // ], + // }, + // { + // name: "w:t", + // elements: [ + // { + // type: "text", + // text: " hello ", + // }, + // ], + // }, + // ], + // }, + // ], + // }, + // renderedParagraph: { + // index: 0, + // path: [0], + // runs: [ + // { + // end: 4, + // index: 0, + // parts: [ + // { + // end: 4, + // index: 0, + // start: 0, + // text: "test ", + // }, + // ], + // start: 0, + // text: "test ", + // }, + // { + // end: 10, + // index: 0, + // parts: [ + // { + // end: 10, + // index: 0, + // start: 5, + // text: "hello ", + // }, + // ], + // start: 5, + // text: "hello ", + // }, + // ], + // text: "test hello ", + // }, + // originalText: "hello", + // replacementText: "world", + // }); + + // expect(output).to.deep.equal({ + // elements: [ + // { + // elements: [ + // { + // elements: [ + // { + // text: "test world ", + // type: "text", + // }, + // ], + // name: "w:t", + // }, + // ], + // name: "w:r", + // }, + // ], + // name: "w:p", + // }); + // }); }); }); diff --git a/src/patcher/relationship-manager.spec.ts b/src/patcher/relationship-manager.spec.ts index 9161ed1dec..4d22e7df8b 100644 --- a/src/patcher/relationship-manager.spec.ts +++ b/src/patcher/relationship-manager.spec.ts @@ -20,6 +20,32 @@ describe("relationship-manager", () => { }); expect(output).to.deep.equal(2); }); + + it("should work with an empty relationship Id", () => { + const output = getNextRelationshipIndex({ + elements: [ + { + type: "element", + name: "Relationships", + elements: [{ type: "element", name: "Relationship" }], + }, + ], + }); + expect(output).to.deep.equal(1); + }); + + it("should work with no relationships", () => { + const output = getNextRelationshipIndex({ + elements: [ + { + type: "element", + name: "Relationships", + elements: [], + }, + ], + }); + expect(output).to.deep.equal(1); + }); }); describe("appendRelationship", () => { diff --git a/src/patcher/relationship-manager.ts b/src/patcher/relationship-manager.ts index 3779500ffe..4f14035703 100644 --- a/src/patcher/relationship-manager.ts +++ b/src/patcher/relationship-manager.ts @@ -3,15 +3,18 @@ import { Element } from "xml-js"; import { RelationshipType, TargetModeType } from "@file/relationships/relationship/relationship"; import { getFirstLevelElements } from "./util"; -const getIdFromRelationshipId = (relationshipId: string): number => parseInt(relationshipId.substring(3), 10); +const getIdFromRelationshipId = (relationshipId: string): number => { + const output = parseInt(relationshipId.substring(3), 10); + return isNaN(output) ? 0 : output; +}; export const getNextRelationshipIndex = (relationships: Element): number => { const relationshipElements = getFirstLevelElements(relationships, "Relationships"); return ( - (relationshipElements + relationshipElements .map((e) => getIdFromRelationshipId(e.attributes?.Id?.toString() ?? "")) - .reduce((acc, curr) => Math.max(acc, curr), 0) ?? 0) + 1 + .reduce((acc, curr) => Math.max(acc, curr), 0) + 1 ); }; diff --git a/src/patcher/replacer.spec.ts b/src/patcher/replacer.spec.ts index af747a3f4a..8b15bd1fa9 100644 --- a/src/patcher/replacer.spec.ts +++ b/src/patcher/replacer.spec.ts @@ -1,6 +1,6 @@ import { IViewWrapper } from "@file/document-wrapper"; import { File } from "@file/file"; -import { TextRun } from "@file/paragraph"; +import { Paragraph, TextRun } from "@file/paragraph"; import { IContext } from "@file/xml-components"; import { expect } from "chai"; import * as sinon from "sinon"; @@ -14,41 +14,6 @@ const MOCK_JSON = { { type: "element", name: "w:hdr", - attributes: { - "xmlns:wpc": "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas", - "xmlns:cx": "http://schemas.microsoft.com/office/drawing/2014/chartex", - "xmlns:cx1": "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", - "xmlns:cx2": "http://schemas.microsoft.com/office/drawing/2015/10/21/chartex", - "xmlns:cx3": "http://schemas.microsoft.com/office/drawing/2016/5/9/chartex", - "xmlns:cx4": "http://schemas.microsoft.com/office/drawing/2016/5/10/chartex", - "xmlns:cx5": "http://schemas.microsoft.com/office/drawing/2016/5/11/chartex", - "xmlns:cx6": "http://schemas.microsoft.com/office/drawing/2016/5/12/chartex", - "xmlns:cx7": "http://schemas.microsoft.com/office/drawing/2016/5/13/chartex", - "xmlns:cx8": "http://schemas.microsoft.com/office/drawing/2016/5/14/chartex", - "xmlns:mc": "http://schemas.openxmlformats.org/markup-compatibility/2006", - "xmlns:aink": "http://schemas.microsoft.com/office/drawing/2016/ink", - "xmlns:am3d": "http://schemas.microsoft.com/office/drawing/2017/model3d", - "xmlns:o": "urn:schemas-microsoft-com:office:office", - "xmlns:oel": "http://schemas.microsoft.com/office/2019/extlst", - "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", - "xmlns:m": "http://schemas.openxmlformats.org/officeDocument/2006/math", - "xmlns:v": "urn:schemas-microsoft-com:vml", - "xmlns:wp14": "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing", - "xmlns:wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", - "xmlns:w10": "urn:schemas-microsoft-com:office:word", - "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "xmlns:w14": "http://schemas.microsoft.com/office/word/2010/wordml", - "xmlns:w15": "http://schemas.microsoft.com/office/word/2012/wordml", - "xmlns:w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex", - "xmlns:w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid", - "xmlns:w16": "http://schemas.microsoft.com/office/word/2018/wordml", - "xmlns:w16sdtdh": "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash", - "xmlns:w16se": "http://schemas.microsoft.com/office/word/2015/wordml/symex", - "xmlns:wpg": "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup", - "xmlns:wpi": "http://schemas.microsoft.com/office/word/2010/wordprocessingInk", - "xmlns:wne": "http://schemas.microsoft.com/office/word/2006/wordml", - "xmlns:wps": "http://schemas.microsoft.com/office/word/2010/wordprocessingShape", - }, elements: [ { type: "element", @@ -106,7 +71,7 @@ describe("replacer", () => { }); }); - it("should return the same object if nothing is added", () => { + it("should replace paragraph type", () => { const output = replacer( MOCK_JSON, { @@ -149,5 +114,93 @@ describe("replacer", () => { expect(JSON.stringify(output)).to.contain("Delightful Header"); }); + + it("should replace document type", () => { + const output = replacer( + MOCK_JSON, + { + type: PatchType.DOCUMENT, + children: [new Paragraph("Lorem ipsum paragraph")], + }, + "{{header_adjective}}", + [ + { + text: "This is a {{header_adjective}} don’t you think?", + runs: [ + { + text: "This is a {{head", + parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }], + index: 1, + start: 0, + end: 15, + }, + { text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 }, + { + text: "_adjective}} don’t you think?", + parts: [{ text: "_adjective}} don’t you think?", index: 0, start: 18, end: 46 }], + index: 3, + start: 18, + end: 46, + }, + ], + index: 0, + path: [0, 0, 0], + }, + ], + { + file: {} as unknown as File, + viewWrapper: { + Relationships: {}, + } as unknown as IViewWrapper, + stack: [], + }, + ); + + expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); + }); + + it("should throw an error if the type is not supported", () => { + expect(() => + replacer( + {}, + { + type: PatchType.DOCUMENT, + children: [new Paragraph("Lorem ipsum paragraph")], + }, + "{{header_adjective}}", + [ + { + text: "This is a {{header_adjective}} don’t you think?", + runs: [ + { + text: "This is a {{head", + parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }], + index: 1, + start: 0, + end: 15, + }, + { text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 }, + { + text: "_adjective}} don’t you think?", + parts: [{ text: "_adjective}} don’t you think?", index: 0, start: 18, end: 46 }], + index: 3, + start: 18, + end: 46, + }, + ], + index: 0, + path: [0, 0, 0], + }, + ], + { + file: {} as unknown as File, + viewWrapper: { + Relationships: {}, + } as unknown as IViewWrapper, + stack: [], + }, + ), + ).to.throw(); + }); }); }); diff --git a/src/patcher/replacer.ts b/src/patcher/replacer.ts index 45861de8e7..bb0190ac51 100644 --- a/src/patcher/replacer.ts +++ b/src/patcher/replacer.ts @@ -27,25 +27,31 @@ export const replacer = ( .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); - const elementIndex = getLastElementIndexFromPath(renderedParagraph.path); - // eslint-disable-next-line functional/immutable-data, prefer-destructuring - parentElement.elements?.splice(elementIndex, 1, ...textJson); - } else if (patch.type === PatchType.PARAGRAPH) { - const paragraphElement = goToElementFromPath(json, renderedParagraph.path); - replaceTokenInParagraphElement({ - paragraphElement, - renderedParagraph, - originalText: patchText, - replacementText: SPLIT_TOKEN, - }); + switch (patch.type) { + case PatchType.DOCUMENT: { + const parentElement = goToParentElementFromPath(json, renderedParagraph.path); + const elementIndex = getLastElementIndexFromPath(renderedParagraph.path); + // eslint-disable-next-line functional/immutable-data, prefer-destructuring + parentElement.elements!.splice(elementIndex, 1, ...textJson); + break; + } + case PatchType.PARAGRAPH: + default: { + const paragraphElement = goToElementFromPath(json, renderedParagraph.path); + replaceTokenInParagraphElement({ + paragraphElement, + renderedParagraph, + originalText: patchText, + replacementText: SPLIT_TOKEN, + }); - const index = findRunElementIndexWithToken(paragraphElement, SPLIT_TOKEN); + const index = findRunElementIndexWithToken(paragraphElement, SPLIT_TOKEN); - const { left, right } = splitRunElement(paragraphElement.elements![index], SPLIT_TOKEN); - // eslint-disable-next-line functional/immutable-data - paragraphElement.elements!.splice(index, 1, left, ...textJson, right); + const { left, right } = splitRunElement(paragraphElement.elements![index], SPLIT_TOKEN); + // eslint-disable-next-line functional/immutable-data + paragraphElement.elements!.splice(index, 1, left, ...textJson, right); + break; + } } } diff --git a/src/patcher/traverser.ts b/src/patcher/traverser.ts index 5ee2f792ff..52112e10b9 100644 --- a/src/patcher/traverser.ts +++ b/src/patcher/traverser.ts @@ -20,28 +20,24 @@ export const findLocationOfText = (node: Element, text: string): readonly IRende // eslint-disable-next-line functional/prefer-readonly-type const queue: ElementWrapper[] = [ - ...(elementsToWrapper({ + ...elementsToWrapper({ element: node, index: 0, parent: undefined, - }) ?? []), + }), ]; // eslint-disable-next-line functional/immutable-data let currentNode: ElementWrapper | undefined; while (queue.length > 0) { // eslint-disable-next-line functional/immutable-data - currentNode = queue.shift(); - - if (!currentNode) { - break; - } + currentNode = queue.shift()!; // This is safe because we check the length of the queue if (currentNode.element.name === "w:p") { renderedParagraphs = [...renderedParagraphs, renderParagraphNode(currentNode)]; } else { // eslint-disable-next-line functional/immutable-data - queue.push(...(elementsToWrapper(currentNode) ?? [])); + queue.push(...elementsToWrapper(currentNode)); } }