diff --git a/demo/93-template-document.ts b/demo/93-template-document.ts new file mode 100644 index 00000000000..7f5263004de --- /dev/null +++ b/demo/93-template-document.ts @@ -0,0 +1,72 @@ +// Patch a document with patches + +import * as fs from "fs"; +import { patchDocument, PatchType, TextRun } from "docx"; + +patchDocument({ + outputType: "nodebuffer", + data: fs.readFileSync("demo/assets/field-trip.docx"), + patches: { + todays_date: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: new Date().toLocaleDateString() })], + }, + + school_name: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + address: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "blah blah" })], + }, + + city: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + state: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + zip: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + phone: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + first_name: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + last_name: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + email_address: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + ft_dates: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + + grade: { + type: PatchType.PARAGRAPH, + children: [new TextRun({ text: "test" })], + }, + }, +}).then((doc) => { + fs.writeFileSync("My Document.docx", doc); +}); diff --git a/demo/assets/field-trip.docx b/demo/assets/field-trip.docx new file mode 100644 index 00000000000..a7415962bbd Binary files /dev/null and b/demo/assets/field-trip.docx differ diff --git a/src/patcher/paragraph-split-inject.spec.ts b/src/patcher/paragraph-split-inject.spec.ts index 6d58c2f0f12..7b30dd36ade 100644 --- a/src/patcher/paragraph-split-inject.spec.ts +++ b/src/patcher/paragraph-split-inject.spec.ts @@ -273,5 +273,65 @@ describe("paragraph-split-inject", () => { }, }); }); + + it("should create an empty end element if it is at the end", () => { + const output = splitRunElement( + { + type: "element", + name: "w:r", + elements: [ + { + type: "element", + name: "w:rPr", + elements: [ + { type: "element", name: "w:rFonts", attributes: { "w:eastAsia": "Times New Roman" } }, + { type: "element", name: "w:kern", attributes: { "w:val": "0" } }, + { type: "element", name: "w:sz", attributes: { "w:val": "20" } }, + { + type: "element", + name: "w:lang", + attributes: { "w:val": "en-US", "w:eastAsia": "en-US", "w:bidi": "ar-SA" }, + }, + ], + }, + { type: "element", name: "w:t", elements: [], attributes: { "xml:space": "preserve" } }, + { type: "element", name: "w:br" }, + { type: "element", name: "w:t", elements: [{ type: "text", text: "ɵ" }] }, + ], + }, + "ɵ", + ); + + expect(output).to.deep.equal({ + left: { + type: "element", + name: "w:r", + elements: [ + { + type: "element", + name: "w:rPr", + elements: [ + { type: "element", name: "w:rFonts", attributes: { "w:eastAsia": "Times New Roman" } }, + { type: "element", name: "w:kern", attributes: { "w:val": "0" } }, + { type: "element", name: "w:sz", attributes: { "w:val": "20" } }, + { + type: "element", + name: "w:lang", + attributes: { "w:val": "en-US", "w:eastAsia": "en-US", "w:bidi": "ar-SA" }, + }, + ], + }, + { type: "element", name: "w:t", elements: [], attributes: { "xml:space": "preserve" } }, + { type: "element", name: "w:br" }, + { type: "element", name: "w:t", elements: [], attributes: { "xml:space": "preserve" } }, + ], + }, + right: { + type: "element", + name: "w:r", + elements: [{ type: "element", name: "w:t", elements: [], attributes: { "xml:space": "preserve" } }], + }, + }); + }); }); }); diff --git a/src/patcher/paragraph-split-inject.ts b/src/patcher/paragraph-split-inject.ts index d7485e4991e..008d47c7fc2 100644 --- a/src/patcher/paragraph-split-inject.ts +++ b/src/patcher/paragraph-split-inject.ts @@ -29,7 +29,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/patch-detector.spec.ts b/src/patcher/patch-detector.spec.ts index 961502c11a0..dee2bb7ecc4 100644 --- a/src/patcher/patch-detector.spec.ts +++ b/src/patcher/patch-detector.spec.ts @@ -195,6 +195,147 @@ const MOCK_XML = ` `; +// cspell:disable +const MOCK_XML_2 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ + + + + + + + + + s + + + + + + + + + chool_ + + + + + + + + + n + + + + + + + + + ame}} + + {{ + + + + + + + + + a + + + + + + + + + ddr + + + + + + + + + ess + + + + + + + + + }} + + {{ + + + + + + +`; +// cspell:enable + describe("patch-detector", () => { describe("patchDetector", () => { describe("document.xml and [Content_Types].xml", () => { @@ -222,4 +363,31 @@ describe("patch-detector", () => { }); }); }); + + describe("patchDetector", () => { + describe("document.xml and [Content_Types].xml", () => { + beforeEach(() => { + vi.spyOn(JSZip, "loadAsync").mockReturnValue( + new Promise((resolve) => { + const zip = new JSZip(); + + zip.file("word/document.xml", MOCK_XML_2); + zip.file("[Content_Types].xml", ``); + resolve(zip); + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should patch the document", async () => { + const output = await patchDetector({ + data: Buffer.from(""), + }); + expect(output).toMatchObject(["school_name", "address"]); + }); + }); + }); }); diff --git a/src/patcher/replacer.spec.ts b/src/patcher/replacer.spec.ts index 903ef0bd2b2..12fdb27dc7e 100644 --- a/src/patcher/replacer.spec.ts +++ b/src/patcher/replacer.spec.ts @@ -200,5 +200,454 @@ describe("replacer", () => { expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); }); + + it("should replace", () => { + // cspell:disable + const output = replacer({ + json: { + elements: [ + { + type: "element", + name: "w:hdr", + elements: [ + { + type: "element", + name: "w:p", + elements: [ + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "{{" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "s" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "chool_" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "n" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "{{" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "a" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "ddr" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "ess" }], + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:r", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rPr", + elements: [ + { type: "text", text: "\n " }, + { + type: "element", + name: "w:rFonts", + attributes: { "w:eastAsia": "Times New Roman" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:kern", + attributes: { "w:val": "0" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:sz", + attributes: { "w:val": "20" }, + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:lang", + attributes: { + "w:val": "en-US", + "w:eastAsia": "en-US", + "w:bidi": "ar-SA", + }, + }, + { type: "text", text: "\n " }, + ], + }, + { type: "text", text: "\n " }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "}}" }], + }, + ], + }, + ], + }, + ], + }, + ], + }, + // cspell:enable + patch: { + type: PatchType.PARAGRAPH, + children: [new Paragraph("Lorem ipsum paragraph")], + }, + patchText: "{{address}}", + context: { + file: {} as unknown as File, + viewWrapper: { + Relationships: {}, + } as unknown as IViewWrapper, + stack: [], + }, + }); + + expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); + }); }); }); diff --git a/src/patcher/replacer.ts b/src/patcher/replacer.ts index ab069457586..f9670c1fb02 100644 --- a/src/patcher/replacer.ts +++ b/src/patcher/replacer.ts @@ -67,7 +67,7 @@ export const replacer = ({ if (keepOriginalStyles) { const runElementNonTextualElements = runElementToBeReplaced.elements!.filter( - (e) => e.type === "element" && e.name !== "w:t", + (e) => e.type === "element" && e.name !== "w:t" && e.name !== "w:br", ); newRunElements = textJson.map((e) => ({