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"] }