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,
|
"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
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
|
# 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
41
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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`;
|
||||||
|
|
||||||
|
@ -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";
|
||||||
|
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"]
|
"@shared": ["./shared/index.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user