From 13cf3eee5a8a80fa6037e30a1e566625ddc05190 Mon Sep 17 00:00:00 2001 From: Dolan Date: Sun, 31 Dec 2023 23:16:48 +0000 Subject: [PATCH] Feature/multiple patch document exports (#2497) * Turn patch document into options object Add outputType to options * Set keep styles to true by default * Simplify method * Rename variable * #2267 Multiple patches of same key * Remove path which won't be visited --- demo/85-template-document.ts | 18 +- demo/87-template-document.ts | 4 +- demo/88-template-document.ts | 4 +- demo/89-template-document.ts | 5 +- demo/assets/simple-template-3.docx | Bin 16452 -> 16818 bytes src/patcher/from-docx.spec.ts | 20 +- src/patcher/from-docx.ts | 103 +++++++---- src/patcher/paragraph-token-replacer.spec.ts | 4 +- src/patcher/replacer.spec.ts | 181 ++++--------------- src/patcher/replacer.ts | 41 +++-- src/patcher/run-renderer.spec.ts | 6 +- src/patcher/run-renderer.ts | 9 +- src/patcher/traverser.spec.ts | 134 +++++++++++++- 13 files changed, 309 insertions(+), 220 deletions(-) diff --git a/demo/85-template-document.ts b/demo/85-template-document.ts index 0b509ecaee3..38ab563156c 100644 --- a/demo/85-template-document.ts +++ b/demo/85-template-document.ts @@ -16,7 +16,9 @@ import { VerticalAlign, } from "docx"; -patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { +patchDocument({ + outputType: "nodebuffer", + data: fs.readFileSync("demo/assets/simple-template.docx"), patches: { name: { type: PatchType.PARAGRAPH, @@ -56,7 +58,11 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { ], link: "https://www.google.co.uk", }), - new ImageRun({ data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }), + new ImageRun({ + type: "png", + data: fs.readFileSync("./demo/images/dog.png"), + transformation: { width: 100, height: 100 }, + }), ], }), ], @@ -82,7 +88,13 @@ patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { }, image_test: { type: PatchType.PARAGRAPH, - children: [new ImageRun({ data: fs.readFileSync("./demo/images/image1.jpeg"), transformation: { width: 100, height: 100 } })], + children: [ + new ImageRun({ + type: "jpg", + data: fs.readFileSync("./demo/images/image1.jpeg"), + transformation: { width: 100, height: 100 }, + }), + ], }, table: { type: PatchType.DOCUMENT, diff --git a/demo/87-template-document.ts b/demo/87-template-document.ts index 0dd2c1761db..d91b93e5992 100644 --- a/demo/87-template-document.ts +++ b/demo/87-template-document.ts @@ -3,7 +3,9 @@ import * as fs from "fs"; import { patchDocument, PatchType, TextRun } from "docx"; -patchDocument(fs.readFileSync("demo/assets/simple-template-2.docx"), { +patchDocument({ + outputType: "nodebuffer", + data: fs.readFileSync("demo/assets/simple-template-2.docx"), patches: { name: { type: PatchType.PARAGRAPH, diff --git a/demo/88-template-document.ts b/demo/88-template-document.ts index 705a5c6f304..b14e076d17e 100644 --- a/demo/88-template-document.ts +++ b/demo/88-template-document.ts @@ -24,7 +24,9 @@ const patches = getPatches({ paragraph_replace: "Lorem ipsum paragraph", }); -patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), { +patchDocument({ + outputType: "nodebuffer", + data: fs.readFileSync("demo/assets/simple-template.docx"), patches, }).then((doc) => { fs.writeFileSync("My Document.docx", doc); diff --git a/demo/89-template-document.ts b/demo/89-template-document.ts index 3b958c78647..ffd1e954c7d 100644 --- a/demo/89-template-document.ts +++ b/demo/89-template-document.ts @@ -22,8 +22,11 @@ const patches = getPatches({ "first-name": "John", }); -patchDocument(fs.readFileSync("demo/assets/simple-template-3.docx"), { +patchDocument({ + outputType: "nodebuffer", + data: fs.readFileSync("demo/assets/simple-template-3.docx"), patches, + keepOriginalStyles: true, }).then((doc) => { fs.writeFileSync("My Document.docx", doc); }); diff --git a/demo/assets/simple-template-3.docx b/demo/assets/simple-template-3.docx index 8d8fa9aacfe5700030d91465fa8c7be52e9e29dd..6f520f3367a4b3edd74db038a9848a430a78a86c 100644 GIT binary patch delta 6951 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 EA1mJkeE { }); it("should patch the document", async () => { - const output = await patchDocument(Buffer.from(""), { + const output = await patchDocument({ + outputType: "uint8array", + data: Buffer.from(""), patches: { name: { type: PatchType.PARAGRAPH, @@ -279,7 +281,9 @@ describe("from-docx", () => { }); it("should patch the document", async () => { - const output = await patchDocument(Buffer.from(""), { + const output = await patchDocument({ + outputType: "uint8array", + data: Buffer.from(""), patches: {}, }); expect(output).to.not.be.undefined; @@ -305,7 +309,9 @@ describe("from-docx", () => { }); it("should use the relationships file rather than create one", async () => { - const output = await patchDocument(Buffer.from(""), { + const output = await patchDocument({ + outputType: "uint8array", + data: Buffer.from(""), patches: { // eslint-disable-next-line @typescript-eslint/naming-convention image_test: { @@ -350,7 +356,9 @@ describe("from-docx", () => { it("should throw an error if the content types is not found", () => expect( - patchDocument(Buffer.from(""), { + patchDocument({ + outputType: "uint8array", + data: Buffer.from(""), patches: { // eslint-disable-next-line @typescript-eslint/naming-convention image_test: { @@ -388,7 +396,9 @@ describe("from-docx", () => { it("should throw an error if the content types is not found", () => expect( - patchDocument(Buffer.from(""), { + patchDocument({ + outputType: "uint8array", + data: Buffer.from(""), patches: { // eslint-disable-next-line @typescript-eslint/naming-convention image_test: { diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 1098bf05eab..97c5400b4dd 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -12,7 +12,6 @@ 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"; @@ -47,14 +46,37 @@ interface IHyperlinkRelationshipAddition { export type IPatch = ParagraphPatch | FilePatch; -export interface PatchDocumentOptions { +// From JSZip +type OutputByType = { + readonly base64: string; + // eslint-disable-next-line id-denylist + readonly string: string; + readonly text: string; + readonly binarystring: string; + readonly array: readonly number[]; + readonly uint8array: Uint8Array; + readonly arraybuffer: ArrayBuffer; + readonly blob: Blob; + readonly nodebuffer: Buffer; +}; + +export type PatchDocumentOutputType = keyof OutputByType; + +export type PatchDocumentOptions = { + readonly outputType: T; + readonly data: InputDataType; readonly patches: { readonly [key: string]: IPatch }; readonly keepOriginalStyles?: boolean; -} +}; const imageReplacer = new ImageReplacer(); -export const patchDocument = async (data: InputDataType, options: PatchDocumentOptions): Promise => { +export const patchDocument = async ({ + outputType, + data, + patches, + keepOriginalStyles, +}: PatchDocumentOptions): Promise => { const zipContent = await JSZip.loadAsync(data); const contexts = new Map(); const file = { @@ -104,38 +126,48 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO }; contexts.set(key, context); - for (const [patchKey, patchValue] of Object.entries(options.patches)) { + for (const [patchKey, patchValue] of Object.entries(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; - } - }), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any, - patchText, - renderedParagraphs, - context, - options.keepOriginalStyles, - ); + // We need to loop through to catch every occurrence of the patch text + // It is possible that the patch text is in the same run + // This algorithm is limited to one patch per text run + // Once it cannot find any more occurrences, it will throw an error, and then we break out of the loop + // https://github.com/dolanmiu/docx/issues/2267 + // eslint-disable-next-line no-constant-condition + while (true) { + try { + replacer({ + json, + patch: { + ...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; + } + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + patchText, + context, + keepOriginalStyles, + }); + } catch { + break; + } + } } const mediaDatas = imageReplacer.getMediaData(JSON.stringify(json), context.file.Media); @@ -201,6 +233,7 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO appendContentType(contentTypesJson, "image/jpeg", "jpg"); appendContentType(contentTypesJson, "image/bmp", "bmp"); appendContentType(contentTypesJson, "image/gif", "gif"); + appendContentType(contentTypesJson, "image/svg+xml", "svg"); } const zip = new JSZip(); @@ -220,7 +253,7 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO } return zip.generateAsync({ - type: "uint8array", + type: outputType, mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", compression: "DEFLATE", }); diff --git a/src/patcher/paragraph-token-replacer.spec.ts b/src/patcher/paragraph-token-replacer.spec.ts index 62ce1b76224..b6561094b7c 100644 --- a/src/patcher/paragraph-token-replacer.spec.ts +++ b/src/patcher/paragraph-token-replacer.spec.ts @@ -27,7 +27,7 @@ describe("paragraph-token-replacer", () => { }, renderedParagraph: { index: 0, - path: [0], + pathToParagraph: [0], runs: [ { end: 4, @@ -128,7 +128,7 @@ describe("paragraph-token-replacer", () => { { text: "World", parts: [{ text: "World", index: 0, start: 15, end: 19 }], index: 3, start: 15, end: 19 }, ], index: 0, - path: [0, 1, 0, 0], + pathToParagraph: [0, 1, 0, 0], }, originalText: "{{name}}", replacementText: "John", diff --git a/src/patcher/replacer.spec.ts b/src/patcher/replacer.spec.ts index 3dd0947f494..903ef0bd2b2 100644 --- a/src/patcher/replacer.spec.ts +++ b/src/patcher/replacer.spec.ts @@ -8,7 +8,7 @@ import { PatchType } from "./from-docx"; import { replacer } from "./replacer"; -const MOCK_JSON = { +export const MOCK_JSON = { elements: [ { type: "element", @@ -73,103 +73,60 @@ const MOCK_JSON = { describe("replacer", () => { describe("replacer", () => { - it("should return the same object if nothing is added", () => { - const output = replacer( - { - elements: [], - }, - { - type: PatchType.PARAGRAPH, - children: [], - }, - "hello", - [], - // eslint-disable-next-line functional/prefer-readonly-type - vi.fn<[], IContext>()(), - ); - - expect(output).to.deep.equal({ - elements: [], - }); + it("should throw an error if nothing is added", () => { + expect(() => + replacer({ + json: { + elements: [], + }, + patch: { + type: PatchType.PARAGRAPH, + children: [], + }, + patchText: "hello", + // eslint-disable-next-line functional/prefer-readonly-type + context: vi.fn<[], IContext>()(), + }), + ).toThrow(); }); it("should replace paragraph type", () => { - const output = replacer( - MOCK_JSON, - { + const output = replacer({ + json: JSON.parse(JSON.stringify(MOCK_JSON)), + patch: { 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], - }, - ], - { + patchText: "{{header_adjective}}", + context: { file: {} as unknown as File, viewWrapper: { Relationships: {}, } as unknown as IViewWrapper, stack: [], }, - ); + }); expect(JSON.stringify(output)).to.contain("Delightful Header"); }); it("should replace paragraph type keeping original styling if keepOriginalStyles is true", () => { - const output = replacer( - MOCK_JSON, - { + const output = replacer({ + json: JSON.parse(JSON.stringify(MOCK_JSON)), + patch: { type: PatchType.PARAGRAPH, children: [new TextRun("sweet")], }, - "{{bold}}", - [ - { - text: "What a {{bold}} text!", - runs: [ - { - text: "What a {{bold}} text!", - parts: [{ text: "What a {{bold}} text!", index: 1, start: 0, end: 21 }], - index: 0, - start: 0, - end: 21, - }, - ], - index: 0, - path: [0, 0, 1], - }, - ], - { + patchText: "{{bold}}", + context: { file: {} as unknown as File, viewWrapper: { Relationships: {}, } as unknown as IViewWrapper, stack: [], }, - true, - ); + keepOriginalStyles: true, + }); expect(JSON.stringify(output)).to.contain("sweet"); expect(output.elements![0].elements![1].elements).toMatchObject([ @@ -225,91 +182,23 @@ describe("replacer", () => { }); it("should replace document type", () => { - const output = replacer( - MOCK_JSON, - { + const output = replacer({ + json: JSON.parse(JSON.stringify(MOCK_JSON)), + patch: { 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], - }, - ], - { + patchText: "{{header_adjective}}", + context: { file: {} as unknown as File, viewWrapper: { Relationships: {}, } as unknown as IViewWrapper, stack: [], }, - ); + }); expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); }); - - it("should throw an error if the type is not supported", () => { - expect(() => - replacer( - {}, - { - type: PatchType.DOCUMENT, - children: [new Paragraph("Lorem ipsum paragraph")], - }, - "{{header_adjective}}", - [ - { - text: "This is a {{header_adjective}} don’t you think?", - runs: [ - { - text: "This is a {{head", - parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }], - index: 1, - start: 0, - end: 15, - }, - { text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 }, - { - text: "_adjective}} don’t you think?", - parts: [{ text: "_adjective}} don’t you think?", index: 0, start: 18, end: 46 }], - index: 3, - start: 18, - end: 46, - }, - ], - index: 0, - path: [0, 0, 0], - }, - ], - { - file: {} as unknown as File, - viewWrapper: { - Relationships: {}, - } as unknown as IViewWrapper, - stack: [], - }, - ), - ).to.throw(); - }); }); }); diff --git a/src/patcher/replacer.ts b/src/patcher/replacer.ts index 2ca7f9f3483..ab069457586 100644 --- a/src/patcher/replacer.ts +++ b/src/patcher/replacer.ts @@ -6,22 +6,33 @@ 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"; +import { findLocationOfText } from "./traverser"; const formatter = new Formatter(); const SPLIT_TOKEN = "ɵ"; -export const replacer = ( - json: Element, - patch: IPatch, - patchText: string, - renderedParagraphs: readonly IRenderedParagraphNode[], - context: IContext, - keepOriginalStyles: boolean = false, -): Element => { +export const replacer = ({ + json, + patch, + patchText, + context, + keepOriginalStyles = true, +}: { + readonly json: Element; + readonly patch: IPatch; + readonly patchText: string; + readonly context: IContext; + readonly keepOriginalStyles?: boolean; +}): Element => { + const renderedParagraphs = findLocationOfText(json, patchText); + + if (renderedParagraphs.length === 0) { + throw new Error(`Could not find text ${patchText}`); + } + for (const renderedParagraph of renderedParagraphs) { const textJson = patch.children // eslint-disable-next-line no-loop-func @@ -30,15 +41,15 @@ export const replacer = ( switch (patch.type) { case PatchType.DOCUMENT: { - const parentElement = goToParentElementFromPath(json, renderedParagraph.path); - const elementIndex = getLastElementIndexFromPath(renderedParagraph.path); + const parentElement = goToParentElementFromPath(json, renderedParagraph.pathToParagraph); + const elementIndex = getLastElementIndexFromPath(renderedParagraph.pathToParagraph); // 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); + const paragraphElement = goToElementFromPath(json, renderedParagraph.pathToParagraph); replaceTokenInParagraphElement({ paragraphElement, renderedParagraph, @@ -87,11 +98,7 @@ const goToElementFromPath = (json: Element, path: readonly number[]): 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"); - } + const nextElements = element.elements!; element = nextElements[index]; } diff --git a/src/patcher/run-renderer.spec.ts b/src/patcher/run-renderer.spec.ts index 5771dc02337..7d17d6c7ca4 100644 --- a/src/patcher/run-renderer.spec.ts +++ b/src/patcher/run-renderer.spec.ts @@ -7,7 +7,7 @@ describe("run-renderer", () => { const output = renderParagraphNode({ element: { name: "w:p" }, index: 0, parent: undefined }); expect(output).to.deep.equal({ index: -1, - path: [], + pathToParagraph: [], runs: [], text: "", }); @@ -39,7 +39,7 @@ describe("run-renderer", () => { }); expect(output).to.deep.equal({ index: 0, - path: [0], + pathToParagraph: [0], runs: [ { end: 4, @@ -79,7 +79,7 @@ describe("run-renderer", () => { }); expect(output).to.deep.equal({ index: 0, - path: [0], + pathToParagraph: [0], runs: [ { end: 0, diff --git a/src/patcher/run-renderer.ts b/src/patcher/run-renderer.ts index 262d6f265aa..005ad71fe79 100644 --- a/src/patcher/run-renderer.ts +++ b/src/patcher/run-renderer.ts @@ -6,7 +6,7 @@ export interface IRenderedParagraphNode { readonly text: string; readonly runs: readonly IRenderedRunNode[]; readonly index: number; - readonly path: readonly number[]; + readonly pathToParagraph: readonly number[]; } interface StartAndEnd { @@ -35,7 +35,7 @@ export const renderParagraphNode = (node: ElementWrapper): IRenderedParagraphNod text: "", runs: [], index: -1, - path: [], + pathToParagraph: [], }; } @@ -50,8 +50,7 @@ export const renderParagraphNode = (node: ElementWrapper): IRenderedParagraphNod return renderedRunNode; }) - .filter((e) => !!e) - .map((e) => e as IRenderedRunNode); + .filter((e) => !!e); const text = runs.reduce((acc, curr) => acc + curr.text, ""); @@ -59,7 +58,7 @@ export const renderParagraphNode = (node: ElementWrapper): IRenderedParagraphNod text, runs, index: node.index, - path: buildNodePath(node), + pathToParagraph: buildNodePath(node), }; }; diff --git a/src/patcher/traverser.spec.ts b/src/patcher/traverser.spec.ts index 0d68a0d00da..a2704e7b09c 100644 --- a/src/patcher/traverser.spec.ts +++ b/src/patcher/traverser.spec.ts @@ -139,6 +139,28 @@ const MOCK_JSON = { }, ], }, + { + type: "element", + name: "w:p", + elements: [ + { + type: "element", + name: "w:r", + elements: [ + { + type: "element", + name: "w:rPr", + elements: [{ type: "element", name: "w:b", attributes: { "w:val": "1" } }], + }, + { + type: "element", + name: "w:t", + elements: [{ type: "text", text: "What a {{bold}} text!" }], + }, + ], + }, + ], + }, { type: "element", name: "w:p", @@ -535,6 +557,45 @@ const MOCK_JSON = { }, ], }, + { + 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?" }], + }, + ], + }, + ], + }, { type: "element", name: "w:sectPr", @@ -574,7 +635,7 @@ describe("traverser", () => { expect(output).to.deep.equal([ { index: 1, - path: [0, 0, 0, 8, 2, 0, 1], + pathToParagraph: [0, 0, 0, 9, 2, 0, 1], runs: [ { end: 18, @@ -595,5 +656,76 @@ describe("traverser", () => { }, ]); }); + + it("should find the location of text", () => { + const output = findLocationOfText(MOCK_JSON, "{{bold}}"); + + expect(output).to.deep.equal([ + { + text: "What a {{bold}} text!", + runs: [ + { + text: "What a {{bold}} text!", + parts: [{ text: "What a {{bold}} text!", index: 1, start: 0, end: 20 }], + index: 0, + start: 0, + end: 20, + }, + ], + index: 5, + pathToParagraph: [0, 0, 0, 5], + }, + ]); + }); + + it("should find the location of text", () => { + const output = findLocationOfText(MOCK_JSON, "{{bold}}"); + + expect(output).to.deep.equal([ + { + text: "What a {{bold}} text!", + runs: [ + { + text: "What a {{bold}} text!", + parts: [{ text: "What a {{bold}} text!", index: 1, start: 0, end: 20 }], + index: 0, + start: 0, + end: 20, + }, + ], + index: 5, + pathToParagraph: [0, 0, 0, 5], + }, + ]); + }); + + it("should find the location of text", () => { + const output = findLocationOfText(MOCK_JSON, "{{header_adjective}}"); + + expect(output).to.deep.equal([ + { + 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: 14, + pathToParagraph: [0, 0, 0, 14], + }, + ]); + }); }); });