diff --git a/.nycrc b/.nycrc
index 3dc8ad302a..6c47fc4974 100644
--- a/.nycrc
+++ b/.nycrc
@@ -1,9 +1,9 @@
{
"check-coverage": true,
"statements": 99.79,
- "branches": 98.41,
+ "branches": 98.17,
"functions": 100,
- "lines": 99.73,
+ "lines": 99.78,
"include": [
"src/**/*.ts"
],
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000000..ef4e0bdeb5
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,18 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run Demo",
+ "type": "node",
+ "request": "launch",
+ "runtimeArgs": [
+ "-r",
+ "${workspaceFolder}/node_modules/ts-node/register",
+ "-r",
+ "${workspaceFolder}/node_modules/tsconfig-paths/register"
+ ],
+ "cwd": "${workspaceRoot}",
+ "program": "${workspaceFolder}/demo/85-template-document.ts"
+ }
+ ]
+}
diff --git a/demo/85-template-document.ts b/demo/85-template-document.ts
new file mode 100644
index 0000000000..6e41fa1a43
--- /dev/null
+++ b/demo/85-template-document.ts
@@ -0,0 +1,155 @@
+// Patch a document with patches
+// Import from 'docx' rather than '../build' if you install from npm
+import * as fs from "fs";
+import {
+ ExternalHyperlink,
+ HeadingLevel,
+ ImageRun,
+ Paragraph,
+ patchDocument,
+ PatchType,
+ Table,
+ TableCell,
+ TableRow,
+ TextDirection,
+ TextRun,
+ VerticalAlign,
+} from "../build";
+
+patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), {
+ patches: {
+ name: {
+ type: PatchType.PARAGRAPH,
+ children: [new TextRun("Sir. "), new TextRun("John Doe"), new TextRun("(The Conqueror)")],
+ },
+ table_heading_1: {
+ type: PatchType.PARAGRAPH,
+ children: [new TextRun("Heading wow!")],
+ },
+ item_1: {
+ type: PatchType.PARAGRAPH,
+ children: [
+ new TextRun("#657"),
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: "BBC News Link",
+ }),
+ ],
+ link: "https://www.bbc.co.uk/news",
+ }),
+ ],
+ },
+ paragraph_replace: {
+ type: PatchType.DOCUMENT,
+ children: [
+ new Paragraph("Lorem ipsum paragraph"),
+ new Paragraph("Another paragraph"),
+ new Paragraph({
+ children: [
+ new TextRun("This is a "),
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: "Google Link",
+ }),
+ ],
+ link: "https://www.google.co.uk",
+ }),
+ new ImageRun({ data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }),
+ ],
+ }),
+ ],
+ },
+ header_adjective: {
+ type: PatchType.PARAGRAPH,
+ children: [new TextRun("Delightful Header")],
+ },
+ footer_text: {
+ type: PatchType.PARAGRAPH,
+ children: [
+ new TextRun("replaced just as"),
+ new TextRun(" well"),
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: "BBC News Link",
+ }),
+ ],
+ link: "https://www.bbc.co.uk/news",
+ }),
+ ],
+ },
+ 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: [
+ new Table({
+ rows: [
+ new TableRow({
+ children: [
+ new TableCell({
+ children: [new Paragraph({}), new Paragraph({})],
+ verticalAlign: VerticalAlign.CENTER,
+ }),
+ new TableCell({
+ children: [new Paragraph({}), new Paragraph({})],
+ verticalAlign: VerticalAlign.CENTER,
+ }),
+ new TableCell({
+ children: [new Paragraph({ text: "bottom to top" }), new Paragraph({})],
+ textDirection: TextDirection.BOTTOM_TO_TOP_LEFT_TO_RIGHT,
+ }),
+ new TableCell({
+ children: [new Paragraph({ text: "top to bottom" }), new Paragraph({})],
+ textDirection: TextDirection.TOP_TO_BOTTOM_RIGHT_TO_LEFT,
+ }),
+ ],
+ }),
+ new TableRow({
+ children: [
+ new TableCell({
+ children: [
+ new Paragraph({
+ text: "Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah",
+ heading: HeadingLevel.HEADING_1,
+ }),
+ ],
+ }),
+ new TableCell({
+ children: [
+ new Paragraph({
+ text: "This text should be in the middle of the cell",
+ }),
+ ],
+ verticalAlign: VerticalAlign.CENTER,
+ }),
+ new TableCell({
+ children: [
+ new Paragraph({
+ text: "Text above should be vertical from bottom to top",
+ }),
+ ],
+ verticalAlign: VerticalAlign.CENTER,
+ }),
+ new TableCell({
+ children: [
+ new Paragraph({
+ text: "Text above should be vertical from top to bottom",
+ }),
+ ],
+ verticalAlign: VerticalAlign.CENTER,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ],
+ },
+ },
+}).then((doc) => {
+ fs.writeFileSync("My Document.docx", doc);
+});
diff --git a/demo/86-generate-template.ts b/demo/86-generate-template.ts
new file mode 100644
index 0000000000..e16060f6a6
--- /dev/null
+++ b/demo/86-generate-template.ts
@@ -0,0 +1,20 @@
+// Generate a template document
+// Import from 'docx' rather than '../build' if you install from npm
+import * as fs from "fs";
+import { Document, Packer, Paragraph, TextRun } from "../build";
+
+const doc = new Document({
+ sections: [
+ {
+ children: [
+ new Paragraph({
+ children: [new TextRun("{{template}}")],
+ }),
+ ],
+ },
+ ],
+});
+
+Packer.toBuffer(doc).then((buffer) => {
+ fs.writeFileSync("My Document.docx", buffer);
+});
diff --git a/demo/assets/generated-template.docx b/demo/assets/generated-template.docx
new file mode 100644
index 0000000000..f7244e0c4b
Binary files /dev/null and b/demo/assets/generated-template.docx differ
diff --git a/demo/assets/simple-template.docx b/demo/assets/simple-template.docx
new file mode 100644
index 0000000000..a3e71cdf6f
Binary files /dev/null and b/demo/assets/simple-template.docx differ
diff --git a/docs/contribution-guidelines.md b/docs/contribution-guidelines.md
index dede758ecf..8aca6bcabc 100644
--- a/docs/contribution-guidelines.md
+++ b/docs/contribution-guidelines.md
@@ -1,11 +1,21 @@
# Contribution Guidelines
-- Include documentation reference(s) at the top of each file:
+- Include documentation reference(s) at the top of each file as a comment. For example:
```ts
// http://officeopenxml.com/WPdocument.php
```
+
+ It can be a link to `officeopenxml.com` or `datypic.com` etc.
+ It could also be a reference to the official ECMA-376 standard: https://www.ecma-international.org/publications-and-standards/standards/ecma-376/
+
+- Include a portion of the schema as a comment for cross reference. For example:
+
+ ```ts
+ //
+ ```
+
- Follow Prettier standards, and consider using the [Prettier VSCode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) plugin.
- Follow the `ESLint` rules
diff --git a/package-lock.json b/package-lock.json
index 1d9a691e26..b9b0773cad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@types/chai": "^4.2.15",
+ "@types/chai-as-promised": "^7.1.5",
"@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0",
"@types/prompt": "^1.1.1",
@@ -29,6 +30,7 @@
"@typescript-eslint/parser": "^5.36.1",
"buffer": "^6.0.3",
"chai": "^4.3.6",
+ "chai-as-promised": "^7.1.1",
"cspell": "^6.2.2",
"docsify-cli": "^4.3.0",
"eslint": "^8.23.0",
@@ -1198,6 +1200,15 @@
"integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
"dev": true
},
+ "node_modules/@types/chai-as-promised": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz",
+ "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==",
+ "dev": true,
+ "dependencies": {
+ "@types/chai": "*"
+ }
+ },
"node_modules/@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -2897,6 +2908,18 @@
"node": ">=4"
}
},
+ "node_modules/chai-as-promised": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz",
+ "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==",
+ "dev": true,
+ "dependencies": {
+ "check-error": "^1.0.2"
+ },
+ "peerDependencies": {
+ "chai": ">= 2.1.2 < 5"
+ }
+ },
"node_modules/chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
@@ -12669,6 +12692,15 @@
"integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
"dev": true
},
+ "@types/chai-as-promised": {
+ "version": "7.1.5",
+ "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz",
+ "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==",
+ "dev": true,
+ "requires": {
+ "@types/chai": "*"
+ }
+ },
"@types/color-name": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -13917,6 +13949,15 @@
"type-detect": "^4.0.5"
}
},
+ "chai-as-promised": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz",
+ "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==",
+ "dev": true,
+ "requires": {
+ "check-error": "^1.0.2"
+ }
+ },
"chainsaw": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
diff --git a/package.json b/package.json
index c08a2678b2..621c6b9fb9 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"homepage": "https://github.com/dolanmiu/docx#readme",
"devDependencies": {
"@types/chai": "^4.2.15",
+ "@types/chai-as-promised": "^7.1.5",
"@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0",
"@types/prompt": "^1.1.1",
@@ -76,6 +77,7 @@
"@typescript-eslint/parser": "^5.36.1",
"buffer": "^6.0.3",
"chai": "^4.3.6",
+ "chai-as-promised": "^7.1.1",
"cspell": "^6.2.2",
"docsify-cli": "^4.3.0",
"eslint": "^8.23.0",
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/index.ts b/src/index.ts
index 3d76a0f70f..40b344721b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,3 +5,4 @@ export * from "./file";
export * from "./export";
export * from "./import-dotx";
export * from "./util";
+export * from "./patcher";
diff --git a/src/patcher/content-types-manager.spec.ts b/src/patcher/content-types-manager.spec.ts
new file mode 100644
index 0000000000..0dd8eb38c4
--- /dev/null
+++ b/src/patcher/content-types-manager.spec.ts
@@ -0,0 +1,51 @@
+import { expect } from "chai";
+import { appendContentType } from "./content-types-manager";
+
+describe("content-types-manager", () => {
+ describe("appendContentType", () => {
+ it("should append a content type", () => {
+ const element = {
+ type: "element",
+ name: "xml",
+ elements: [
+ {
+ type: "element",
+ name: "Types",
+ elements: [
+ {
+ type: "element",
+ name: "Default",
+ },
+ ],
+ },
+ ],
+ };
+ appendContentType(element, "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "docx");
+
+ expect(element).to.deep.equal({
+ elements: [
+ {
+ elements: [
+ {
+ name: "Default",
+ type: "element",
+ },
+ {
+ attributes: {
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
+ Extension: "docx",
+ },
+ name: "Default",
+ type: "element",
+ },
+ ],
+ name: "Types",
+ type: "element",
+ },
+ ],
+ name: "xml",
+ type: "element",
+ });
+ });
+ });
+});
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.spec.ts b/src/patcher/from-docx.spec.ts
new file mode 100644
index 0000000000..296c3d93d7
--- /dev/null
+++ b/src/patcher/from-docx.spec.ts
@@ -0,0 +1,370 @@
+import * as chai from "chai";
+import * as sinon from "sinon";
+import * as JSZip from "jszip";
+import * as chaiAsPromised from "chai-as-promised";
+
+import { ExternalHyperlink, ImageRun, Paragraph, TextRun } from "@file/paragraph";
+
+import { patchDocument, PatchType } from "./from-docx";
+
+chai.use(chaiAsPromised);
+const { expect } = chai;
+
+const MOCK_XML = `
+
+
+
+
+
+
+
+
+ Hello World
+
+
+
+
+
+ Hello {{name}},
+
+
+ how are you?
+
+
+
+
+
+ {{paragraph_replace}}
+
+
+
+
+
+ {{table}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{table_heading_1}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Item: {{item_1}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{image_test}}
+
+
+
+
+
+ Thank you
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+describe("from-docx", () => {
+ describe("patchDocument", () => {
+ describe("document.xml and [Content_Types].xml", () => {
+ before(() => {
+ sinon.createStubInstance(JSZip, {});
+ sinon.stub(JSZip, "loadAsync").callsFake(
+ () =>
+ new Promise((resolve) => {
+ const zip = new JSZip();
+
+ zip.file("word/document.xml", MOCK_XML);
+ zip.file("[Content_Types].xml", ``);
+ resolve(zip);
+ }),
+ );
+ });
+
+ after(() => {
+ (JSZip.loadAsync as unknown as sinon.SinonStub).restore();
+ });
+
+ it("should patch the document", async () => {
+ const output = await patchDocument(Buffer.from(""), {
+ patches: {
+ name: {
+ type: PatchType.PARAGRAPH,
+ children: [new TextRun("Sir. "), new TextRun("John Doe"), new TextRun("(The Conqueror)")],
+ },
+ item_1: {
+ type: PatchType.PARAGRAPH,
+ children: [
+ new TextRun("#657"),
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: "BBC News Link",
+ }),
+ ],
+ link: "https://www.bbc.co.uk/news",
+ }),
+ ],
+ },
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ paragraph_replace: {
+ type: PatchType.DOCUMENT,
+ children: [
+ new Paragraph({
+ children: [
+ new TextRun("This is a "),
+ new ExternalHyperlink({
+ children: [
+ new TextRun({
+ text: "Google Link",
+ }),
+ ],
+ link: "https://www.google.co.uk",
+ }),
+ new ImageRun({
+ data: Buffer.from(""),
+ transformation: { width: 100, height: 100 },
+ }),
+ ],
+ }),
+ ],
+ },
+ // 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 patch the document", async () => {
+ const output = await patchDocument(Buffer.from(""), {
+ patches: {},
+ });
+ expect(output).to.not.be.undefined;
+ });
+ });
+
+ describe("document.xml and [Content_Types].xml with relationships", () => {
+ before(() => {
+ 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);
+ }),
+ );
+ });
+
+ after(() => {
+ (JSZip.loadAsync as unknown as sinon.SinonStub).restore();
+ });
+
+ it("should use the relationships file rather than create one", async () => {
+ const output = await 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;
+ });
+ });
+
+ describe("document.xml", () => {
+ before(() => {
+ sinon.createStubInstance(JSZip, {});
+ sinon.stub(JSZip, "loadAsync").callsFake(
+ () =>
+ new Promise((resolve) => {
+ const zip = new JSZip();
+
+ zip.file("word/document.xml", MOCK_XML);
+ resolve(zip);
+ }),
+ );
+ });
+
+ after(() => {
+ (JSZip.loadAsync as unknown as sinon.SinonStub).restore();
+ });
+
+ it("should throw an error if the content types is not found", () =>
+ 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.eventually.be.rejected);
+ });
+ });
+});
diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts
new file mode 100644
index 0000000000..675f72c5a5
--- /dev/null
+++ b/src/patcher/from-docx.ts
@@ -0,0 +1,233 @@
+import * as JSZip from "jszip";
+import { Element, js2xml } from "xml-js";
+
+import { ConcreteHyperlink, ExternalHyperlink, 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 { TargetModeType } from "@file/relationships/relationship/relationship";
+import { uniqueId } from "@util/convenience-functions";
+
+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;
+
+export enum PatchType {
+ DOCUMENT = "file",
+ PARAGRAPH = "paragraph",
+}
+
+type ParagraphPatch = {
+ readonly type: PatchType.PARAGRAPH;
+ readonly children: readonly ParagraphChild[];
+};
+
+type FilePatch = {
+ readonly type: PatchType.DOCUMENT;
+ readonly children: readonly FileChild[];
+};
+
+interface IImageRelationshipAddition {
+ readonly key: string;
+ readonly mediaDatas: readonly IMediaData[];
+}
+
+interface IHyperlinkRelationshipAddition {
+ readonly key: string;
+ readonly hyperlink: { readonly id: string; readonly link: string };
+}
+
+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 contexts = new Map();
+ const file = {
+ Media: new Media(),
+ } as unknown as File;
+
+ const map = new Map();
+
+ // eslint-disable-next-line functional/prefer-readonly-type
+ const imageRelationshipAdditions: IImageRelationshipAddition[] = [];
+ // eslint-disable-next-line functional/prefer-readonly-type
+ const hyperlinkRelationshipAdditions: IHyperlinkRelationshipAddition[] = [];
+ let hasMedia = false;
+
+ for (const [key, value] of Object.entries(zipContent.files)) {
+ const json = toJson(await value.async("text"));
+ if (key.startsWith("word/") && !key.endsWith(".xml.rels")) {
+ const context: IContext = {
+ file,
+ viewWrapper: {
+ Relationships: {
+ createRelationship: (linkId: string, _: string, target: string, __: TargetModeType) => {
+ // eslint-disable-next-line functional/immutable-data
+ hyperlinkRelationshipAdditions.push({
+ key,
+ hyperlink: {
+ id: linkId,
+ link: target,
+ },
+ });
+ },
+ },
+ } as unknown as IViewWrapper,
+ stack: [],
+ };
+ contexts.set(key, context);
+
+ 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,
+ children: patchValue.children.map((element) => {
+ // We need to replace external hyperlinks with concrete hyperlinks
+ if (element instanceof ExternalHyperlink) {
+ const concreteHyperlink = new ConcreteHyperlink(element.options.children, uniqueId());
+ // eslint-disable-next-line functional/immutable-data
+ hyperlinkRelationshipAdditions.push({
+ key,
+ hyperlink: {
+ id: concreteHyperlink.linkId,
+ link: element.options.link,
+ },
+ });
+ return concreteHyperlink;
+ } else {
+ return element;
+ }
+ }),
+ },
+ 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
+ imageRelationshipAdditions.push({
+ key,
+ mediaDatas,
+ });
+ }
+ }
+
+ map.set(key, json);
+ }
+
+ for (const { key, mediaDatas } of imageRelationshipAdditions) {
+ // eslint-disable-next-line functional/immutable-data
+ const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`;
+ 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 (let i = 0; i < mediaDatas.length; i++) {
+ const { fileName } = mediaDatas[i];
+ appendRelationship(
+ relationshipsJson,
+ index + i,
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
+ `media/${fileName}`,
+ );
+ }
+ }
+
+ for (const { key, hyperlink } of hyperlinkRelationshipAdditions) {
+ // eslint-disable-next-line functional/immutable-data
+ const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`;
+
+ const relationshipsJson = map.get(relationshipKey) ?? createRelationshipFile();
+ map.set(relationshipKey, relationshipsJson);
+
+ appendRelationship(
+ relationshipsJson,
+ hyperlink.id,
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
+ hyperlink.link,
+ TargetModeType.EXTERNAL,
+ );
+ }
+
+ 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) {
+ const output = toXml(value);
+
+ zip.file(key, output);
+ }
+
+ for (const { stream, fileName } of file.Media.Array) {
+ zip.file(`word/media/${fileName}`, stream);
+ }
+
+ return zip.generateAsync({
+ type: "nodebuffer",
+ mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ compression: "DEFLATE",
+ });
+};
+
+const toXml = (jsonObj: Element): string => {
+ const output = js2xml(jsonObj);
+ return output;
+};
+
+const createRelationshipFile = (): Element => ({
+ declaration: {
+ attributes: {
+ version: "1.0",
+ encoding: "UTF-8",
+ standalone: "yes",
+ },
+ },
+ elements: [
+ {
+ type: "element",
+ name: "Relationships",
+ attributes: {
+ xmlns: "http://schemas.openxmlformats.org/package/2006/relationships",
+ },
+ elements: [],
+ },
+ ],
+});
diff --git a/src/patcher/index.ts b/src/patcher/index.ts
new file mode 100644
index 0000000000..466cb3eda7
--- /dev/null
+++ b/src/patcher/index.ts
@@ -0,0 +1 @@
+export * from "./from-docx";
diff --git a/src/patcher/paragraph-split-inject.spec.ts b/src/patcher/paragraph-split-inject.spec.ts
new file mode 100644
index 0000000000..5228a88d2d
--- /dev/null
+++ b/src/patcher/paragraph-split-inject.spec.ts
@@ -0,0 +1,224 @@
+import { expect } from "chai";
+
+import { findRunElementIndexWithToken, splitRunElement } from "./paragraph-split-inject";
+
+describe("paragraph-split-inject", () => {
+ describe("findRunElementIndexWithToken", () => {
+ it("should find the index of a run element with a token", () => {
+ const output = findRunElementIndexWithToken(
+ {
+ name: "w:p",
+ type: "element",
+ elements: [
+ {
+ name: "w:r",
+ type: "element",
+ elements: [
+ {
+ name: "w:t",
+ type: "element",
+ elements: [
+ {
+ type: "text",
+ text: "hello world",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ "hello",
+ );
+ 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", () => {
+ it("should split a run element", () => {
+ const output = splitRunElement(
+ {
+ name: "w:r",
+ type: "element",
+ elements: [
+ {
+ name: "w:t",
+ type: "element",
+ elements: [
+ {
+ type: "text",
+ text: "hello*world",
+ },
+ ],
+ },
+ {
+ name: "w:x",
+ type: "element",
+ },
+ ],
+ },
+ "*",
+ );
+
+ expect(output).to.deep.equal({
+ left: {
+ elements: [
+ {
+ attributes: {
+ "xml:space": "preserve",
+ },
+ elements: [
+ {
+ text: "hello",
+ type: "text",
+ },
+ ],
+ name: "w:t",
+ type: "element",
+ },
+ ],
+ name: "w:r",
+ type: "element",
+ },
+ right: {
+ elements: [
+ {
+ attributes: {
+ "xml:space": "preserve",
+ },
+ elements: [
+ {
+ text: "world",
+ type: "text",
+ },
+ ],
+ 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
new file mode 100644
index 0000000000..a32cea407f
--- /dev/null
+++ b/src/patcher/paragraph-split-inject.ts
@@ -0,0 +1,54 @@
+import { Element } from "xml-js";
+import { createTextElementContents, patchSpaceAttribute } from "./util";
+
+export const findRunElementIndexWithToken = (paragraphElement: Element, token: string): number => {
+ for (let i = 0; i < (paragraphElement.elements ?? []).length; i++) {
+ const element = paragraphElement.elements![i];
+ if (element.type === "element" && element.name === "w:r") {
+ const textElement = (element.elements ?? []).filter((e) => e.type === "element" && e.name === "w:t");
+
+ for (const text of textElement) {
+ if ((text.elements?.[0].text as string)?.includes(token)) {
+ return i;
+ }
+ }
+ }
+ }
+
+ throw new Error("Token not found");
+};
+
+export const splitRunElement = (runElement: Element, token: string): { readonly left: Element; readonly right: Element } => {
+ let splitIndex = 0;
+
+ const splitElements =
+ runElement.elements
+ ?.map((e, i) => {
+ if (e.type === "element" && e.name === "w:t") {
+ const text = (e.elements?.[0].text as string) ?? "";
+ const splitText = text.split(token);
+ const newElements = splitText.map((t) => ({
+ ...e,
+ ...patchSpaceAttribute(e),
+ elements: createTextElementContents(t),
+ }));
+ splitIndex = i;
+ return newElements;
+ } else {
+ return e;
+ }
+ })
+ .flat() ?? [];
+
+ const leftRunElement: Element = {
+ ...JSON.parse(JSON.stringify(runElement)),
+ elements: splitElements.slice(0, splitIndex + 1),
+ };
+
+ const rightRunElement: Element = {
+ ...JSON.parse(JSON.stringify(runElement)),
+ elements: splitElements.slice(splitIndex + 1),
+ };
+
+ return { left: leftRunElement, right: rightRunElement };
+};
diff --git a/src/patcher/paragraph-token-replacer.spec.ts b/src/patcher/paragraph-token-replacer.spec.ts
new file mode 100644
index 0000000000..bcfc72e75c
--- /dev/null
+++ b/src/patcher/paragraph-token-replacer.spec.ts
@@ -0,0 +1,165 @@
+import { expect } from "chai";
+
+import { replaceTokenInParagraphElement } from "./paragraph-token-replacer";
+
+describe("paragraph-token-replacer", () => {
+ describe("replaceTokenInParagraphElement", () => {
+ 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: "hello",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ renderedParagraph: {
+ index: 0,
+ path: [0],
+ runs: [
+ {
+ end: 4,
+ index: 0,
+ parts: [
+ {
+ end: 4,
+ index: 0,
+ start: 0,
+ text: "hello",
+ },
+ ],
+ start: 0,
+ text: "hello",
+ },
+ ],
+ text: "hello",
+ },
+ originalText: "hello",
+ replacementText: "world",
+ });
+
+ expect(output).to.deep.equal({
+ elements: [
+ {
+ elements: [
+ {
+ elements: [
+ {
+ text: "world",
+ type: "text",
+ },
+ ],
+ name: "w:t",
+ },
+ ],
+ name: "w:r",
+ },
+ ],
+ 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/paragraph-token-replacer.ts b/src/patcher/paragraph-token-replacer.ts
new file mode 100644
index 0000000000..06593c625f
--- /dev/null
+++ b/src/patcher/paragraph-token-replacer.ts
@@ -0,0 +1,69 @@
+import { Element } from "xml-js";
+
+import { createTextElementContents, patchSpaceAttribute } from "./util";
+import { IRenderedParagraphNode } from "./run-renderer";
+
+enum ReplaceMode {
+ START,
+ MIDDLE,
+ END,
+}
+
+export const replaceTokenInParagraphElement = ({
+ paragraphElement,
+ renderedParagraph,
+ originalText,
+ replacementText,
+}: {
+ readonly paragraphElement: Element;
+ readonly renderedParagraph: IRenderedParagraphNode;
+ readonly originalText: string;
+ readonly replacementText: string;
+}): Element => {
+ const startIndex = renderedParagraph.text.indexOf(originalText);
+ const endIndex = startIndex + originalText.length - 1;
+
+ let replaceMode = ReplaceMode.START;
+
+ for (const run of renderedParagraph.runs) {
+ for (const { text, index, start, end } of run.parts) {
+ switch (replaceMode) {
+ case ReplaceMode.START:
+ if (startIndex >= start) {
+ const partToReplace = run.text.substring(Math.max(startIndex, start), Math.min(endIndex, end) + 1);
+ // We use a token to split the text if the replacement is within the same run
+ // If not, we just add text to the middle of the run later
+ const firstPart = text.replace(partToReplace, replacementText);
+ patchTextElement(paragraphElement.elements![run.index].elements![index], firstPart);
+ replaceMode = ReplaceMode.MIDDLE;
+ continue;
+ }
+ break;
+ case ReplaceMode.MIDDLE:
+ if (endIndex <= end) {
+ const lastPart = text.substring(endIndex - start + 1);
+ patchTextElement(paragraphElement.elements![run.index].elements![index], lastPart);
+ const currentElement = paragraphElement.elements![run.index].elements![index];
+ // We need to add xml:space="preserve" to the last element to preserve the whitespace
+ // Otherwise, the text will be merged with the next element
+ // eslint-disable-next-line functional/immutable-data
+ paragraphElement.elements![run.index].elements![index] = patchSpaceAttribute(currentElement);
+ replaceMode = ReplaceMode.END;
+ } else {
+ patchTextElement(paragraphElement.elements![run.index].elements![index], "");
+ }
+ break;
+ default:
+ }
+ }
+ }
+
+ return paragraphElement;
+};
+
+const patchTextElement = (element: Element, text: string): Element => {
+ // eslint-disable-next-line functional/immutable-data
+ element.elements = createTextElementContents(text);
+
+ return element;
+};
diff --git a/src/patcher/relationship-manager.spec.ts b/src/patcher/relationship-manager.spec.ts
new file mode 100644
index 0000000000..4d22e7df8b
--- /dev/null
+++ b/src/patcher/relationship-manager.spec.ts
@@ -0,0 +1,87 @@
+import { TargetModeType } from "@file/relationships/relationship/relationship";
+import { expect } from "chai";
+
+import { appendRelationship, getNextRelationshipIndex } from "./relationship-manager";
+
+describe("relationship-manager", () => {
+ describe("getNextRelationshipIndex", () => {
+ it("should get next relationship index", () => {
+ const output = getNextRelationshipIndex({
+ elements: [
+ {
+ type: "element",
+ name: "Relationships",
+ elements: [
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ ],
+ },
+ ],
+ });
+ 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", () => {
+ it("should append a relationship", () => {
+ const output = appendRelationship(
+ {
+ elements: [
+ {
+ type: "element",
+ name: "Relationships",
+ elements: [
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ ],
+ },
+ ],
+ },
+ 1,
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
+ "test",
+ TargetModeType.EXTERNAL,
+ );
+ expect(output).to.deep.equal([
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ { type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
+ {
+ attributes: {
+ Id: "rId1",
+ Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
+ TargetMode: TargetModeType.EXTERNAL,
+ Target: "test",
+ },
+ name: "Relationship",
+ type: "element",
+ },
+ ]);
+ });
+ });
+});
diff --git a/src/patcher/relationship-manager.ts b/src/patcher/relationship-manager.ts
new file mode 100644
index 0000000000..4f14035703
--- /dev/null
+++ b/src/patcher/relationship-manager.ts
@@ -0,0 +1,42 @@
+import { Element } from "xml-js";
+
+import { RelationshipType, TargetModeType } from "@file/relationships/relationship/relationship";
+import { getFirstLevelElements } from "./util";
+
+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
+ .map((e) => getIdFromRelationshipId(e.attributes?.Id?.toString() ?? ""))
+ .reduce((acc, curr) => Math.max(acc, curr), 0) + 1
+ );
+};
+
+export const appendRelationship = (
+ relationships: Element,
+ id: number | string,
+ type: RelationshipType,
+ target: string,
+ targetMode?: TargetModeType,
+): readonly Element[] => {
+ const relationshipElements = getFirstLevelElements(relationships, "Relationships");
+ // eslint-disable-next-line functional/immutable-data
+ relationshipElements.push({
+ attributes: {
+ Id: `rId${id}`,
+ Type: type,
+ Target: target,
+ TargetMode: targetMode,
+ },
+ name: "Relationship",
+ type: "element",
+ });
+
+ return relationshipElements;
+};
diff --git a/src/patcher/replacer.spec.ts b/src/patcher/replacer.spec.ts
new file mode 100644
index 0000000000..8b15bd1fa9
--- /dev/null
+++ b/src/patcher/replacer.spec.ts
@@ -0,0 +1,206 @@
+import { IViewWrapper } from "@file/document-wrapper";
+import { File } from "@file/file";
+import { Paragraph, TextRun } from "@file/paragraph";
+import { IContext } from "@file/xml-components";
+import { expect } from "chai";
+import * as sinon from "sinon";
+
+import { PatchType } from "./from-docx";
+
+import { replacer } from "./replacer";
+
+const MOCK_JSON = {
+ elements: [
+ {
+ type: "element",
+ name: "w:hdr",
+ elements: [
+ {
+ type: "element",
+ name: "w:p",
+ attributes: { "w14:paraId": "3BE1A671", "w14:textId": "74E856C4", "w:rsidR": "000D38A7", "w:rsidRDefault": "000D38A7" },
+ elements: [
+ {
+ type: "element",
+ name: "w:pPr",
+ elements: [{ type: "element", name: "w:pStyle", attributes: { "w:val": "Header" } }],
+ },
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "This is a {{head" }] }],
+ },
+ {
+ type: "element",
+ name: "w:r",
+ attributes: { "w:rsidR": "004A3A99" },
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "er" }] }],
+ },
+ {
+ type: "element",
+ name: "w:r",
+ elements: [
+ { type: "element", name: "w:t", elements: [{ type: "text", text: "_adjective}} don’t you think?" }] },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+describe("replacer", () => {
+ describe("replacer", () => {
+ it("should return the same object if nothing is added", () => {
+ const output = replacer(
+ {
+ elements: [],
+ },
+ {
+ type: PatchType.PARAGRAPH,
+ children: [],
+ },
+ "hello",
+ [],
+ sinon.mock() as unknown as IContext,
+ );
+
+ expect(output).to.deep.equal({
+ elements: [],
+ });
+ });
+
+ it("should replace paragraph type", () => {
+ const output = replacer(
+ MOCK_JSON,
+ {
+ type: PatchType.PARAGRAPH,
+ children: [new TextRun("Delightful Header")],
+ },
+ "{{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("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
new file mode 100644
index 0000000000..bb0190ac51
--- /dev/null
+++ b/src/patcher/replacer.ts
@@ -0,0 +1,83 @@
+import { Element } from "xml-js";
+import * as xml from "xml";
+
+import { Formatter } from "@export/formatter";
+import { IContext, XmlComponent } from "@file/xml-components";
+
+import { IPatch, PatchType } from "./from-docx";
+import { toJson } from "./util";
+import { IRenderedParagraphNode } from "./run-renderer";
+import { replaceTokenInParagraphElement } from "./paragraph-token-replacer";
+import { findRunElementIndexWithToken, splitRunElement } from "./paragraph-split-inject";
+
+const formatter = new Formatter();
+
+const SPLIT_TOKEN = "ɵ";
+
+export const replacer = (
+ json: Element,
+ patch: IPatch,
+ patchText: string,
+ renderedParagraphs: readonly IRenderedParagraphNode[],
+ context: IContext,
+): Element => {
+ for (const renderedParagraph of renderedParagraphs) {
+ 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]);
+
+ 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 { left, right } = splitRunElement(paragraphElement.elements![index], SPLIT_TOKEN);
+ // eslint-disable-next-line functional/immutable-data
+ paragraphElement.elements!.splice(index, 1, left, ...textJson, right);
+ break;
+ }
+ }
+ }
+
+ return json;
+};
+
+const goToElementFromPath = (json: Element, path: readonly number[]): Element => {
+ let element = json;
+
+ // We start from 1 because the first element is the root element
+ // Which we do not want to double count
+ for (let i = 1; i < path.length; i++) {
+ const index = path[i];
+ const nextElements = element.elements;
+
+ if (!nextElements) {
+ throw new Error("Could not find element");
+ }
+
+ element = nextElements[index];
+ }
+
+ return element;
+};
+
+const goToParentElementFromPath = (json: Element, path: readonly number[]): Element =>
+ goToElementFromPath(json, path.slice(0, path.length - 1));
+
+const getLastElementIndexFromPath = (path: readonly number[]): number => path[path.length - 1];
diff --git a/src/patcher/run-renderer.spec.ts b/src/patcher/run-renderer.spec.ts
new file mode 100644
index 0000000000..949f4a7635
--- /dev/null
+++ b/src/patcher/run-renderer.spec.ts
@@ -0,0 +1,96 @@
+import { expect } from "chai";
+import { renderParagraphNode } from "./run-renderer";
+
+describe("run-renderer", () => {
+ describe("renderParagraphNode", () => {
+ it("should return a rendered paragraph node if theres no elements", () => {
+ const output = renderParagraphNode({ element: { name: "w:p" }, index: 0, parent: undefined });
+ expect(output).to.deep.equal({
+ index: -1,
+ path: [],
+ runs: [],
+ text: "",
+ });
+ });
+
+ it("should return a rendered paragraph node if there are elements", () => {
+ const output = renderParagraphNode({
+ element: {
+ name: "w:p",
+ elements: [
+ {
+ name: "w:r",
+ elements: [
+ {
+ name: "w:t",
+ elements: [
+ {
+ type: "text",
+ text: "hello",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ index: 0,
+ parent: undefined,
+ });
+ expect(output).to.deep.equal({
+ index: 0,
+ path: [0],
+ runs: [
+ {
+ end: 4,
+ index: 0,
+ parts: [
+ {
+ end: 4,
+ index: 0,
+ start: 0,
+ text: "hello",
+ },
+ ],
+ start: 0,
+ text: "hello",
+ },
+ ],
+ text: "hello",
+ });
+ });
+
+ it("should throw an error if the element is not a paragraph", () => {
+ expect(() => renderParagraphNode({ element: { name: "w:r" }, index: 0, parent: undefined })).to.throw();
+ });
+
+ it("should return blank defaults if run is empty", () => {
+ const output = renderParagraphNode({
+ element: {
+ name: "w:p",
+ elements: [
+ {
+ name: "w:r",
+ },
+ ],
+ },
+ index: 0,
+ parent: undefined,
+ });
+ expect(output).to.deep.equal({
+ index: 0,
+ path: [0],
+ runs: [
+ {
+ end: 0,
+ index: -1,
+ parts: [],
+ start: 0,
+ text: "",
+ },
+ ],
+ text: "",
+ });
+ });
+ });
+});
diff --git a/src/patcher/run-renderer.ts b/src/patcher/run-renderer.ts
new file mode 100644
index 0000000000..262d6f265a
--- /dev/null
+++ b/src/patcher/run-renderer.ts
@@ -0,0 +1,109 @@
+import { Element } from "xml-js";
+
+import { ElementWrapper } from "./traverser";
+
+export interface IRenderedParagraphNode {
+ readonly text: string;
+ readonly runs: readonly IRenderedRunNode[];
+ readonly index: number;
+ readonly path: readonly number[];
+}
+
+interface StartAndEnd {
+ readonly start: number;
+ readonly end: number;
+}
+
+type IParts = {
+ readonly text: string;
+ readonly index: number;
+} & StartAndEnd;
+
+export type IRenderedRunNode = {
+ readonly text: string;
+ readonly parts: readonly IParts[];
+ readonly index: number;
+} & StartAndEnd;
+
+export const renderParagraphNode = (node: ElementWrapper): IRenderedParagraphNode => {
+ if (node.element.name !== "w:p") {
+ throw new Error(`Invalid node type: ${node.element.name}`);
+ }
+
+ if (!node.element.elements) {
+ return {
+ text: "",
+ runs: [],
+ index: -1,
+ path: [],
+ };
+ }
+
+ let currentRunStringLength = 0;
+
+ const runs = node.element.elements
+ .map((element, i) => ({ element, i }))
+ .filter(({ element }) => element.name === "w:r")
+ .map(({ element, i }) => {
+ const renderedRunNode = renderRunNode(element, i, currentRunStringLength);
+ currentRunStringLength += renderedRunNode.text.length;
+
+ return renderedRunNode;
+ })
+ .filter((e) => !!e)
+ .map((e) => e as IRenderedRunNode);
+
+ const text = runs.reduce((acc, curr) => acc + curr.text, "");
+
+ return {
+ text,
+ runs,
+ index: node.index,
+ path: buildNodePath(node),
+ };
+};
+
+const renderRunNode = (node: Element, index: number, currentRunStringIndex: number): IRenderedRunNode => {
+ if (!node.elements) {
+ return {
+ text: "",
+ parts: [],
+ index: -1,
+ start: currentRunStringIndex,
+ end: currentRunStringIndex,
+ };
+ }
+
+ let currentTextStringIndex = currentRunStringIndex;
+
+ const parts = node.elements
+ .map((element, i: number) =>
+ element.name === "w:t" && element.elements && element.elements.length > 0
+ ? {
+ text: element.elements[0].text?.toString() ?? "",
+ index: i,
+ start: currentTextStringIndex,
+ end: (() => {
+ // Side effect
+ currentTextStringIndex += (element.elements[0].text?.toString() ?? "").length - 1;
+ return currentTextStringIndex;
+ })(),
+ }
+ : undefined,
+ )
+ .filter((e) => !!e)
+ .map((e) => e as IParts);
+
+ const text = parts.reduce((acc, curr) => acc + curr.text, "");
+
+ return {
+ text,
+ parts,
+ index,
+ start: currentRunStringIndex,
+ end: currentTextStringIndex,
+ };
+};
+
+const buildNodePath = (node: ElementWrapper): readonly number[] =>
+ node.parent ? [...buildNodePath(node.parent), node.index] : [node.index];
diff --git a/src/patcher/traverser.spec.ts b/src/patcher/traverser.spec.ts
new file mode 100644
index 0000000000..d961bae0bb
--- /dev/null
+++ b/src/patcher/traverser.spec.ts
@@ -0,0 +1,599 @@
+import { expect } from "chai";
+
+import { findLocationOfText } from "./traverser";
+
+const MOCK_JSON = {
+ elements: [
+ {
+ type: "element",
+ name: "w:document",
+ 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",
+ name: "w:body",
+ elements: [
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "2499FE9F",
+ "w14:textId": "0A3D130F",
+ "w:rsidR": "00B51233",
+ "w:rsidRDefault": "007B52ED",
+ "w:rsidP": "007B52ED",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:pPr",
+ elements: [{ type: "element", name: "w:pStyle", attributes: { "w:val": "Title" } }],
+ },
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "Hello World" }] }],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "6410D9A0",
+ "w14:textId": "7579AB49",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "57ACF964",
+ "w14:textId": "315D7A05",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "Hello {{name}}," }] }],
+ },
+ {
+ type: "element",
+ name: "w:r",
+ attributes: { "w:rsidR": "008126CB" },
+ elements: [
+ {
+ type: "element",
+ name: "w:t",
+ attributes: { "xml:space": "preserve" },
+ elements: [{ type: "text", text: " how are you?" }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "38C7DF4A",
+ "w14:textId": "66CDEC9A",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "04FABE2B",
+ "w14:textId": "3DACA001",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [
+ { type: "element", name: "w:t", elements: [{ type: "text", text: "{{paragraph_replace}}" }] },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "7AD7975D",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "3BD6D75A",
+ "w14:textId": "19AE3121",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "{{table}}" }] }],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "76023962",
+ "w14:textId": "4E606AB9",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ },
+ {
+ type: "element",
+ name: "w:tbl",
+ elements: [
+ {
+ type: "element",
+ name: "w:tblPr",
+ elements: [
+ { type: "element", name: "w:tblStyle", attributes: { "w:val": "TableGrid" } },
+ { type: "element", name: "w:tblW", attributes: { "w:w": "0", "w:type": "auto" } },
+ {
+ type: "element",
+ name: "w:tblLook",
+ attributes: {
+ "w:val": "04A0",
+ "w:firstRow": "1",
+ "w:lastRow": "0",
+ "w:firstColumn": "1",
+ "w:lastColumn": "0",
+ "w:noHBand": "0",
+ "w:noVBand": "1",
+ },
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tblGrid",
+ elements: [
+ { type: "element", name: "w:gridCol", attributes: { "w:w": "3003" } },
+ { type: "element", name: "w:gridCol", attributes: { "w:w": "3003" } },
+ { type: "element", name: "w:gridCol", attributes: { "w:w": "3004" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tr",
+ attributes: {
+ "w:rsidR": "00EF161F",
+ "w14:paraId": "1DEC5955",
+ "w14:textId": "77777777",
+ "w:rsidTr": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "54DA5587",
+ "w14:textId": "625BAC60",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [
+ {
+ type: "element",
+ name: "w:t",
+ elements: [{ type: "text", text: "{{table_heading_1}}" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "57100910",
+ "w14:textId": "71FD5616",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3004", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "1D388FAB",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tr",
+ attributes: {
+ "w:rsidR": "00EF161F",
+ "w14:paraId": "0F53D2DC",
+ "w14:textId": "77777777",
+ "w:rsidTr": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "0F2BCCED",
+ "w14:textId": "3C3B6706",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [
+ {
+ type: "element",
+ name: "w:t",
+ elements: [{ type: "text", text: "Item: {{item_1}}" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "1E6158AC",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3004", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "17937748",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tr",
+ attributes: {
+ "w:rsidR": "00EF161F",
+ "w14:paraId": "781DAC1A",
+ "w14:textId": "77777777",
+ "w:rsidTr": "00EF161F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "1DCD0343",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3003", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "5D02E3CD",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:tc",
+ elements: [
+ {
+ type: "element",
+ name: "w:tcPr",
+ elements: [
+ { type: "element", name: "w:tcW", attributes: { "w:w": "3004", "w:type": "dxa" } },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "52EA0DBB",
+ "w14:textId": "77777777",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "00EF161F",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "47CD1FBC",
+ "w14:textId": "23474CBC",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "0ACCEE90",
+ "w14:textId": "67907499",
+ "w:rsidR": "00EF161F",
+ "w:rsidRDefault": "0077578F",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "{{image_test}}" }] }],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "23FA9862",
+ "w14:textId": "77777777",
+ "w:rsidR": "0077578F",
+ "w:rsidRDefault": "0077578F",
+ },
+ },
+ {
+ type: "element",
+ name: "w:p",
+ attributes: {
+ "w14:paraId": "01578F2F",
+ "w14:textId": "3BDC6C85",
+ "w:rsidR": "007B52ED",
+ "w:rsidRDefault": "007B52ED",
+ },
+ elements: [
+ {
+ type: "element",
+ name: "w:r",
+ elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "Thank you" }] }],
+ },
+ ],
+ },
+ {
+ type: "element",
+ name: "w:sectPr",
+ attributes: { "w:rsidR": "007B52ED", "w:rsidSect": "0072043F" },
+ elements: [
+ { type: "element", name: "w:headerReference", attributes: { "w:type": "default", "r:id": "rId6" } },
+ { type: "element", name: "w:footerReference", attributes: { "w:type": "default", "r:id": "rId7" } },
+ { type: "element", name: "w:pgSz", attributes: { "w:w": "11900", "w:h": "16840" } },
+ {
+ type: "element",
+ name: "w:pgMar",
+ attributes: {
+ "w:top": "1440",
+ "w:right": "1440",
+ "w:bottom": "1440",
+ "w:left": "1440",
+ "w:header": "708",
+ "w:footer": "708",
+ "w:gutter": "0",
+ },
+ },
+ { type: "element", name: "w:cols", attributes: { "w:space": "708" } },
+ { type: "element", name: "w:docGrid", attributes: { "w:linePitch": "360" } },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
+
+describe("traverser", () => {
+ describe("findLocationOfText", () => {
+ it("should find the location of text", () => {
+ const output = findLocationOfText(MOCK_JSON, "{{table_heading_1}}");
+ expect(output).to.deep.equal([
+ {
+ index: 1,
+ path: [0, 0, 0, 8, 2, 0, 1],
+ runs: [
+ {
+ end: 18,
+ index: 0,
+ parts: [
+ {
+ end: 18,
+ index: 0,
+ start: 0,
+ text: "{{table_heading_1}}",
+ },
+ ],
+ start: 0,
+ text: "{{table_heading_1}}",
+ },
+ ],
+ text: "{{table_heading_1}}",
+ },
+ ]);
+ });
+ });
+});
diff --git a/src/patcher/traverser.ts b/src/patcher/traverser.ts
new file mode 100644
index 0000000000..52112e10b9
--- /dev/null
+++ b/src/patcher/traverser.ts
@@ -0,0 +1,45 @@
+import { Element } from "xml-js";
+
+import { IRenderedParagraphNode, renderParagraphNode } from "./run-renderer";
+
+export interface ElementWrapper {
+ readonly element: Element;
+ readonly index: number;
+ readonly parent: ElementWrapper | undefined;
+}
+
+const elementsToWrapper = (wrapper: ElementWrapper): readonly ElementWrapper[] =>
+ wrapper.element.elements?.map((e, i) => ({
+ element: e,
+ index: i,
+ parent: wrapper,
+ })) ?? [];
+
+export const findLocationOfText = (node: Element, text: string): readonly IRenderedParagraphNode[] => {
+ let renderedParagraphs: readonly IRenderedParagraphNode[] = [];
+
+ // eslint-disable-next-line functional/prefer-readonly-type
+ const queue: ElementWrapper[] = [
+ ...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()!; // 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));
+ }
+ }
+
+ return renderedParagraphs.filter((p) => p.text.includes(text));
+};
diff --git a/src/patcher/util.spec.ts b/src/patcher/util.spec.ts
new file mode 100644
index 0000000000..c303b4ffe6
--- /dev/null
+++ b/src/patcher/util.spec.ts
@@ -0,0 +1,50 @@
+import { expect } from "chai";
+
+import { createTextElementContents, getFirstLevelElements, patchSpaceAttribute, toJson } from "./util";
+
+describe("util", () => {
+ describe("toJson", () => {
+ it("should return an Element", () => {
+ const output = toJson("");
+ expect(output).to.be.an("object");
+ });
+ });
+
+ describe("createTextElementContents", () => {
+ it("should return an array of elements", () => {
+ const output = createTextElementContents("hello");
+ expect(output).to.deep.equal([{ type: "text", text: "hello" }]);
+ });
+ });
+
+ describe("patchSpaceAttribute", () => {
+ it("should return an element with the xml:space attribute", () => {
+ const output = patchSpaceAttribute({ type: "element", name: "xml" });
+ expect(output).to.deep.equal({
+ type: "element",
+ name: "xml",
+ attributes: {
+ "xml:space": "preserve",
+ },
+ });
+ });
+ });
+
+ describe("getFirstLevelElements", () => {
+ it("should return an empty array if no elements are found", () => {
+ const elements = getFirstLevelElements(
+ { elements: [{ type: "element", name: "Relationships", elements: [] }] },
+ "Relationships",
+ );
+ expect(elements).to.deep.equal([]);
+ });
+
+ it("should return an array if elements are found", () => {
+ const elements = getFirstLevelElements(
+ { elements: [{ type: "element", name: "Relationships", elements: [{ type: "element", name: "Relationship" }] }] },
+ "Relationships",
+ );
+ expect(elements).to.deep.equal([{ type: "element", name: "Relationship" }]);
+ });
+ });
+});
diff --git a/src/patcher/util.ts b/src/patcher/util.ts
new file mode 100644
index 0000000000..6f9ed57dd0
--- /dev/null
+++ b/src/patcher/util.ts
@@ -0,0 +1,30 @@
+import { xml2js, Element } from "xml-js";
+import * as xml from "xml";
+
+import { Formatter } from "@export/formatter";
+import { Text } from "@file/paragraph/run/run-components/text";
+
+const formatter = new Formatter();
+
+export const toJson = (xmlData: string): Element => {
+ const xmlObj = xml2js(xmlData, { compact: false }) as Element;
+ return xmlObj;
+};
+
+// eslint-disable-next-line functional/prefer-readonly-type
+export const createTextElementContents = (text: string): Element[] => {
+ const textJson = toJson(xml(formatter.format(new Text({ text }))));
+
+ return textJson.elements![0].elements ?? [];
+};
+
+export const patchSpaceAttribute = (element: Element): Element => ({
+ ...element,
+ attributes: {
+ "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 ?? [];
diff --git a/tsconfig.json b/tsconfig.json
index 53cf5a12fe..41262ad1d5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -20,5 +20,10 @@
"@shared": ["./shared/index.ts"]
}
},
+ "ts-node": {
+ "compilerOptions": {
+ "module": "commonjs"
+ }
+ },
"include": ["src"]
}