Compare commits
191 Commits
feature/pa
...
8.0.2
Author | SHA1 | Date | |
---|---|---|---|
accb1d44d0 | |||
b61f798cd9 | |||
be2ec9d4cf | |||
514fbb1c09 | |||
8ce397f61e | |||
3b0dfb8489 | |||
f4933deaf2 | |||
add2273551 | |||
91b00466a0 | |||
d0612e115e | |||
e4a6cbe887 | |||
0386810714 | |||
d4585f60b3 | |||
18f015a959 | |||
10c866bdea | |||
680c938dcf | |||
c3a617fefd | |||
6362b498a6 | |||
2e30ab29be | |||
44568023cd | |||
c57aaa856d | |||
0f4f24c3f3 | |||
2fe7a2e3d3 | |||
20b62f5768 | |||
ab063eee08 | |||
66c7624b40 | |||
bfc302023f | |||
3505912f74 | |||
ec379f2e4d | |||
2d17954104 | |||
01c834b16b | |||
2aeb2c2e88 | |||
be88086434 | |||
28f0cc681d | |||
def29c535b | |||
168e5e6e11 | |||
ce71f19826 | |||
4fd3f3a6b8 | |||
39763d3869 | |||
36f90edc67 | |||
b0cbd6f609 | |||
d23d4a4557 | |||
07c58ee43c | |||
1037e8f1b9 | |||
f0ae8396f7 | |||
1ae5995f9c | |||
751ad9d304 | |||
0bd1514826 | |||
16bfc78d8d | |||
528008406a | |||
9e3010ac6a | |||
7c1fa6f4bf | |||
f58d31cc3d | |||
286c742ece | |||
3edc51d5a0 | |||
05d4c9688e | |||
d4401d1597 | |||
90358e73f6 | |||
a77ca02ea9 | |||
7785f0df02 | |||
0e6ed6e5ea | |||
70e84c8d38 | |||
11c3886659 | |||
6104e5bc35 | |||
c748b9a7fc | |||
714dbc0179 | |||
7f16cfc359 | |||
78757753c8 | |||
8343edcdf1 | |||
1ab9e08eb9 | |||
e1bc7f34c9 | |||
d5b495df5b | |||
d6256d2acb | |||
f1c7e448ae | |||
b600fd9324 | |||
3bf40ecb33 | |||
ffb650daa9 | |||
d2122e7806 | |||
6b6eb1d7a2 | |||
c80d881105 | |||
3c223c69e9 | |||
2420321008 | |||
29d5421cea | |||
71953cf45a | |||
325866d9c3 | |||
457e9c12e3 | |||
ff4a66466a | |||
cc142cc052 | |||
6e0dc69217 | |||
33332bcf66 | |||
b94f9ac25b | |||
0a7be48dcb | |||
3f3397620e | |||
154bc1df95 | |||
715e436c4c | |||
f5a9bcf839 | |||
31d2da86ff | |||
9dad3ca356 | |||
355e01edc6 | |||
c19bf1c404 | |||
970450074d | |||
91e8295648 | |||
3e40ae862b | |||
236cce604f | |||
0388a564b5 | |||
cd77ceba69 | |||
5ae8731abe | |||
7bb90c55d1 | |||
a07d6378e8 | |||
4fb8d277b4 | |||
73186ce920 | |||
d02f98956f | |||
d186d42bb6 | |||
338f7be967 | |||
b63a6e6e16 | |||
7e9884081e | |||
262f6323d0 | |||
811dd61562 | |||
9801879063 | |||
e30836c935 | |||
352511bb55 | |||
8ce057e25c | |||
0775ecb08b | |||
e909ab9886 | |||
e03bb155b9 | |||
91620d61d8 | |||
0fba450c9a | |||
ca604fb004 | |||
9e998d233c | |||
6c0fd3adec | |||
6ad18420e5 | |||
ef95a16f39 | |||
53861ec18d | |||
81b1569ad2 | |||
66a1992da0 | |||
100356c344 | |||
31905a27b1 | |||
08916176a2 | |||
ffb3d63c92 | |||
22005428ba | |||
a70acd6d63 | |||
af9e0b0d3f | |||
a0c2be8af2 | |||
df9d4b4219 | |||
cdf5015920 | |||
fe9b438a51 | |||
59cbf8c9cb | |||
7af8175034 | |||
bf0393b485 | |||
83a8822cf7 | |||
eada41b8a1 | |||
2c03377c47 | |||
484fc4aa2d | |||
2846014db0 | |||
c9d86619de | |||
ce485dbc29 | |||
4e875b4744 | |||
3f6c006716 | |||
f8f5d43b0c | |||
8c0fe00c6f | |||
c37d9ca5b3 | |||
f3dc1f0712 | |||
a4d96bbf6e | |||
90c40178aa | |||
2ee96b419a | |||
773900b620 | |||
ece440340f | |||
5233b4b5e6 | |||
86de252a52 | |||
c206d23480 | |||
5a53a138d9 | |||
5b5deb198e | |||
88a30e5af7 | |||
0b9ffa9a7b | |||
5052bcde4f | |||
d5409678ee | |||
72c39a9eb2 | |||
2ed7a28d1e | |||
7b2ff00c83 | |||
d5b0083d77 | |||
406183d104 | |||
c6d3e60314 | |||
be71037a13 | |||
c8c64b320b | |||
8514e8ad31 | |||
ffd998cbf5 | |||
8cbc6c15fe | |||
2c21f64b9f | |||
7ec9cff433 | |||
3f979b9981 | |||
3077ca96a7 |
61
.cspell.json
61
.cspell.json
@ -4,34 +4,40 @@
|
||||
"version": "0.2",
|
||||
// language - current active spelling language
|
||||
"language": "en_US",
|
||||
"dictionaries": ["en_US", "typescript", "softwareTerms", "fonts", "npm"],
|
||||
"dictionaries": [
|
||||
"en_US",
|
||||
"typescript",
|
||||
"softwareTerms",
|
||||
"fonts",
|
||||
"npm"
|
||||
],
|
||||
// words - list of words to be always considered correct
|
||||
"words": [
|
||||
"Xmlable",
|
||||
"Abjad",
|
||||
"aiueo",
|
||||
"ATLEAST",
|
||||
"chosung",
|
||||
"clippy",
|
||||
"datas",
|
||||
"docsify",
|
||||
"dolan",
|
||||
"falsey",
|
||||
"Initializable",
|
||||
"iroha",
|
||||
"jsonify",
|
||||
"jszip",
|
||||
"NUMPAGES",
|
||||
"odttf",
|
||||
"ooxml",
|
||||
"panose",
|
||||
"rels",
|
||||
"rsid",
|
||||
"twip",
|
||||
"twips",
|
||||
"jsonify",
|
||||
"falsey",
|
||||
"aiueo",
|
||||
"iroha",
|
||||
"aiueo",
|
||||
"iroha",
|
||||
"chosung",
|
||||
"Abjad",
|
||||
"Initializable",
|
||||
"rels",
|
||||
"dolan",
|
||||
"xmlify",
|
||||
"Xmlifyed",
|
||||
"Xmlable",
|
||||
"xmlified",
|
||||
"datas",
|
||||
"jszip",
|
||||
"rsid",
|
||||
"NUMPAGES",
|
||||
"ATLEAST",
|
||||
"ooxml",
|
||||
"clippy",
|
||||
"docsify"
|
||||
"xmlify",
|
||||
"Xmlifyed"
|
||||
],
|
||||
"ignoreRegExpList": [
|
||||
"/\"w:.+\"/",
|
||||
@ -51,10 +57,15 @@
|
||||
"/<element name=\"[a-z]+\"/gi",
|
||||
"/<attribute name=\"[a-z]+\"/gi"
|
||||
],
|
||||
"ignorePaths": ["package.json", "docs/api"],
|
||||
"ignorePaths": [
|
||||
"package.json",
|
||||
"docs/api"
|
||||
],
|
||||
"allowCompoundWords": true,
|
||||
// flagWords - list of words to be always considered incorrect
|
||||
// This is useful for offensive words and common spelling errors.
|
||||
// For example "hte" should be "the"
|
||||
"flagWords": ["hte"]
|
||||
"flagWords": [
|
||||
"hte"
|
||||
]
|
||||
}
|
||||
|
9
.github/workflows/demos.yml
vendored
9
.github/workflows/demos.yml
vendored
@ -301,15 +301,6 @@ jobs:
|
||||
with:
|
||||
xml-file: build/extracted-doc/word/document.xml
|
||||
xml-schema-file: ooxml-schemas/microsoft/wml-2010.xsd
|
||||
- name: Run Demo
|
||||
run: npm run ts-node -- ./demo/30-template-document.ts
|
||||
- name: Extract Word Document
|
||||
run: npm run extract
|
||||
- name: Validate XML
|
||||
uses: ChristophWurst/xmllint-action@v1
|
||||
with:
|
||||
xml-file: build/extracted-doc/word/document.xml
|
||||
xml-schema-file: ooxml-schemas/microsoft/wml-2010.xsd
|
||||
- name: Run Demo
|
||||
run: npm run ts-node -- ./demo/31-tables.ts
|
||||
- name: Extract Word Document
|
||||
|
@ -41,3 +41,6 @@ build-tests
|
||||
|
||||
# docs
|
||||
docs
|
||||
|
||||
# src
|
||||
src
|
||||
|
6
.nycrc
6
.nycrc
@ -1,9 +1,9 @@
|
||||
{
|
||||
"check-coverage": true,
|
||||
"statements": 99.79,
|
||||
"branches": 98.41,
|
||||
"statements": 99.87,
|
||||
"branches": 98.21,
|
||||
"functions": 100,
|
||||
"lines": 99.73,
|
||||
"lines": 99.86,
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
|
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Demo",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"-r",
|
||||
"${workspaceFolder}/node_modules/ts-node/register",
|
||||
"-r",
|
||||
"${workspaceFolder}/node_modules/tsconfig-paths/register"
|
||||
],
|
||||
"cwd": "${workspaceRoot}",
|
||||
"program": "${workspaceFolder}/demo/85-template-document.ts"
|
||||
}
|
||||
]
|
||||
}
|
@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Easily generate .docx files with JS/TS. Works for Node and on the Browser.
|
||||
Easily generate and modify .docx files with JS/TS. Works for Node and on the Browser.
|
||||
</p>
|
||||
|
||||
---
|
||||
@ -88,6 +88,7 @@ Read the contribution guidelines [here](https://docx.js.org/#/contribution-guide
|
||||
[<img src="https://i.imgur.com/cmykN7c.png" alt="drawing"/>](https://www.arity.co/)
|
||||
[<img src="https://i.imgur.com/PXo25um.png" alt="drawing" height="50"/>](https://www.circadianrisk.com/)
|
||||
[<img src="https://i.imgur.com/AKGhtlh.png" alt="drawing"/>](https://lexense.com/)
|
||||
[<img src="https://i.imgur.com/9tqJaHw.png" alt="drawing" height="50"/>](https://novelpad.co/)
|
||||
|
||||
...and many more!
|
||||
|
||||
|
@ -25,6 +25,17 @@ const doc = new Document({
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
border: {
|
||||
top: {
|
||||
color: "auto",
|
||||
space: 1,
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 6,
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
|
@ -1,35 +0,0 @@
|
||||
// Example on how to use a template document
|
||||
// Import from 'docx' rather than '../build' if you install from npm
|
||||
import * as fs from "fs";
|
||||
import { Document, ImportDotx, Packer, Paragraph } from "../build";
|
||||
|
||||
const importDotx = new ImportDotx();
|
||||
const filePath = "./demo/dotx/template.dotx";
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
throw new Error(`Failed to read file ${filePath}.`);
|
||||
}
|
||||
|
||||
importDotx.extract(data).then((templateDocument) => {
|
||||
const doc = new Document(
|
||||
{
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
titlePage: templateDocument.titlePageIsDefined,
|
||||
},
|
||||
children: [new Paragraph("Hello World")],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
template: templateDocument,
|
||||
},
|
||||
);
|
||||
|
||||
Packer.toBuffer(doc).then((buffer) => {
|
||||
fs.writeFileSync("My Document.docx", buffer);
|
||||
});
|
||||
});
|
||||
});
|
@ -5,7 +5,83 @@ import { Document, Packer, Paragraph, TextRun, CommentRangeStart, CommentRangeEn
|
||||
|
||||
const doc = new Document({
|
||||
comments: {
|
||||
children: [{ id: 0, author: "Ray Chen", date: new Date(), text: "comment text content" }],
|
||||
children: [
|
||||
{
|
||||
id: 0,
|
||||
author: "Ray Chen",
|
||||
date: new Date(),
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "some initial text content",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "comment text content",
|
||||
}),
|
||||
new TextRun({ text: "", break: 1 }),
|
||||
new TextRun({
|
||||
text: "More text here",
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
author: "Bob Ross",
|
||||
date: new Date(),
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Some initial text content",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "comment text content",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: "John Doe",
|
||||
date: new Date(),
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Hello World",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: "Beatriz",
|
||||
date: new Date(),
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Another reply",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
@ -26,6 +102,32 @@ const doc = new Document({
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new CommentRangeStart(1),
|
||||
new CommentRangeStart(2),
|
||||
new CommentRangeStart(3),
|
||||
new TextRun({
|
||||
text: "Some text which need commenting",
|
||||
bold: true,
|
||||
}),
|
||||
new CommentRangeEnd(1),
|
||||
new TextRun({
|
||||
children: [new CommentReference(1)],
|
||||
bold: true,
|
||||
}),
|
||||
new CommentRangeEnd(2),
|
||||
new TextRun({
|
||||
children: [new CommentReference(2)],
|
||||
bold: true,
|
||||
}),
|
||||
new CommentRangeEnd(3),
|
||||
new TextRun({
|
||||
children: [new CommentReference(3)],
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
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);
|
||||
});
|
15
demo/87-template-document.ts
Normal file
15
demo/87-template-document.ts
Normal file
@ -0,0 +1,15 @@
|
||||
// Patch a document with patches
|
||||
// Import from 'docx' rather than '../build' if you install from npm
|
||||
import * as fs from "fs";
|
||||
import { patchDocument, PatchType, TextRun } from "../build";
|
||||
|
||||
patchDocument(fs.readFileSync("demo/assets/simple-template-2.docx"), {
|
||||
patches: {
|
||||
name: {
|
||||
type: PatchType.PARAGRAPH,
|
||||
children: [new TextRun("Max")],
|
||||
},
|
||||
},
|
||||
}).then((doc) => {
|
||||
fs.writeFileSync("My Document.docx", doc);
|
||||
});
|
31
demo/88-template-document.ts
Normal file
31
demo/88-template-document.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// Patch a document with patches
|
||||
// Import from 'docx' rather than '../build' if you install from npm
|
||||
import * as fs from "fs";
|
||||
import { IPatch, patchDocument, PatchType, TextRun } from "../build";
|
||||
|
||||
export const font = "Trebuchet MS";
|
||||
export const getPatches = (fields: { [key: string]: string }) => {
|
||||
const patches: { [key: string]: IPatch } = {};
|
||||
|
||||
for (const field in fields) {
|
||||
patches[field] = {
|
||||
type: PatchType.PARAGRAPH,
|
||||
children: [new TextRun({ text: fields[field], font })],
|
||||
};
|
||||
}
|
||||
|
||||
return patches;
|
||||
};
|
||||
|
||||
const patches = getPatches({
|
||||
name: "Mr",
|
||||
table_heading_1: "John",
|
||||
item_1: "Doe",
|
||||
paragraph_replace: "Lorem ipsum paragraph",
|
||||
});
|
||||
|
||||
patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), {
|
||||
patches,
|
||||
}).then((doc) => {
|
||||
fs.writeFileSync("My Document.docx", doc);
|
||||
});
|
29
demo/89-template-document.ts
Normal file
29
demo/89-template-document.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// Patch a document with patches
|
||||
// Import from 'docx' rather than '../build' if you install from npm
|
||||
import * as fs from "fs";
|
||||
import { IPatch, patchDocument, PatchType, TextRun } from "../build";
|
||||
|
||||
export const font = "Trebuchet MS";
|
||||
export const getPatches = (fields: { [key: string]: string }) => {
|
||||
const patches: { [key: string]: IPatch } = {};
|
||||
|
||||
for (const field in fields) {
|
||||
patches[field] = {
|
||||
type: PatchType.PARAGRAPH,
|
||||
children: [new TextRun({ text: fields[field], font })],
|
||||
};
|
||||
}
|
||||
|
||||
return patches;
|
||||
};
|
||||
|
||||
const patches = getPatches({
|
||||
salutation: "Mr.",
|
||||
"first-name": "John",
|
||||
});
|
||||
|
||||
patchDocument(fs.readFileSync("demo/assets/simple-template-3.docx"), {
|
||||
patches,
|
||||
}).then((doc) => {
|
||||
fs.writeFileSync("My Document.docx", doc);
|
||||
});
|
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-2.docx
Normal file
BIN
demo/assets/simple-template-2.docx
Normal file
Binary file not shown.
BIN
demo/assets/simple-template-3.docx
Normal file
BIN
demo/assets/simple-template-3.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,10 +1,10 @@
|
||||
<img src="https://i.imgur.com/37uBGhO.gif" alt="drawing" style="width:200px;"/>
|
||||
|
||||
> Easily generate .docx files with JS/TS. Works for Node and on the Browser. :100:
|
||||
> Easily generate and modify .docx files with JS/TS. Works for Node and on the Browser. :100:
|
||||
|
||||
- Simple, declarative API
|
||||
- 60+ usage examples
|
||||
- Battle tested, mature, 99%+ coverage
|
||||
- 80+ usage examples
|
||||
- Battle tested, mature, 99.9%+ coverage
|
||||
|
||||
[GitHub](https://github.com/dolanmiu/docx)
|
||||
[Get Started](#Welcome)
|
||||
|
@ -1,6 +1,8 @@
|
||||
- [Getting Started](/)
|
||||
|
||||
- [Examples](https://github.com/dolanmiu/docx/tree/master/demo)
|
||||
- Examples
|
||||
|
||||
- [Demos](https://github.com/dolanmiu/docx/tree/master/demo)
|
||||
|
||||
- API
|
||||
|
||||
@ -36,6 +38,10 @@
|
||||
|
||||
- [Packers](usage/packers.md)
|
||||
|
||||
- Modifying Existing Documents
|
||||
|
||||
- [Patcher](usage/patcher.md)
|
||||
|
||||
- Utility
|
||||
|
||||
- [Convenience functions](usage/convenience-functions.md)
|
||||
|
@ -1,11 +1,21 @@
|
||||
# Contribution Guidelines
|
||||
|
||||
- Include documentation reference(s) at the top of each file:
|
||||
- Include documentation reference(s) at the top of each file as a comment. For example:
|
||||
|
||||
```ts
|
||||
// http://officeopenxml.com/WPdocument.php
|
||||
```
|
||||
|
||||
<!-- cSpell:ignore datypic -->
|
||||
It can be a link to `officeopenxml.com` or `datypic.com` etc.
|
||||
It could also be a reference to the official ECMA-376 standard: https://www.ecma-international.org/publications-and-standards/standards/ecma-376/
|
||||
|
||||
- Include a portion of the schema as a comment for cross reference. For example:
|
||||
|
||||
```ts
|
||||
// <xsd:element name="tbl" type="CT_Tbl" minOccurs="0" maxOccurs="1"/>
|
||||
```
|
||||
|
||||
- Follow Prettier standards, and consider using the [Prettier VSCode](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) plugin.
|
||||
|
||||
- Follow the `ESLint` rules
|
||||
|
@ -38,6 +38,7 @@
|
||||
<script src="https://unpkg.com/docsify-copy-code@2"></script>
|
||||
<script src="//unpkg.com/docsify/lib/plugins/search.min.js"></script>
|
||||
<script src="//unpkg.com/prismjs/components/prism-typescript.min.js"></script>
|
||||
<script src="https://unpkg.com/docsify-sign-off-sheet@1.0.0/dist/index.iife.js"></script>
|
||||
<script src="//cdn.jsdelivr.net/npm/docsify-darklight-theme@latest/dist/index.min.js" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
42
docs/usage/footnotes.md
Normal file
42
docs/usage/footnotes.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Footnotes
|
||||
|
||||
!> Footnotes requires an understanding of [Sections](usage/sections.md).
|
||||
|
||||
Use footnotes and endnotes to explain, comment on, or provide references to something in a document. Usually, footnotes appear at the bottom of the page.
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
const doc = new Document({
|
||||
footnotes: {
|
||||
1: { children: [new Paragraph("Foo"), new Paragraph("Bar")] },
|
||||
2: { children: [new Paragraph("Test")] },
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
children: ["Hello"],
|
||||
}),
|
||||
new FootnoteReferenceRun(1),
|
||||
new TextRun({
|
||||
children: [" World!"],
|
||||
}),
|
||||
new FootnoteReferenceRun(2),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Footnotes requires an entry into the `footnotes` array in the `Document` constructor, and a `FootnoteReferenceRun` in the `Paragraph` constructor.
|
||||
|
||||
`footnotes` is an object of number to `Footnote` objects. The number is the reference number, and the `Footnote` object is the content of the footnote. The `Footnote` object has a `children` property, which is an array of `Paragraph` objects.
|
||||
|
||||
`FootnoteReferenceRun` is a `Run` object, which are added to `Paragraph`s. It takes a number as a parameter, which is the reference number of the footnote.
|
@ -305,4 +305,12 @@ Example: https://github.com/dolanmiu/docx/blob/master/demo/15-page-break-before.
|
||||
|
||||
## Page break control
|
||||
|
||||
Paragraphs have `.keepLines()` and `.keepNext()` methods that allow restricting page breaks within and between paragraphs. See [this Microsoft article](https://support.office.com/en-us/article/Keep-lines-and-paragraphs-together-d72af534-926f-4c4b-830a-abfc2daa3bfa) for more details)
|
||||
Paragraphs have `keepLines` and `keepNext` properties that allow restricting page breaks within and between paragraphs. See [this Microsoft article](https://support.office.com/en-us/article/Keep-lines-and-paragraphs-together-d72af534-926f-4c4b-830a-abfc2daa3bfa) for more details.
|
||||
|
||||
```ts
|
||||
const paragraph = new Paragraph({
|
||||
text: "Stay on the same page",
|
||||
keepLines: true,
|
||||
keepNext: true,
|
||||
});
|
||||
```
|
||||
|
94
docs/usage/patcher.md
Normal file
94
docs/usage/patcher.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Patcher
|
||||
|
||||
The patcher allows you to modify existing documents, and add new content to them.
|
||||
|
||||
!> The Patcher requires an understanding of [Paragraphs](usage/paragraph.md).
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import * as fs from "fs";
|
||||
import { patchDocument } from "docx";
|
||||
|
||||
patchDocument(fs.readFileSync("My Document.docx"), {
|
||||
patches: {
|
||||
// Patches here
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Patches
|
||||
|
||||
The patcher takes in a `patches` object, which is a map of `string` to `Patch`:
|
||||
|
||||
```ts
|
||||
interface Patch {
|
||||
type: PatchType;
|
||||
children: FileChild[] | ParagraphChild[];
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Notes | Possible Values |
|
||||
| -------- | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| type | `PatchType` | Required | `DOCUMENT`, `PARAGRAPH` |
|
||||
| children | `FileChild[] or ParagraphChild[]` | Required | The contents to replace with. A `FileChild` is a `Paragraph` or `Table`, whereas a `ParagraphChild` is typical `Paragraph` children. |
|
||||
|
||||
### How to patch existing document
|
||||
|
||||
1. Open your existing word document in your favorite Word Processor
|
||||
2. Write tags in the document where you want to patch in a mustache style notation. For example, `{{my_patch}}` and `{{my_second_patch}}`.
|
||||
3. Run the patcher with the patches as a key value pair.
|
||||
|
||||
## Example
|
||||
|
||||
### Word Document
|
||||
|
||||

|
||||
|
||||
### Patcher
|
||||
|
||||
?> Notice how there is no handlebar notation in the key.
|
||||
|
||||
The patch can be as simple as a string, or as complex as a table. Images, hyperlinks, and other complex elements within the `docx` library are also supported.
|
||||
|
||||
```ts
|
||||
patchDocument(fs.readFileSync("My Document.docx"), {
|
||||
patches: {
|
||||
my_patch: {
|
||||
type: PatchType.PARAGRAPH,
|
||||
children: [new TextRun("Sir. "), new TextRun("John Doe"), new TextRun("(The Conqueror)")],
|
||||
},
|
||||
my_second_patch: {
|
||||
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 } }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||
_Source: https://github.com/dolanmiu/docx/blob/master/demo/85-template-document.ts_
|
||||
|
||||
[Example](https://raw.githubusercontent.com/dolanmiu/docx/master/demo/85-template-document.ts ":include :type=code typescript")
|
2135
package-lock.json
generated
2135
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "docx",
|
||||
"version": "7.8.2",
|
||||
"version": "8.0.2",
|
||||
"description": "Easily generate .docx files with JS/TS with a nice declarative API. Works for Node and on the Browser.",
|
||||
"main": "build/index.js",
|
||||
"scripts": {
|
||||
@ -28,9 +28,7 @@
|
||||
"lint"
|
||||
],
|
||||
"files": [
|
||||
"src",
|
||||
"build",
|
||||
"template"
|
||||
"build"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -61,14 +59,14 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/dolanmiu/docx/issues"
|
||||
},
|
||||
"homepage": "https://github.com/dolanmiu/docx#readme",
|
||||
"homepage": "https://docx.js.org",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.15",
|
||||
"@types/glob": "^8.0.0",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/mocha": "^10.0.0",
|
||||
"@types/prompt": "^1.1.1",
|
||||
"@types/request-promise": "^4.1.42",
|
||||
"@types/shelljs": "^0.8.9",
|
||||
"@types/shelljs": "^0.8.11",
|
||||
"@types/sinon": "^10.0.0",
|
||||
"@types/unzipper": "^0.10.4",
|
||||
"@types/webpack": "^5.0.0",
|
||||
@ -76,16 +74,17 @@
|
||||
"@typescript-eslint/parser": "^5.36.1",
|
||||
"buffer": "^6.0.3",
|
||||
"chai": "^4.3.6",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"cspell": "^6.2.2",
|
||||
"docsify-cli": "^4.3.0",
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-plugin-functional": "^5.0.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-jsdoc": "^39.3.6",
|
||||
"eslint-plugin-jsdoc": "^40.0.0",
|
||||
"eslint-plugin-no-null": "^1.0.2",
|
||||
"eslint-plugin-prefer-arrow": "^1.2.3",
|
||||
"eslint-plugin-unicorn": "^45.0.0",
|
||||
"glob": "^8.0.1",
|
||||
"eslint-plugin-unicorn": "^46.0.0",
|
||||
"glob": "^9.3.0",
|
||||
"jszip": "^3.1.5",
|
||||
"mocha": "^10.0.0",
|
||||
"nyc": "^15.1.0",
|
||||
@ -105,7 +104,7 @@
|
||||
"tsconfig-paths": "^4.0.0",
|
||||
"tsconfig-paths-webpack-plugin": "^4.0.0",
|
||||
"typedoc": "^0.23.2",
|
||||
"typescript": "4.9.5",
|
||||
"typescript": "5.0.3",
|
||||
"unzipper": "^0.10.11",
|
||||
"webpack": "^5.28.0",
|
||||
"webpack-cli": "^5.0.0"
|
||||
|
@ -9,7 +9,7 @@ for (const file of files) {
|
||||
from: /"@[a-z/-]*"/gi,
|
||||
to: (match) => {
|
||||
const matchSlug = match.replace(/['"]+/g, "").replace(/[@]+/g, "").trim();
|
||||
const levelCount = file.split("/").length - 2;
|
||||
const levelCount = file.split(/[\/\\]/).length - 2;
|
||||
const backLevels = Array(levelCount).fill("../").join("");
|
||||
|
||||
return `"${backLevels}${matchSlug}"`;
|
||||
|
@ -59,9 +59,8 @@ export class Compiler {
|
||||
}
|
||||
}
|
||||
|
||||
for (const data of file.Media.Array) {
|
||||
const mediaData = data.stream;
|
||||
zip.file(`word/media/${data.fileName}`, mediaData);
|
||||
for (const { stream, fileName } of file.Media.Array) {
|
||||
zip.file(`word/media/${fileName}`, stream);
|
||||
}
|
||||
|
||||
return zip;
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { IDocumentTemplate } from "../import-dotx";
|
||||
|
||||
export interface IFileProperties {
|
||||
readonly template?: IDocumentTemplate;
|
||||
}
|
||||
|
||||
// Needed because of: https://github.com/s-panferov/awesome-typescript-loader/issues/432
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
export const WORKAROUND = "";
|
@ -1,12 +1,11 @@
|
||||
import { expect } from "chai";
|
||||
|
||||
import { Formatter } from "@export/formatter";
|
||||
import { sectionMarginDefaults, sectionPageSizeDefaults } from "./document";
|
||||
|
||||
import { sectionMarginDefaults, sectionPageSizeDefaults } from "./document";
|
||||
import { File } from "./file";
|
||||
import { Footer, Header } from "./header";
|
||||
import { Paragraph } from "./paragraph";
|
||||
import { Media } from "./media";
|
||||
|
||||
const PAGE_SIZE_DEFAULTS = {
|
||||
"w:h": sectionPageSizeDefaults.HEIGHT,
|
||||
@ -433,29 +432,6 @@ describe("File", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#templates", () => {
|
||||
// Test will be deprecated when import-dotx and templates are deprecated
|
||||
it("should work with template", () => {
|
||||
const doc = new File(
|
||||
{
|
||||
sections: [],
|
||||
},
|
||||
{
|
||||
template: {
|
||||
currentRelationshipId: 1,
|
||||
headers: [],
|
||||
footers: [],
|
||||
styles: "",
|
||||
titlePageIsDefined: true,
|
||||
media: new Media(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(doc).to.not.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("#externalStyles", () => {
|
||||
it("should work with external styles", () => {
|
||||
const doc = new File({
|
||||
|
@ -4,7 +4,6 @@ import { CoreProperties, IPropertiesOptions } from "./core-properties";
|
||||
import { CustomProperties } from "./custom-properties";
|
||||
import { DocumentWrapper } from "./document-wrapper";
|
||||
import { HeaderFooterReferenceType, ISectionPropertiesOptions } from "./document/body/section-properties";
|
||||
import { IFileProperties } from "./file-properties";
|
||||
import { FooterWrapper, IDocumentFooter } from "./footer-wrapper";
|
||||
import { FootnotesWrapper } from "./footnotes-wrapper";
|
||||
import { Footer, Header } from "./header";
|
||||
@ -55,7 +54,7 @@ export class File {
|
||||
private readonly styles: Styles;
|
||||
private readonly comments: Comments;
|
||||
|
||||
public constructor(options: IPropertiesOptions, fileProperties: IFileProperties = {}) {
|
||||
public constructor(options: IPropertiesOptions) {
|
||||
this.coreProperties = new CoreProperties({
|
||||
...options,
|
||||
creator: options.creator ?? "Un-named",
|
||||
@ -80,20 +79,9 @@ export class File {
|
||||
updateFields: options.features?.updateFields,
|
||||
});
|
||||
|
||||
this.media = fileProperties.template && fileProperties.template.media ? fileProperties.template.media : new Media();
|
||||
this.media = new Media();
|
||||
|
||||
if (fileProperties.template) {
|
||||
this.currentRelationshipId = fileProperties.template.currentRelationshipId + 1;
|
||||
}
|
||||
|
||||
// set up styles
|
||||
if (fileProperties.template && options.externalStyles) {
|
||||
throw Error("can not use both template and external styles");
|
||||
}
|
||||
if (fileProperties.template && fileProperties.template.styles) {
|
||||
const stylesFactory = new ExternalStylesFactory();
|
||||
this.styles = stylesFactory.newInstance(fileProperties.template.styles);
|
||||
} else if (options.externalStyles) {
|
||||
if (options.externalStyles) {
|
||||
const stylesFactory = new ExternalStylesFactory();
|
||||
this.styles = stylesFactory.newInstance(options.externalStyles);
|
||||
} else if (options.styles) {
|
||||
@ -110,18 +98,6 @@ export class File {
|
||||
|
||||
this.addDefaultRelationships();
|
||||
|
||||
if (fileProperties.template && fileProperties.template.headers) {
|
||||
for (const templateHeader of fileProperties.template.headers) {
|
||||
this.addHeaderToDocument(templateHeader.header, templateHeader.type);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileProperties.template && fileProperties.template.footers) {
|
||||
for (const templateFooter of fileProperties.template.footers) {
|
||||
this.addFooterToDocument(templateFooter.footer, templateFooter.type);
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of options.sections) {
|
||||
this.addSection(section);
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
export * from "./paragraph";
|
||||
export * from "./table";
|
||||
export * from "./file";
|
||||
export * from "./file-properties";
|
||||
export * from "./numbering";
|
||||
export * from "./media";
|
||||
export * from "./drawing";
|
||||
|
@ -15,94 +15,28 @@ describe("Media", () => {
|
||||
(convenienceFunctions.uniqueId as SinonStub).restore();
|
||||
});
|
||||
|
||||
describe("#addMedia", () => {
|
||||
it("should add media", () => {
|
||||
const image = new Media().addMedia("", {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
expect(image.fileName).to.equal("test.png");
|
||||
expect(image.transformation).to.deep.equal({
|
||||
pixels: {
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
flip: undefined,
|
||||
emus: {
|
||||
x: 952500,
|
||||
y: 952500,
|
||||
},
|
||||
rotation: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return UInt8Array if atob is present", () => {
|
||||
// eslint-disable-next-line functional/immutable-data
|
||||
global.atob = () => "atob result";
|
||||
|
||||
const image = new Media().addMedia("", {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
expect(image.stream).to.be.an.instanceof(Uint8Array);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, functional/immutable-data
|
||||
(global as any).atob = undefined;
|
||||
});
|
||||
|
||||
it("should use data as is if its not a string", () => {
|
||||
// eslint-disable-next-line functional/immutable-data
|
||||
global.atob = () => "atob result";
|
||||
|
||||
const image = new Media().addMedia(Buffer.from(""), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
expect(image.stream).to.be.an.instanceof(Uint8Array);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, functional/immutable-data
|
||||
(global as any).atob = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("#addImage", () => {
|
||||
it("should add media", () => {
|
||||
describe("#Array", () => {
|
||||
it("Get images as array", () => {
|
||||
const media = new Media();
|
||||
media.addMedia("", {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
media.addImage("test2.png", {
|
||||
stream: Buffer.from(""),
|
||||
fileName: "",
|
||||
fileName: "test.png",
|
||||
transformation: {
|
||||
pixels: {
|
||||
x: Math.round(1),
|
||||
y: Math.round(1),
|
||||
x: Math.round(100),
|
||||
y: Math.round(100),
|
||||
},
|
||||
flip: {
|
||||
vertical: true,
|
||||
horizontal: true,
|
||||
},
|
||||
emus: {
|
||||
x: Math.round(1 * 9525),
|
||||
y: Math.round(1 * 9525),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(media.Array).to.be.lengthOf(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#Array", () => {
|
||||
it("Get images as array", () => {
|
||||
const media = new Media();
|
||||
media.addMedia("", {
|
||||
width: 100,
|
||||
height: 100,
|
||||
flip: {
|
||||
vertical: true,
|
||||
horizontal: true,
|
||||
},
|
||||
rotation: 90,
|
||||
},
|
||||
});
|
||||
|
||||
const array = media.Array;
|
||||
@ -121,10 +55,10 @@ describe("Media", () => {
|
||||
horizontal: true,
|
||||
},
|
||||
emus: {
|
||||
x: 952500,
|
||||
y: 952500,
|
||||
x: 9525,
|
||||
y: 9525,
|
||||
},
|
||||
rotation: 5400000,
|
||||
rotation: 90,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { uniqueId } from "@util/convenience-functions";
|
||||
|
||||
import { IMediaData } from "./data";
|
||||
|
||||
export interface IMediaTransformation {
|
||||
@ -20,33 +18,6 @@ export class Media {
|
||||
this.map = new Map<string, IMediaData>();
|
||||
}
|
||||
|
||||
public addMedia(data: Buffer | string | Uint8Array | ArrayBuffer, transformation: IMediaTransformation): IMediaData {
|
||||
const key = `${uniqueId()}.png`;
|
||||
|
||||
const newData = typeof data === "string" ? this.convertDataURIToBinary(data) : data;
|
||||
|
||||
const imageData: IMediaData = {
|
||||
stream: newData,
|
||||
fileName: key,
|
||||
transformation: {
|
||||
pixels: {
|
||||
x: Math.round(transformation.width),
|
||||
y: Math.round(transformation.height),
|
||||
},
|
||||
emus: {
|
||||
x: Math.round(transformation.width * 9525),
|
||||
y: Math.round(transformation.height * 9525),
|
||||
},
|
||||
flip: transformation.flip,
|
||||
rotation: transformation.rotation ? transformation.rotation * 60000 : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
this.map.set(key, imageData);
|
||||
|
||||
return imageData;
|
||||
}
|
||||
|
||||
public addImage(key: string, mediaData: IMediaData): void {
|
||||
this.map.set(key, mediaData);
|
||||
}
|
||||
@ -54,24 +25,4 @@ export class Media {
|
||||
public get Array(): readonly IMediaData[] {
|
||||
return Array.from(this.map.values());
|
||||
}
|
||||
|
||||
private convertDataURIToBinary(dataURI: string): Uint8Array {
|
||||
// https://gist.github.com/borismus/1032746
|
||||
// https://github.com/mafintosh/base64-to-uint8array
|
||||
const BASE64_MARKER = ";base64,";
|
||||
|
||||
const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length;
|
||||
|
||||
if (typeof atob === "function") {
|
||||
return new Uint8Array(
|
||||
atob(dataURI.substring(base64Index))
|
||||
.split("")
|
||||
.map((c) => c.charCodeAt(0)),
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
|
||||
const b = require("buf" + "fer");
|
||||
return new b.Buffer(dataURI, "base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -125,6 +125,21 @@ describe("ParagraphProperties", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create with the autoSpaceEastAsianText property", () => {
|
||||
const properties = new ParagraphProperties({
|
||||
autoSpaceEastAsianText: true,
|
||||
});
|
||||
const tree = new Formatter().format(properties);
|
||||
|
||||
expect(tree).to.deep.equal({
|
||||
"w:pPr": [
|
||||
{
|
||||
"w:autoSpaceDN": {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create with the wordWrap property", () => {
|
||||
const properties = new ParagraphProperties({
|
||||
wordWrap: true,
|
||||
|
@ -23,7 +23,13 @@ export interface ILevelParagraphStylePropertiesOptions {
|
||||
readonly leftTabStop?: number;
|
||||
readonly indent?: IIndentAttributesProperties;
|
||||
readonly spacing?: ISpacingProperties;
|
||||
/**
|
||||
* Specifies that the paragraph (or at least part of it) should be rendered on the same page as the next paragraph when possible. If multiple paragraphs are to be kept together but they exceed a page, then the set of paragraphs begin on a new page and page breaks are used thereafter as needed.
|
||||
*/
|
||||
readonly keepNext?: boolean;
|
||||
/**
|
||||
* Specifies that all lines of the paragraph are to be kept on a single page when possible.
|
||||
*/
|
||||
readonly keepLines?: boolean;
|
||||
readonly outlineLevel?: number;
|
||||
}
|
||||
@ -53,6 +59,11 @@ export interface IParagraphPropertiesOptions extends IParagraphStylePropertiesOp
|
||||
readonly suppressLineNumbers?: boolean;
|
||||
readonly wordWrap?: boolean;
|
||||
readonly scale?: number;
|
||||
/**
|
||||
* This element specifies whether inter-character spacing shall automatically be adjusted between regions of numbers and regions of East Asian text in the current paragraph. These regions shall be determined by the Unicode character values of the text content within the paragraph.
|
||||
* This only works in Microsoft Word. It is not part of the ECMA-376 OOXML standard.
|
||||
*/
|
||||
readonly autoSpaceEastAsianText?: boolean;
|
||||
}
|
||||
|
||||
export class ParagraphProperties extends IgnoreIfEmptyXmlComponent {
|
||||
@ -179,6 +190,10 @@ export class ParagraphProperties extends IgnoreIfEmptyXmlComponent {
|
||||
if (options.suppressLineNumbers !== undefined) {
|
||||
this.push(new OnOffElement("w:suppressLineNumbers", options.suppressLineNumbers));
|
||||
}
|
||||
|
||||
if (options.autoSpaceEastAsianText !== undefined) {
|
||||
this.push(new OnOffElement("w:autoSpaceDN", options.autoSpaceEastAsianText));
|
||||
}
|
||||
}
|
||||
|
||||
public push(item: XmlComponent): void {
|
||||
|
@ -2,6 +2,8 @@ import { expect } from "chai";
|
||||
import * as sinon from "sinon";
|
||||
|
||||
import { Formatter } from "@export/formatter";
|
||||
|
||||
import { Paragraph } from "../paragraph";
|
||||
import { Comment, CommentRangeEnd, CommentRangeStart, CommentReference, Comments } from "./comment-run";
|
||||
|
||||
describe("CommentRangeStart", () => {
|
||||
@ -56,7 +58,7 @@ describe("Comment", () => {
|
||||
it("should create", () => {
|
||||
const component = new Comment({
|
||||
id: 0,
|
||||
text: "test-comment",
|
||||
children: [new Paragraph("test-comment")],
|
||||
date: new Date("1999-01-01T00:00:00.000Z"),
|
||||
});
|
||||
const tree = new Formatter().format(component);
|
||||
@ -88,7 +90,7 @@ describe("Comment", () => {
|
||||
it("should create by using default date", () => {
|
||||
const component = new Comment({
|
||||
id: 0,
|
||||
text: "test-comment",
|
||||
children: [new Paragraph("test-comment")],
|
||||
});
|
||||
const tree = new Formatter().format(component);
|
||||
expect(tree).to.deep.equal({
|
||||
@ -125,12 +127,12 @@ describe("Comments", () => {
|
||||
children: [
|
||||
{
|
||||
id: 0,
|
||||
text: "test-comment",
|
||||
children: [new Paragraph("test-comment")],
|
||||
date: new Date("1999-01-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
text: "test-comment-2",
|
||||
children: [new Paragraph("test-comment-2")],
|
||||
date: new Date("1999-01-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { Paragraph } from "@file/paragraph";
|
||||
import { FileChild } from "@file/file-child";
|
||||
import { XmlAttributeComponent, XmlComponent } from "@file/xml-components";
|
||||
|
||||
import { TextRun } from "./text-run";
|
||||
|
||||
export interface ICommentOptions {
|
||||
readonly id: number;
|
||||
readonly text: string;
|
||||
readonly children: readonly FileChild[];
|
||||
readonly initials?: string;
|
||||
readonly author?: string;
|
||||
readonly date?: Date;
|
||||
@ -120,7 +118,7 @@ export class CommentReference extends XmlComponent {
|
||||
}
|
||||
|
||||
export class Comment extends XmlComponent {
|
||||
public constructor({ id, initials, author, date = new Date(), text }: ICommentOptions) {
|
||||
public constructor({ id, initials, author, date = new Date(), children }: ICommentOptions) {
|
||||
super("w:comment");
|
||||
|
||||
this.root.push(
|
||||
@ -132,7 +130,9 @@ export class Comment extends XmlComponent {
|
||||
}),
|
||||
);
|
||||
|
||||
this.root.push(new Paragraph({ children: [new TextRun(text)] }));
|
||||
for (const child of children) {
|
||||
this.root.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
export class Comments extends XmlComponent {
|
||||
|
@ -8,7 +8,6 @@ export interface IBaseCharacterStyleOptions extends IStyleOptions {
|
||||
|
||||
export interface ICharacterStyleOptions extends IBaseCharacterStyleOptions {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export class StyleForCharacter extends Style {
|
||||
|
@ -9,7 +9,6 @@ export interface IBaseParagraphStyleOptions extends IStyleOptions {
|
||||
|
||||
export interface IParagraphStyleOptions extends IBaseParagraphStyleOptions {
|
||||
readonly id: string;
|
||||
readonly name?: string;
|
||||
}
|
||||
|
||||
export class StyleForParagraph extends Style {
|
||||
|
@ -41,6 +41,13 @@ export interface IStyleAttributes {
|
||||
|
||||
export interface IStyleOptions {
|
||||
readonly name?: string;
|
||||
/**
|
||||
* Specifies the style upon which the current style is based-that is, the style from which the current style inherits. It is the mechanism for implementing style inheritance.
|
||||
* Note that if the type of the current style must match the type of the style upon which it is based or the basedOn element will be ignored.
|
||||
* However, if the current style is a numbering style, then the `basedOn` element is ignored.
|
||||
*
|
||||
* **WARNING**: You cannot set `basedOn` to be the same as `name`. This is akin to inheriting from itself. This creates a cyclic dependency and cause undesirable behavior.
|
||||
*/
|
||||
readonly basedOn?: string;
|
||||
readonly next?: string;
|
||||
readonly link?: string;
|
||||
|
@ -38,9 +38,7 @@ describe("Table Float Properties", () => {
|
||||
expect(tree).to.deep.equal({
|
||||
"w:tblpPr": [
|
||||
{
|
||||
_attr: {
|
||||
overlap: "never",
|
||||
},
|
||||
_attr: {},
|
||||
},
|
||||
{
|
||||
"w:tblOverlap": {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StringEnumValueElement, XmlAttributeComponent, XmlComponent } from "@file/xml-components";
|
||||
import { NextAttributeComponent, StringEnumValueElement, XmlComponent } from "@file/xml-components";
|
||||
import { PositiveUniversalMeasure, signedTwipsMeasureValue, twipsMeasureValue, UniversalMeasure } from "@util/values";
|
||||
|
||||
export enum TableAnchorType {
|
||||
@ -35,7 +35,7 @@ export enum OverlapType {
|
||||
OVERLAP = "overlap",
|
||||
}
|
||||
|
||||
export interface ITableFloatOptions {
|
||||
export type ITableFloatOptions = {
|
||||
/* cSpell:disable */
|
||||
/**
|
||||
* Specifies the horizontal anchor or the base object from which the horizontal positioning in the
|
||||
@ -124,7 +124,7 @@ export interface ITableFloatOptions {
|
||||
*/
|
||||
readonly rightFromText?: number | PositiveUniversalMeasure;
|
||||
readonly overlap?: OverlapType;
|
||||
}
|
||||
};
|
||||
|
||||
// <xsd:complexType name="CT_TblPPr">
|
||||
// <xsd:attribute name="leftFromText" type="s:ST_TwipsMeasure"/>
|
||||
@ -139,51 +139,65 @@ export interface ITableFloatOptions {
|
||||
// <xsd:attribute name="tblpY" type="ST_SignedTwipsMeasure"/>
|
||||
// </xsd:complexType>
|
||||
|
||||
export class TableFloatOptionsAttributes extends XmlAttributeComponent<ITableFloatOptions> {
|
||||
protected readonly xmlKeys = {
|
||||
horizontalAnchor: "w:horzAnchor",
|
||||
verticalAnchor: "w:vertAnchor",
|
||||
absoluteHorizontalPosition: "w:tblpX",
|
||||
relativeHorizontalPosition: "w:tblpXSpec",
|
||||
absoluteVerticalPosition: "w:tblpY",
|
||||
relativeVerticalPosition: "w:tblpYSpec",
|
||||
bottomFromText: "w:bottomFromText",
|
||||
topFromText: "w:topFromText",
|
||||
leftFromText: "w:leftFromText",
|
||||
rightFromText: "w:rightFromText",
|
||||
};
|
||||
}
|
||||
|
||||
export class TableFloatProperties extends XmlComponent {
|
||||
public constructor({
|
||||
horizontalAnchor,
|
||||
verticalAnchor,
|
||||
absoluteHorizontalPosition,
|
||||
relativeHorizontalPosition,
|
||||
absoluteVerticalPosition,
|
||||
relativeVerticalPosition,
|
||||
bottomFromText,
|
||||
topFromText,
|
||||
leftFromText,
|
||||
rightFromText,
|
||||
topFromText,
|
||||
bottomFromText,
|
||||
absoluteHorizontalPosition,
|
||||
absoluteVerticalPosition,
|
||||
...options
|
||||
overlap,
|
||||
}: ITableFloatOptions) {
|
||||
super("w:tblpPr");
|
||||
this.root.push(
|
||||
new TableFloatOptionsAttributes({
|
||||
leftFromText: leftFromText === undefined ? undefined : twipsMeasureValue(leftFromText),
|
||||
rightFromText: rightFromText === undefined ? undefined : twipsMeasureValue(rightFromText),
|
||||
topFromText: topFromText === undefined ? undefined : twipsMeasureValue(topFromText),
|
||||
bottomFromText: bottomFromText === undefined ? undefined : twipsMeasureValue(bottomFromText),
|
||||
absoluteHorizontalPosition:
|
||||
absoluteHorizontalPosition === undefined ? undefined : signedTwipsMeasureValue(absoluteHorizontalPosition),
|
||||
absoluteVerticalPosition:
|
||||
absoluteVerticalPosition === undefined ? undefined : signedTwipsMeasureValue(absoluteVerticalPosition),
|
||||
...options,
|
||||
new NextAttributeComponent<Omit<ITableFloatOptions, "overlap">>({
|
||||
leftFromText: { key: "w:leftFromText", value: leftFromText === undefined ? undefined : twipsMeasureValue(leftFromText) },
|
||||
rightFromText: {
|
||||
key: "w:rightFromText",
|
||||
value: rightFromText === undefined ? undefined : twipsMeasureValue(rightFromText),
|
||||
},
|
||||
topFromText: { key: "w:topFromText", value: topFromText === undefined ? undefined : twipsMeasureValue(topFromText) },
|
||||
bottomFromText: {
|
||||
key: "w:bottomFromText",
|
||||
value: bottomFromText === undefined ? undefined : twipsMeasureValue(bottomFromText),
|
||||
},
|
||||
absoluteHorizontalPosition: {
|
||||
key: "w:tblpX",
|
||||
value: absoluteHorizontalPosition === undefined ? undefined : signedTwipsMeasureValue(absoluteHorizontalPosition),
|
||||
},
|
||||
absoluteVerticalPosition: {
|
||||
key: "w:tblpY",
|
||||
value: absoluteVerticalPosition === undefined ? undefined : signedTwipsMeasureValue(absoluteVerticalPosition),
|
||||
},
|
||||
horizontalAnchor: {
|
||||
key: "w:horzAnchor",
|
||||
value: horizontalAnchor === undefined ? undefined : horizontalAnchor,
|
||||
},
|
||||
relativeHorizontalPosition: {
|
||||
key: "w:tblpXSpec",
|
||||
value: relativeHorizontalPosition,
|
||||
},
|
||||
relativeVerticalPosition: {
|
||||
key: "w:tblpYSpec",
|
||||
value: relativeVerticalPosition,
|
||||
},
|
||||
verticalAnchor: {
|
||||
key: "w:vertAnchor",
|
||||
value: verticalAnchor,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
if (options.overlap) {
|
||||
if (overlap) {
|
||||
// <xsd:complexType name="CT_TblOverlap">
|
||||
// <xsd:attribute name="val" type="ST_TblOverlap" use="required"/>
|
||||
// </xsd:complexType>
|
||||
this.root.push(new StringEnumValueElement<OverlapType>("w:tblOverlap", options.overlap));
|
||||
this.root.push(new StringEnumValueElement<OverlapType>("w:tblOverlap", overlap));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
41
src/file/xml-components/simple-elements.spec.ts
Normal file
41
src/file/xml-components/simple-elements.spec.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { expect } from "chai";
|
||||
|
||||
import { Formatter } from "@export/formatter";
|
||||
|
||||
import { BuilderElement } from "./simple-elements";
|
||||
|
||||
describe("BuilderElement", () => {
|
||||
describe("#constructor()", () => {
|
||||
it("should create a simple BuilderElement", () => {
|
||||
const element = new BuilderElement({
|
||||
name: "test",
|
||||
});
|
||||
|
||||
const tree = new Formatter().format(element);
|
||||
expect(tree).to.deep.equal({
|
||||
test: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a simple BuilderElement with attributes", () => {
|
||||
const element = new BuilderElement<{ readonly testAttr: string }>({
|
||||
name: "test",
|
||||
attributes: {
|
||||
testAttr: {
|
||||
key: "w:testAttr",
|
||||
value: "test",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tree = new Formatter().format(element);
|
||||
expect(tree).to.deep.equal({
|
||||
test: {
|
||||
_attr: {
|
||||
"w:testAttr": "test",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -92,5 +92,7 @@ export class BuilderElement<T extends AttributeData> extends XmlComponent {
|
||||
if (options.attributes) {
|
||||
this.root.push(new NextAttributeComponent(options.attributes));
|
||||
}
|
||||
|
||||
// TODO: Children
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { expect } from "chai";
|
||||
|
||||
import { ImportDotx } from "./import-dotx";
|
||||
|
||||
describe("ImportDotx", () => {
|
||||
describe("#constructor", () => {
|
||||
it("should create", () => {
|
||||
const file = new ImportDotx();
|
||||
|
||||
expect(file).to.deep.equal({});
|
||||
});
|
||||
});
|
||||
|
||||
// describe("#extract", () => {
|
||||
// it("should create", async () => {
|
||||
// const file = new ImportDotx();
|
||||
// const filePath = "./demo/dotx/template.dotx";
|
||||
|
||||
// const templateDocument = await file.extract(data);
|
||||
|
||||
// await file.extract(data);
|
||||
|
||||
// expect(templateDocument).to.be.equal({ currentRelationshipId: 1 });
|
||||
// });
|
||||
// });
|
||||
});
|
@ -1,266 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// This will be deprecated soon
|
||||
import * as JSZip from "jszip";
|
||||
import { Element as XMLElement, ElementCompact as XMLElementCompact, xml2js } from "xml-js";
|
||||
|
||||
import { HeaderFooterReferenceType } from "@file/document/body/section-properties";
|
||||
import { FooterWrapper, IDocumentFooter } from "@file/footer-wrapper";
|
||||
import { HeaderWrapper, IDocumentHeader } from "@file/header-wrapper";
|
||||
import { Media } from "@file/media";
|
||||
import { TargetModeType } from "@file/relationships/relationship/relationship";
|
||||
import { convertToXmlComponent, ImportedXmlComponent } from "@file/xml-components";
|
||||
|
||||
const schemeToType = {
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/header": "header",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer": "footer",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image": "image",
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink": "hyperlink",
|
||||
};
|
||||
|
||||
interface IDocumentRefs {
|
||||
readonly headers: { readonly id: number; readonly type: HeaderFooterReferenceType }[];
|
||||
readonly footers: { readonly id: number; readonly type: HeaderFooterReferenceType }[];
|
||||
}
|
||||
|
||||
enum RelationshipType {
|
||||
HEADER = "header",
|
||||
FOOTER = "footer",
|
||||
IMAGE = "image",
|
||||
HYPERLINK = "hyperlink",
|
||||
}
|
||||
|
||||
interface IRelationshipFileInfo {
|
||||
readonly id: number;
|
||||
readonly target: string;
|
||||
readonly type: RelationshipType;
|
||||
}
|
||||
|
||||
// Document Template
|
||||
// https://fileinfo.com/extension/dotx
|
||||
export interface IDocumentTemplate {
|
||||
readonly currentRelationshipId: number;
|
||||
readonly headers: IDocumentHeader[];
|
||||
readonly footers: IDocumentFooter[];
|
||||
readonly styles: string;
|
||||
readonly titlePageIsDefined: boolean;
|
||||
readonly media: Media;
|
||||
}
|
||||
|
||||
export class ImportDotx {
|
||||
public async extract(
|
||||
data: Buffer | string | number[] | Uint8Array | ArrayBuffer | Blob | NodeJS.ReadableStream,
|
||||
): Promise<IDocumentTemplate> {
|
||||
const zipContent = await JSZip.loadAsync(data);
|
||||
|
||||
const documentContent = await zipContent.files["word/document.xml"].async("text");
|
||||
const relationshipContent = await zipContent.files["word/_rels/document.xml.rels"].async("text");
|
||||
|
||||
const documentRefs = this.extractDocumentRefs(documentContent);
|
||||
const documentRelationships = this.findReferenceFiles(relationshipContent);
|
||||
|
||||
const media = new Media();
|
||||
|
||||
const templateDocument: IDocumentTemplate = {
|
||||
headers: await this.createHeaders(zipContent, documentRefs, documentRelationships, media, 0),
|
||||
footers: await this.createFooters(zipContent, documentRefs, documentRelationships, media, documentRefs.headers.length),
|
||||
currentRelationshipId: documentRefs.footers.length + documentRefs.headers.length,
|
||||
styles: await zipContent.files["word/styles.xml"].async("text"),
|
||||
titlePageIsDefined: this.checkIfTitlePageIsDefined(documentContent),
|
||||
media: media,
|
||||
};
|
||||
|
||||
return templateDocument;
|
||||
}
|
||||
|
||||
private async createFooters(
|
||||
zipContent: JSZip,
|
||||
documentRefs: IDocumentRefs,
|
||||
documentRelationships: IRelationshipFileInfo[],
|
||||
media: Media,
|
||||
startingRelationshipId: number,
|
||||
): Promise<IDocumentFooter[]> {
|
||||
const result = documentRefs.footers
|
||||
.map(async (reference, i) => {
|
||||
const relationshipFileInfo = documentRelationships.find((rel) => rel.id === reference.id);
|
||||
|
||||
if (relationshipFileInfo === null || !relationshipFileInfo) {
|
||||
throw new Error(`Can not find target file for id ${reference.id}`);
|
||||
}
|
||||
|
||||
const xmlData = await zipContent.files[`word/${relationshipFileInfo.target}`].async("text");
|
||||
const xmlObj = xml2js(xmlData, { compact: false, captureSpacesBetweenElements: true }) as XMLElement;
|
||||
|
||||
if (!xmlObj.elements) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const xmlElement = xmlObj.elements.reduce((acc, current) => (current.name === "w:ftr" ? current : acc));
|
||||
|
||||
const importedComp = convertToXmlComponent(xmlElement) as ImportedXmlComponent;
|
||||
const wrapper = new FooterWrapper(media, startingRelationshipId + i, importedComp);
|
||||
await this.addRelationshipToWrapper(relationshipFileInfo, zipContent, wrapper, media);
|
||||
|
||||
return { type: reference.type, footer: wrapper };
|
||||
})
|
||||
.filter((x) => !!x) as Promise<IDocumentFooter>[];
|
||||
|
||||
return Promise.all(result);
|
||||
}
|
||||
|
||||
private async createHeaders(
|
||||
zipContent: JSZip,
|
||||
documentRefs: IDocumentRefs,
|
||||
documentRelationships: IRelationshipFileInfo[],
|
||||
media: Media,
|
||||
startingRelationshipId: number,
|
||||
): Promise<IDocumentHeader[]> {
|
||||
const result = documentRefs.headers
|
||||
.map(async (reference, i) => {
|
||||
const relationshipFileInfo = documentRelationships.find((rel) => rel.id === reference.id);
|
||||
|
||||
if (relationshipFileInfo === null || !relationshipFileInfo) {
|
||||
throw new Error(`Can not find target file for id ${reference.id}`);
|
||||
}
|
||||
|
||||
const xmlData = await zipContent.files[`word/${relationshipFileInfo.target}`].async("text");
|
||||
const xmlObj = xml2js(xmlData, { compact: false, captureSpacesBetweenElements: true }) as XMLElement;
|
||||
|
||||
if (!xmlObj.elements) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const xmlElement = xmlObj.elements.reduce((acc, current) => (current.name === "w:hdr" ? current : acc));
|
||||
|
||||
const importedComp = convertToXmlComponent(xmlElement) as ImportedXmlComponent;
|
||||
const wrapper = new HeaderWrapper(media, startingRelationshipId + i, importedComp);
|
||||
await this.addRelationshipToWrapper(relationshipFileInfo, zipContent, wrapper, media);
|
||||
|
||||
return { type: reference.type, header: wrapper };
|
||||
})
|
||||
.filter((x) => !!x) as Promise<IDocumentHeader>[];
|
||||
|
||||
return Promise.all(result);
|
||||
}
|
||||
|
||||
private async addRelationshipToWrapper(
|
||||
relationshipFile: IRelationshipFileInfo,
|
||||
zipContent: JSZip,
|
||||
wrapper: HeaderWrapper | FooterWrapper,
|
||||
media: Media,
|
||||
): Promise<void> {
|
||||
const refFile = zipContent.files[`word/_rels/${relationshipFile.target}.rels`];
|
||||
|
||||
if (!refFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xmlRef = await refFile.async("text");
|
||||
const wrapperImagesReferences = this.findReferenceFiles(xmlRef).filter((r) => r.type === RelationshipType.IMAGE);
|
||||
const hyperLinkReferences = this.findReferenceFiles(xmlRef).filter((r) => r.type === RelationshipType.HYPERLINK);
|
||||
|
||||
for (const r of wrapperImagesReferences) {
|
||||
const bufferType = JSZip.support.arraybuffer ? "arraybuffer" : "nodebuffer";
|
||||
const buffer = await zipContent.files[`word/${r.target}`].async(bufferType);
|
||||
const mediaData = media.addMedia(buffer, {
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
wrapper.Relationships.createRelationship(
|
||||
r.id,
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
|
||||
`media/${mediaData.fileName}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const r of hyperLinkReferences) {
|
||||
wrapper.Relationships.createRelationship(
|
||||
r.id,
|
||||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
|
||||
r.target,
|
||||
TargetModeType.EXTERNAL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private findReferenceFiles(xmlData: string): IRelationshipFileInfo[] {
|
||||
const xmlObj = xml2js(xmlData, { compact: true }) as XMLElementCompact;
|
||||
const relationXmlArray = Array.isArray(xmlObj.Relationships.Relationship)
|
||||
? xmlObj.Relationships.Relationship
|
||||
: [xmlObj.Relationships.Relationship];
|
||||
const relationships: IRelationshipFileInfo[] = relationXmlArray
|
||||
.map((item: XMLElementCompact) => {
|
||||
if (item._attributes === undefined) {
|
||||
throw Error("relationship element has no attributes");
|
||||
}
|
||||
return {
|
||||
id: this.parseRefId(item._attributes.Id as string),
|
||||
type: schemeToType[item._attributes.Type as string],
|
||||
target: item._attributes.Target as string,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.type !== null);
|
||||
return relationships;
|
||||
}
|
||||
|
||||
private extractDocumentRefs(xmlData: string): IDocumentRefs {
|
||||
const xmlObj = xml2js(xmlData, { compact: true }) as XMLElementCompact;
|
||||
const sectionProp = xmlObj["w:document"]["w:body"]["w:sectPr"];
|
||||
|
||||
const headerProps: XMLElementCompact = sectionProp["w:headerReference"];
|
||||
let headersXmlArray: XMLElementCompact[];
|
||||
if (headerProps === undefined) {
|
||||
headersXmlArray = [];
|
||||
} else if (Array.isArray(headerProps)) {
|
||||
headersXmlArray = headerProps;
|
||||
} else {
|
||||
headersXmlArray = [headerProps];
|
||||
}
|
||||
const headers = headersXmlArray.map((item) => {
|
||||
if (item._attributes === undefined) {
|
||||
throw Error("header reference element has no attributes");
|
||||
}
|
||||
return {
|
||||
type: item._attributes["w:type"] as HeaderFooterReferenceType,
|
||||
id: this.parseRefId(item._attributes["r:id"] as string),
|
||||
};
|
||||
});
|
||||
|
||||
const footerProps: XMLElementCompact = sectionProp["w:footerReference"];
|
||||
let footersXmlArray: XMLElementCompact[];
|
||||
if (footerProps === undefined) {
|
||||
footersXmlArray = [];
|
||||
} else if (Array.isArray(footerProps)) {
|
||||
footersXmlArray = footerProps;
|
||||
} else {
|
||||
footersXmlArray = [footerProps];
|
||||
}
|
||||
|
||||
const footers = footersXmlArray.map((item) => {
|
||||
if (item._attributes === undefined) {
|
||||
throw Error("footer reference element has no attributes");
|
||||
}
|
||||
return {
|
||||
type: item._attributes["w:type"] as HeaderFooterReferenceType,
|
||||
id: this.parseRefId(item._attributes["r:id"] as string),
|
||||
};
|
||||
});
|
||||
|
||||
return { headers, footers };
|
||||
}
|
||||
|
||||
private checkIfTitlePageIsDefined(xmlData: string): boolean {
|
||||
const xmlObj = xml2js(xmlData, { compact: true }) as XMLElementCompact;
|
||||
const sectionProp = xmlObj["w:document"]["w:body"]["w:sectPr"];
|
||||
|
||||
return sectionProp["w:titlePg"] !== undefined;
|
||||
}
|
||||
|
||||
private parseRefId(str: string): number {
|
||||
const match = /^rId(\d+)$/.exec(str);
|
||||
if (match === null) {
|
||||
throw new Error("Invalid ref id");
|
||||
}
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from "./import-dotx";
|
@ -3,5 +3,5 @@
|
||||
export { File as Document } from "./file";
|
||||
export * from "./file";
|
||||
export * from "./export";
|
||||
export * from "./import-dotx";
|
||||
export * from "./util";
|
||||
export * from "./patcher";
|
||||
|
51
src/patcher/content-types-manager.spec.ts
Normal file
51
src/patcher/content-types-manager.spec.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { expect } from "chai";
|
||||
import { appendContentType } from "./content-types-manager";
|
||||
|
||||
describe("content-types-manager", () => {
|
||||
describe("appendContentType", () => {
|
||||
it("should append a content type", () => {
|
||||
const element = {
|
||||
type: "element",
|
||||
name: "xml",
|
||||
elements: [
|
||||
{
|
||||
type: "element",
|
||||
name: "Types",
|
||||
elements: [
|
||||
{
|
||||
type: "element",
|
||||
name: "Default",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
appendContentType(element, "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "docx");
|
||||
|
||||
expect(element).to.deep.equal({
|
||||
elements: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
name: "Default",
|
||||
type: "element",
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
|
||||
Extension: "docx",
|
||||
},
|
||||
name: "Default",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "Types",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "xml",
|
||||
type: "element",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
16
src/patcher/content-types-manager.ts
Normal file
16
src/patcher/content-types-manager.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Element } from "xml-js";
|
||||
|
||||
import { getFirstLevelElements } from "./util";
|
||||
|
||||
export const appendContentType = (element: Element, contentType: string, extension: string): void => {
|
||||
const relationshipElements = getFirstLevelElements(element, "Types");
|
||||
// eslint-disable-next-line functional/immutable-data
|
||||
relationshipElements.push({
|
||||
attributes: {
|
||||
ContentType: contentType,
|
||||
Extension: extension,
|
||||
},
|
||||
name: "Default",
|
||||
type: "element",
|
||||
});
|
||||
};
|
413
src/patcher/from-docx.spec.ts
Normal file
413
src/patcher/from-docx.spec.ts
Normal file
@ -0,0 +1,413 @@
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(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", () => {
|
||||
beforeEach(() => {
|
||||
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);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(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 },
|
||||
}),
|
||||
new ExternalHyperlink({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "Google Link",
|
||||
}),
|
||||
],
|
||||
link: "https://www.google.co.uk",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(output).to.not.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe("document.xml", () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(JSZip, "loadAsync").callsFake(
|
||||
() =>
|
||||
new Promise<JSZip>((resolve) => {
|
||||
const zip = new JSZip();
|
||||
|
||||
zip.file("word/document.xml", MOCK_XML);
|
||||
resolve(zip);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(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);
|
||||
});
|
||||
|
||||
describe("Images", () => {
|
||||
beforeEach(() => {
|
||||
sinon.stub(JSZip, "loadAsync").callsFake(
|
||||
() =>
|
||||
new Promise<JSZip>((resolve) => {
|
||||
const zip = new JSZip();
|
||||
|
||||
zip.file("word/document.xml", MOCK_XML);
|
||||
zip.file("word/document.bmp", "");
|
||||
|
||||
resolve(zip);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(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);
|
||||
});
|
||||
});
|
||||
});
|
244
src/patcher/from-docx.ts
Normal file
244
src/patcher/from-docx.ts
Normal file
@ -0,0 +1,244 @@
|
||||
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;
|
||||
|
||||
const binaryContentMap = new Map<string, Buffer>();
|
||||
|
||||
for (const [key, value] of Object.entries(zipContent.files)) {
|
||||
if (!key.endsWith(".xml") && !key.endsWith(".rels")) {
|
||||
binaryContentMap.set(key, await value.async("nodebuffer"));
|
||||
continue;
|
||||
}
|
||||
|
||||
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 [key, value] of binaryContentMap) {
|
||||
zip.file(key, value);
|
||||
}
|
||||
|
||||
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";
|
277
src/patcher/paragraph-split-inject.spec.ts
Normal file
277
src/patcher/paragraph-split-inject.spec.ts
Normal file
@ -0,0 +1,277 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it("should continue if text run doesn't have text", () => {
|
||||
expect(() =>
|
||||
findRunElementIndexWithToken(
|
||||
{
|
||||
name: "w:p",
|
||||
type: "element",
|
||||
elements: [
|
||||
{
|
||||
name: "w:r",
|
||||
type: "element",
|
||||
elements: [
|
||||
{
|
||||
name: "w:t",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"hello",
|
||||
),
|
||||
).to.throw();
|
||||
});
|
||||
|
||||
it("should continue if text run doesn't have text", () => {
|
||||
expect(() =>
|
||||
findRunElementIndexWithToken(
|
||||
{
|
||||
name: "w:p",
|
||||
type: "element",
|
||||
elements: [
|
||||
{
|
||||
name: "w:r",
|
||||
type: "element",
|
||||
elements: [
|
||||
{
|
||||
name: "w:t",
|
||||
type: "element",
|
||||
elements: [
|
||||
{
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
"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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
58
src/patcher/paragraph-split-inject.ts
Normal file
58
src/patcher/paragraph-split-inject.ts
Normal file
@ -0,0 +1,58 @@
|
||||
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]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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 };
|
||||
};
|
315
src/patcher/paragraph-token-replacer.spec.ts
Normal file
315
src/patcher/paragraph-token-replacer.spec.ts
Normal file
@ -0,0 +1,315 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle case where it cannot find any text to replace", () => {
|
||||
const output = replaceTokenInParagraphElement({
|
||||
paragraphElement: {
|
||||
name: "w:p",
|
||||
attributes: {
|
||||
"w14:paraId": "2499FE9F",
|
||||
"w14:textId": "27B4FBC2",
|
||||
"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",
|
||||
attributes: { "xml:space": "preserve" },
|
||||
elements: [{ type: "text", text: "Hello " }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
name: "w:r",
|
||||
attributes: { "w:rsidR": "007F116B" },
|
||||
elements: [
|
||||
{
|
||||
type: "element",
|
||||
name: "w:t",
|
||||
attributes: { "xml:space": "preserve" },
|
||||
elements: [{ type: "text", text: "{{name}} " }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "element",
|
||||
name: "w:r",
|
||||
elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "World" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
renderedParagraph: {
|
||||
text: "Hello {{name}} World",
|
||||
runs: [
|
||||
{ text: "Hello ", parts: [{ text: "Hello ", index: 0, start: 0, end: 5 }], index: 1, start: 0, end: 5 },
|
||||
{ text: "{{name}} ", parts: [{ text: "{{name}} ", index: 0, start: 6, end: 14 }], index: 2, start: 6, end: 14 },
|
||||
{ text: "World", parts: [{ text: "World", index: 0, start: 15, end: 19 }], index: 3, start: 15, end: 19 },
|
||||
],
|
||||
index: 0,
|
||||
path: [0, 1, 0, 0],
|
||||
},
|
||||
originalText: "{{name}}",
|
||||
replacementText: "John",
|
||||
});
|
||||
|
||||
expect(output).to.deep.equal({
|
||||
attributes: {
|
||||
"w14:paraId": "2499FE9F",
|
||||
"w14:textId": "27B4FBC2",
|
||||
"w:rsidP": "007B52ED",
|
||||
"w:rsidR": "00B51233",
|
||||
"w:rsidRDefault": "007B52ED",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
attributes: {
|
||||
"w:val": "Title",
|
||||
},
|
||||
name: "w:pStyle",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "w:pPr",
|
||||
type: "element",
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
attributes: {
|
||||
"xml:space": "preserve",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
text: "Hello ",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
name: "w:t",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "w:r",
|
||||
type: "element",
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
"w:rsidR": "007F116B",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
attributes: {
|
||||
"xml:space": "preserve",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
text: "John ",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
name: "w:t",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "w:r",
|
||||
type: "element",
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
attributes: {
|
||||
"xml:space": "preserve",
|
||||
},
|
||||
elements: [
|
||||
{
|
||||
text: "World",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
name: "w:t",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
name: "w:r",
|
||||
type: "element",
|
||||
},
|
||||
],
|
||||
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",
|
||||
// });
|
||||
// });
|
||||
});
|
||||
});
|
75
src/patcher/paragraph-token-replacer.ts
Normal file
75
src/patcher/paragraph-token-replacer.ts
Normal file
@ -0,0 +1,75 @@
|
||||
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 offsetStartIndex = startIndex - start;
|
||||
const offsetEndIndex = Math.min(endIndex, end) - start;
|
||||
const partToReplace = run.text.substring(offsetStartIndex, offsetEndIndex + 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
|
||||
if (partToReplace === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
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, captureSpacesBetweenElements: true }) as Element;
|
||||
return xmlObj;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line functional/prefer-readonly-type
|
||||
export const createTextElementContents = (text: string): Element[] => {
|
||||
const textJson = toJson(xml(formatter.format(new Text({ text }))));
|
||||
|
||||
return textJson.elements![0].elements ?? [];
|
||||
};
|
||||
|
||||
export const patchSpaceAttribute = (element: Element): Element => ({
|
||||
...element,
|
||||
attributes: {
|
||||
"xml:space": "preserve",
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line functional/prefer-readonly-type
|
||||
export const getFirstLevelElements = (relationships: Element, id: string): Element[] =>
|
||||
relationships.elements?.filter((e) => e.name === id)[0].elements ?? [];
|
@ -20,5 +20,10 @@
|
||||
"@shared": ["./shared/index.ts"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"typedocOptions": {
|
||||
"out": "docs/api",
|
||||
"exclude": "test",
|
||||
|
Reference in New Issue
Block a user