Merge pull request #1960 from dolanmiu/feat/new-template

Document Patcher - Re-write / Re-vamp template feature
This commit is contained in:
Dolan
2023-03-16 23:37:31 +00:00
committed by GitHub
32 changed files with 2788 additions and 6 deletions

4
.nycrc
View File

@ -1,9 +1,9 @@
{ {
"check-coverage": true, "check-coverage": true,
"statements": 99.79, "statements": 99.79,
"branches": 98.41, "branches": 98.17,
"functions": 100, "functions": 100,
"lines": 99.73, "lines": 99.78,
"include": [ "include": [
"src/**/*.ts" "src/**/*.ts"
], ],

18
.vscode/launch.json vendored Normal file
View File

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

View File

@ -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);
});

View File

@ -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);
});

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,21 @@
# Contribution Guidelines # 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 ```ts
// http://officeopenxml.com/WPdocument.php // http://officeopenxml.com/WPdocument.php
``` ```
<!-- cSpell:ignore datypic -->
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
// <xsd:element name="tbl" type="CT_Tbl" minOccurs="0" maxOccurs="1"/>
```
- Follow Prettier standards, and consider using the [Prettier VSCode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) plugin. - Follow Prettier standards, and consider using the [Prettier VSCode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) plugin.
- Follow the `ESLint` rules - Follow the `ESLint` rules

41
package-lock.json generated
View File

@ -17,6 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.15", "@types/chai": "^4.2.15",
"@types/chai-as-promised": "^7.1.5",
"@types/glob": "^8.0.0", "@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0", "@types/mocha": "^10.0.0",
"@types/prompt": "^1.1.1", "@types/prompt": "^1.1.1",
@ -29,6 +30,7 @@
"@typescript-eslint/parser": "^5.36.1", "@typescript-eslint/parser": "^5.36.1",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"chai": "^4.3.6", "chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"cspell": "^6.2.2", "cspell": "^6.2.2",
"docsify-cli": "^4.3.0", "docsify-cli": "^4.3.0",
"eslint": "^8.23.0", "eslint": "^8.23.0",
@ -1198,6 +1200,15 @@
"integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
"dev": true "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": { "node_modules/@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -2897,6 +2908,18 @@
"node": ">=4" "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": { "node_modules/chainsaw": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",
@ -12669,6 +12692,15 @@
"integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==", "integrity": "sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==",
"dev": true "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": { "@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@ -13917,6 +13949,15 @@
"type-detect": "^4.0.5" "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": { "chainsaw": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz",

View File

@ -64,6 +64,7 @@
"homepage": "https://github.com/dolanmiu/docx#readme", "homepage": "https://github.com/dolanmiu/docx#readme",
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.15", "@types/chai": "^4.2.15",
"@types/chai-as-promised": "^7.1.5",
"@types/glob": "^8.0.0", "@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0", "@types/mocha": "^10.0.0",
"@types/prompt": "^1.1.1", "@types/prompt": "^1.1.1",
@ -76,6 +77,7 @@
"@typescript-eslint/parser": "^5.36.1", "@typescript-eslint/parser": "^5.36.1",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"chai": "^4.3.6", "chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"cspell": "^6.2.2", "cspell": "^6.2.2",
"docsify-cli": "^4.3.0", "docsify-cli": "^4.3.0",
"eslint": "^8.23.0", "eslint": "^8.23.0",

View File

@ -59,9 +59,8 @@ export class Compiler {
} }
} }
for (const data of file.Media.Array) { for (const { stream, fileName } of file.Media.Array) {
const mediaData = data.stream; zip.file(`word/media/${fileName}`, stream);
zip.file(`word/media/${data.fileName}`, mediaData);
} }
return zip; return zip;

View File

@ -20,6 +20,7 @@ export class Media {
this.map = new Map<string, IMediaData>(); this.map = new Map<string, IMediaData>();
} }
// TODO: Unused
public addMedia(data: Buffer | string | Uint8Array | ArrayBuffer, transformation: IMediaTransformation): IMediaData { public addMedia(data: Buffer | string | Uint8Array | ArrayBuffer, transformation: IMediaTransformation): IMediaData {
const key = `${uniqueId()}.png`; const key = `${uniqueId()}.png`;

View File

@ -5,3 +5,4 @@ export * from "./file";
export * from "./export"; export * from "./export";
export * from "./import-dotx"; export * from "./import-dotx";
export * from "./util"; export * from "./util";
export * from "./patcher";

View File

@ -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",
});
});
});
});

View File

@ -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",
});
};

View File

@ -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 = `
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document 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">
<w:body>
<w:p w14:paraId="2499FE9F" w14:textId="0A3D130F" w:rsidR="00B51233"
w:rsidRDefault="007B52ED" w:rsidP="007B52ED">
<w:pPr>
<w:pStyle w:val="Title" />
</w:pPr>
<w:r>
<w:t>Hello World</w:t>
</w:r>
</w:p>
<w:p w14:paraId="6410D9A0" w14:textId="7579AB49" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="57ACF964" w14:textId="315D7A05" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>Hello {{name}},</w:t>
</w:r>
<w:r w:rsidR="008126CB">
<w:t xml:space="preserve"> how are you?</w:t>
</w:r>
</w:p>
<w:p w14:paraId="38C7DF4A" w14:textId="66CDEC9A" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="04FABE2B" w14:textId="3DACA001" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>{{paragraph_replace}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="7AD7975D" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
<w:p w14:paraId="3BD6D75A" w14:textId="19AE3121" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>{{table}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="76023962" w14:textId="4E606AB9" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:tbl>
<w:tblPr>
<w:tblStyle w:val="TableGrid" />
<w:tblW w:w="0" w:type="auto" />
<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1"
w:lastColumn="0" w:noHBand="0" w:noVBand="1" />
</w:tblPr>
<w:tblGrid>
<w:gridCol w:w="3003" />
<w:gridCol w:w="3003" />
<w:gridCol w:w="3004" />
</w:tblGrid>
<w:tr w:rsidR="00EF161F" w14:paraId="1DEC5955" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="54DA5587" w14:textId="625BAC60" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>{{table_heading_1}}</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="57100910" w14:textId="71FD5616" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1D388FAB" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
<w:tr w:rsidR="00EF161F" w14:paraId="0F53D2DC" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="0F2BCCED" w14:textId="3C3B6706" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>Item: {{item_1}}</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1E6158AC" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="17937748" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
<w:tr w:rsidR="00EF161F" w14:paraId="781DAC1A" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1DCD0343" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="5D02E3CD" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="52EA0DBB" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
</w:tbl>
<w:p w14:paraId="47CD1FBC" w14:textId="23474CBC" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="0ACCEE90" w14:textId="67907499" w:rsidR="00EF161F"
w:rsidRDefault="0077578F">
<w:r>
<w:t>{{image_test}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="23FA9862" w14:textId="77777777" w:rsidR="0077578F"
w:rsidRDefault="0077578F" />
<w:p w14:paraId="01578F2F" w14:textId="3BDC6C85" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>Thank you</w:t>
</w:r>
</w:p>
<w:sectPr w:rsidR="007B52ED" w:rsidSect="0072043F">
<w:headerReference w:type="default" r:id="rId6" />
<w:footerReference w:type="default" r:id="rId7" />
<w:pgSz w:w="11900" w:h="16840" />
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708"
w:footer="708" w:gutter="0" />
<w:cols w:space="708" />
<w:docGrid w:linePitch="360" />
</w:sectPr>
</w:body>
</w:document>
`;
describe("from-docx", () => {
describe("patchDocument", () => {
describe("document.xml and [Content_Types].xml", () => {
before(() => {
sinon.createStubInstance(JSZip, {});
sinon.stub(JSZip, "loadAsync").callsFake(
() =>
new Promise<JSZip>((resolve) => {
const zip = new JSZip();
zip.file("word/document.xml", MOCK_XML);
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
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<JSZip>((resolve) => {
const zip = new JSZip();
zip.file("word/document.xml", MOCK_XML);
zip.file("word/_rels/document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
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<JSZip>((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);
});
});
});

233
src/patcher/from-docx.ts Normal file
View File

@ -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<Buffer> => {
const zipContent = await JSZip.loadAsync(data);
const contexts = new Map<string, IContext>();
const file = {
Media: new Media(),
} as unknown as File;
const map = new Map<string, Element>();
// 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: [],
},
],
});

1
src/patcher/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./from-docx";

View File

@ -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",
},
});
});
});
});

View File

@ -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 };
};

View File

@ -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",
// });
// });
});
});

View File

@ -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;
};

View File

@ -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",
},
]);
});
});
});

View File

@ -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;
};

View File

@ -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}} dont 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}} dont 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}} dont you think?",
parts: [{ text: "_adjective}} dont 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}} dont 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}} dont you think?",
parts: [{ text: "_adjective}} dont 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}} dont 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}} dont you think?",
parts: [{ text: "_adjective}} dont 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();
});
});
});

83
src/patcher/replacer.ts Normal file
View File

@ -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];

View File

@ -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: "",
});
});
});
});

109
src/patcher/run-renderer.ts Normal file
View File

@ -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];

View File

@ -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}}",
},
]);
});
});
});

45
src/patcher/traverser.ts Normal file
View File

@ -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));
};

50
src/patcher/util.spec.ts Normal file
View File

@ -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("<xml></xml>");
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" }]);
});
});
});

30
src/patcher/util.ts Normal file
View File

@ -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 ?? [];

View File

@ -20,5 +20,10 @@
"@shared": ["./shared/index.ts"] "@shared": ["./shared/index.ts"]
} }
}, },
"ts-node": {
"compilerOptions": {
"module": "commonjs"
}
},
"include": ["src"] "include": ["src"]
} }