Merge pull request #1960 from dolanmiu/feat/new-template
Document Patcher - Re-write / Re-vamp template feature
This commit is contained in:
4
.nycrc
4
.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"
|
||||
],
|
||||
|
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
155
demo/85-template-document.ts
Normal file
155
demo/85-template-document.ts
Normal 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);
|
||||
});
|
20
demo/86-generate-template.ts
Normal file
20
demo/86-generate-template.ts
Normal 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);
|
||||
});
|
BIN
demo/assets/generated-template.docx
Normal file
BIN
demo/assets/generated-template.docx
Normal file
Binary file not shown.
BIN
demo/assets/simple-template.docx
Normal file
BIN
demo/assets/simple-template.docx
Normal file
Binary file not shown.
@ -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
|
||||
```
|
||||
|
||||
<!-- 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 the `ESLint` rules
|
||||
|
41
package-lock.json
generated
41
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -20,6 +20,7 @@ export class Media {
|
||||
this.map = new Map<string, IMediaData>();
|
||||
}
|
||||
|
||||
// TODO: Unused
|
||||
public addMedia(data: Buffer | string | Uint8Array | ArrayBuffer, transformation: IMediaTransformation): IMediaData {
|
||||
const key = `${uniqueId()}.png`;
|
||||
|
||||
|
@ -5,3 +5,4 @@ export * from "./file";
|
||||
export * from "./export";
|
||||
export * from "./import-dotx";
|
||||
export * from "./util";
|
||||
export * from "./patcher";
|
||||
|
51
src/patcher/content-types-manager.spec.ts
Normal file
51
src/patcher/content-types-manager.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
16
src/patcher/content-types-manager.ts
Normal file
16
src/patcher/content-types-manager.ts
Normal 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",
|
||||
});
|
||||
};
|
370
src/patcher/from-docx.spec.ts
Normal file
370
src/patcher/from-docx.spec.ts
Normal 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
233
src/patcher/from-docx.ts
Normal 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
1
src/patcher/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./from-docx";
|
224
src/patcher/paragraph-split-inject.spec.ts
Normal file
224
src/patcher/paragraph-split-inject.spec.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
54
src/patcher/paragraph-split-inject.ts
Normal file
54
src/patcher/paragraph-split-inject.ts
Normal 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 };
|
||||
};
|
165
src/patcher/paragraph-token-replacer.spec.ts
Normal file
165
src/patcher/paragraph-token-replacer.spec.ts
Normal 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",
|
||||
// });
|
||||
// });
|
||||
});
|
||||
});
|
69
src/patcher/paragraph-token-replacer.ts
Normal file
69
src/patcher/paragraph-token-replacer.ts
Normal 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;
|
||||
};
|
87
src/patcher/relationship-manager.spec.ts
Normal file
87
src/patcher/relationship-manager.spec.ts
Normal 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
42
src/patcher/relationship-manager.ts
Normal file
42
src/patcher/relationship-manager.ts
Normal 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;
|
||||
};
|
206
src/patcher/replacer.spec.ts
Normal file
206
src/patcher/replacer.spec.ts
Normal 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}} 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();
|
||||
});
|
||||
});
|
||||
});
|
83
src/patcher/replacer.ts
Normal file
83
src/patcher/replacer.ts
Normal 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];
|
96
src/patcher/run-renderer.spec.ts
Normal file
96
src/patcher/run-renderer.spec.ts
Normal 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
109
src/patcher/run-renderer.ts
Normal 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];
|
599
src/patcher/traverser.spec.ts
Normal file
599
src/patcher/traverser.spec.ts
Normal 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
45
src/patcher/traverser.ts
Normal 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
50
src/patcher/util.spec.ts
Normal 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
30
src/patcher/util.ts
Normal 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 ?? [];
|
@ -20,5 +20,10 @@
|
||||
"@shared": ["./shared/index.ts"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user