Compare commits

..

1 Commits

Author SHA1 Message Date
c5a2c1c0cb Ability to add PageNumber to paragraph 2023-02-13 23:04:59 +00:00
60 changed files with 1443 additions and 4357 deletions

View File

@ -301,6 +301,15 @@ 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

View File

@ -41,6 +41,3 @@ build-tests
# docs
docs
# src
src

6
.nycrc
View File

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

18
.vscode/launch.json vendored
View File

@ -1,18 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Demo",
"type": "node",
"request": "launch",
"runtimeArgs": [
"-r",
"${workspaceFolder}/node_modules/ts-node/register",
"-r",
"${workspaceFolder}/node_modules/tsconfig-paths/register"
],
"cwd": "${workspaceRoot}",
"program": "${workspaceFolder}/demo/85-template-document.ts"
}
]
}

View File

@ -3,7 +3,7 @@
</p>
<p align="center">
Easily generate and modify .docx files with JS/TS. Works for Node and on the Browser.
Easily generate .docx files with JS/TS. Works for Node and on the Browser.
</p>
---
@ -88,7 +88,6 @@ 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!

View File

@ -0,0 +1,35 @@
// 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);
});
});
});

View File

@ -5,34 +5,7 @@ import { Document, Packer, Paragraph, TextRun, CommentRangeStart, CommentRangeEn
const doc = new Document({
comments: {
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,
}),
],
}),
],
},
],
children: [{ id: 0, author: "Ray Chen", date: new Date(), text: "comment text content" }],
},
sections: [
{

View File

@ -1,155 +0,0 @@
// Patch a document with patches
// Import from 'docx' rather than '../build' if you install from npm
import * as fs from "fs";
import {
ExternalHyperlink,
HeadingLevel,
ImageRun,
Paragraph,
patchDocument,
PatchType,
Table,
TableCell,
TableRow,
TextDirection,
TextRun,
VerticalAlign,
} from "../build";
patchDocument(fs.readFileSync("demo/assets/simple-template.docx"), {
patches: {
name: {
type: PatchType.PARAGRAPH,
children: [new TextRun("Sir. "), new TextRun("John Doe"), new TextRun("(The Conqueror)")],
},
table_heading_1: {
type: PatchType.PARAGRAPH,
children: [new TextRun("Heading wow!")],
},
item_1: {
type: PatchType.PARAGRAPH,
children: [
new TextRun("#657"),
new ExternalHyperlink({
children: [
new TextRun({
text: "BBC News Link",
}),
],
link: "https://www.bbc.co.uk/news",
}),
],
},
paragraph_replace: {
type: PatchType.DOCUMENT,
children: [
new Paragraph("Lorem ipsum paragraph"),
new Paragraph("Another paragraph"),
new Paragraph({
children: [
new TextRun("This is a "),
new ExternalHyperlink({
children: [
new TextRun({
text: "Google Link",
}),
],
link: "https://www.google.co.uk",
}),
new ImageRun({ data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }),
],
}),
],
},
header_adjective: {
type: PatchType.PARAGRAPH,
children: [new TextRun("Delightful Header")],
},
footer_text: {
type: PatchType.PARAGRAPH,
children: [
new TextRun("replaced just as"),
new TextRun(" well"),
new ExternalHyperlink({
children: [
new TextRun({
text: "BBC News Link",
}),
],
link: "https://www.bbc.co.uk/news",
}),
],
},
image_test: {
type: PatchType.PARAGRAPH,
children: [new ImageRun({ data: fs.readFileSync("./demo/images/image1.jpeg"), transformation: { width: 100, height: 100 } })],
},
table: {
type: PatchType.DOCUMENT,
children: [
new Table({
rows: [
new TableRow({
children: [
new TableCell({
children: [new Paragraph({}), new Paragraph({})],
verticalAlign: VerticalAlign.CENTER,
}),
new TableCell({
children: [new Paragraph({}), new Paragraph({})],
verticalAlign: VerticalAlign.CENTER,
}),
new TableCell({
children: [new Paragraph({ text: "bottom to top" }), new Paragraph({})],
textDirection: TextDirection.BOTTOM_TO_TOP_LEFT_TO_RIGHT,
}),
new TableCell({
children: [new Paragraph({ text: "top to bottom" }), new Paragraph({})],
textDirection: TextDirection.TOP_TO_BOTTOM_RIGHT_TO_LEFT,
}),
],
}),
new TableRow({
children: [
new TableCell({
children: [
new Paragraph({
text: "Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah Blah",
heading: HeadingLevel.HEADING_1,
}),
],
}),
new TableCell({
children: [
new Paragraph({
text: "This text should be in the middle of the cell",
}),
],
verticalAlign: VerticalAlign.CENTER,
}),
new TableCell({
children: [
new Paragraph({
text: "Text above should be vertical from bottom to top",
}),
],
verticalAlign: VerticalAlign.CENTER,
}),
new TableCell({
children: [
new Paragraph({
text: "Text above should be vertical from top to bottom",
}),
],
verticalAlign: VerticalAlign.CENTER,
}),
],
}),
],
}),
],
},
},
}).then((doc) => {
fs.writeFileSync("My Document.docx", doc);
});

View File

@ -1,20 +0,0 @@
// Generate a template document
// Import from 'docx' rather than '../build' if you install from npm
import * as fs from "fs";
import { Document, Packer, Paragraph, TextRun } from "../build";
const doc = new Document({
sections: [
{
children: [
new Paragraph({
children: [new TextRun("{{template}}")],
}),
],
},
],
});
Packer.toBuffer(doc).then((buffer) => {
fs.writeFileSync("My Document.docx", buffer);
});

Binary file not shown.

Binary file not shown.

View File

@ -1,10 +1,10 @@
<img src="https://i.imgur.com/37uBGhO.gif" alt="drawing" style="width:200px;"/>
> Easily generate and modify .docx files with JS/TS. Works for Node and on the Browser. :100:
> Easily generate .docx files with JS/TS. Works for Node and on the Browser. :100:
- Simple, declarative API
- 80+ usage examples
- Battle tested, mature, 99.9%+ coverage
- 60+ usage examples
- Battle tested, mature, 99%+ coverage
[GitHub](https://github.com/dolanmiu/docx)
[Get Started](#Welcome)

View File

@ -1,8 +1,6 @@
- [Getting Started](/)
- Examples
- [Demos](https://github.com/dolanmiu/docx/tree/master/demo)
- [Examples](https://github.com/dolanmiu/docx/tree/master/demo)
- API
@ -38,10 +36,6 @@
- [Packers](usage/packers.md)
- Modifying Existing Documents
- [Patcher](usage/patcher.md)
- Utility
- [Convenience functions](usage/convenience-functions.md)

View File

@ -1,21 +1,11 @@
# Contribution Guidelines
- Include documentation reference(s) at the top of each file as a comment. For example:
- Include documentation reference(s) at the top of each file:
```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

View File

@ -1,42 +0,0 @@
# 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.

View File

@ -1,94 +0,0 @@
# 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
![Word Document screenshot](https://i.imgur.com/ybkvw6Z.png)
### 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")

2068
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "docx",
"version": "8.0.0",
"version": "7.8.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": {
@ -64,7 +64,6 @@
"homepage": "https://github.com/dolanmiu/docx#readme",
"devDependencies": {
"@types/chai": "^4.2.15",
"@types/chai-as-promised": "^7.1.5",
"@types/glob": "^8.0.0",
"@types/mocha": "^10.0.0",
"@types/prompt": "^1.1.1",
@ -77,17 +76,16 @@
"@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": "^40.0.0",
"eslint-plugin-jsdoc": "^39.3.6",
"eslint-plugin-no-null": "^1.0.2",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-unicorn": "^46.0.0",
"glob": "^9.3.0",
"eslint-plugin-unicorn": "^45.0.0",
"glob": "^8.0.1",
"jszip": "^3.1.5",
"mocha": "^10.0.0",
"nyc": "^15.1.0",
@ -107,7 +105,7 @@
"tsconfig-paths": "^4.0.0",
"tsconfig-paths-webpack-plugin": "^4.0.0",
"typedoc": "^0.23.2",
"typescript": "5.0.2",
"typescript": "4.9.5",
"unzipper": "^0.10.11",
"webpack": "^5.28.0",
"webpack-cli": "^5.0.0"

View File

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

View File

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

View File

@ -0,0 +1,11 @@
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 = "";

View File

@ -1,11 +1,12 @@
import { expect } from "chai";
import { Formatter } from "@export/formatter";
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,
@ -432,6 +433,29 @@ 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({

View File

@ -4,6 +4,7 @@ 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";
@ -54,7 +55,7 @@ export class File {
private readonly styles: Styles;
private readonly comments: Comments;
public constructor(options: IPropertiesOptions) {
public constructor(options: IPropertiesOptions, fileProperties: IFileProperties = {}) {
this.coreProperties = new CoreProperties({
...options,
creator: options.creator ?? "Un-named",
@ -79,9 +80,20 @@ export class File {
updateFields: options.features?.updateFields,
});
this.media = new Media();
this.media = fileProperties.template && fileProperties.template.media ? fileProperties.template.media : new Media();
if (options.externalStyles) {
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) {
const stylesFactory = new ExternalStylesFactory();
this.styles = stylesFactory.newInstance(options.externalStyles);
} else if (options.styles) {
@ -98,6 +110,18 @@ 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);
}

View File

@ -1,6 +1,7 @@
export * from "./paragraph";
export * from "./table";
export * from "./file";
export * from "./file-properties";
export * from "./numbering";
export * from "./media";
export * from "./drawing";

View File

@ -15,30 +15,96 @@ describe("Media", () => {
(convenienceFunctions.uniqueId as SinonStub).restore();
});
describe("#Array", () => {
it("Get images as array", () => {
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", () => {
const media = new Media();
media.addMedia("", {
width: 100,
height: 100,
});
media.addImage("test2.png", {
stream: Buffer.from(""),
fileName: "test.png",
fileName: "",
transformation: {
pixels: {
x: Math.round(100),
y: Math.round(100),
},
flip: {
vertical: true,
horizontal: true,
x: Math.round(1),
y: Math.round(1),
},
emus: {
x: Math.round(1 * 9525),
y: Math.round(1 * 9525),
},
rotation: 90,
},
});
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;
expect(array).to.be.an.instanceof(Array);
expect(array.length).to.equal(1);
@ -55,10 +121,10 @@ describe("Media", () => {
horizontal: true,
},
emus: {
x: 9525,
y: 9525,
x: 952500,
y: 952500,
},
rotation: 90,
rotation: 5400000,
});
});
});

View File

@ -1,3 +1,5 @@
import { uniqueId } from "@util/convenience-functions";
import { IMediaData } from "./data";
export interface IMediaTransformation {
@ -18,6 +20,33 @@ 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);
}
@ -25,4 +54,24 @@ 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");
}
}
}

View File

@ -10,8 +10,10 @@ import { ColumnBreak, PageBreak } from "./formatting/break";
import { Bookmark, ConcreteHyperlink, ExternalHyperlink, InternalHyperlink } from "./links";
import { Math } from "./math";
import { IParagraphPropertiesOptions, ParagraphProperties } from "./properties";
import { ImageRun, Run, SequentialIdentifier, SimpleField, SimpleMailMergeField, SymbolRun, TextRun } from "./run";
import { ImageRun, PageNumber, Run, SequentialIdentifier, SimpleField, SimpleMailMergeField, SymbolRun, TextRun } from "./run";
import { Comment, CommentRangeEnd, CommentRangeStart, CommentReference, Comments } from "./run/comment-run";
import { Begin, End, Separate } from "./run/field";
import { NumberOfPages, NumberOfPagesSection, Page } from "./run/page-number";
export type ParagraphChild =
| TextRun
@ -33,7 +35,8 @@ export type ParagraphChild =
| Comment
| CommentRangeStart
| CommentRangeEnd
| CommentReference;
| CommentReference
| PageNumber;
export interface IParagraphOptions extends IParagraphPropertiesOptions {
readonly text?: string;
@ -63,6 +66,34 @@ export class Paragraph extends FileChild {
if (options.children) {
for (const child of options.children) {
if (typeof child === "string") {
switch (child) {
case PageNumber.CURRENT:
this.root.push(new TextRun({ children: [new Begin()] }));
this.root.push(new TextRun({ children: [new Page()] }));
this.root.push(new TextRun({ children: [new Separate()] }));
this.root.push(new TextRun({ children: [new End()] }));
break;
case PageNumber.TOTAL_PAGES:
this.root.push(new TextRun({ children: [new Begin()] }));
this.root.push(new TextRun({ children: [new NumberOfPages()] }));
this.root.push(new TextRun({ children: [new Separate()] }));
this.root.push(new TextRun("0"));
this.root.push(new TextRun({ children: [new End()] }));
break;
case PageNumber.TOTAL_PAGES_IN_SECTION:
this.root.push(new TextRun({ children: [new Begin()] }));
this.root.push(new TextRun({ children: [new NumberOfPagesSection()] }));
this.root.push(new TextRun({ children: [new Separate()] }));
this.root.push(new TextRun({ children: [new End()] }));
break;
default:
this.root.push(new TextRun(child));
break;
}
continue;
}
if (child instanceof Bookmark) {
this.root.push(child.start);
for (const textRun of child.children) {

View File

@ -125,21 +125,6 @@ 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,

View File

@ -53,11 +53,6 @@ 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 {
@ -184,10 +179,6 @@ 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 {

View File

@ -2,8 +2,6 @@ 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", () => {
@ -58,7 +56,7 @@ describe("Comment", () => {
it("should create", () => {
const component = new Comment({
id: 0,
children: [new Paragraph("test-comment")],
text: "test-comment",
date: new Date("1999-01-01T00:00:00.000Z"),
});
const tree = new Formatter().format(component);
@ -90,7 +88,7 @@ describe("Comment", () => {
it("should create by using default date", () => {
const component = new Comment({
id: 0,
children: [new Paragraph("test-comment")],
text: "test-comment",
});
const tree = new Formatter().format(component);
expect(tree).to.deep.equal({
@ -127,12 +125,12 @@ describe("Comments", () => {
children: [
{
id: 0,
children: [new Paragraph("test-comment")],
text: "test-comment",
date: new Date("1999-01-01T00:00:00.000Z"),
},
{
id: 1,
children: [new Paragraph("test-comment-2")],
text: "test-comment-2",
date: new Date("1999-01-01T00:00:00.000Z"),
},
],

View File

@ -1,9 +1,11 @@
import { FileChild } from "@file/file-child";
import { Paragraph } from "@file/paragraph";
import { XmlAttributeComponent, XmlComponent } from "@file/xml-components";
import { TextRun } from "./text-run";
export interface ICommentOptions {
readonly id: number;
readonly children: readonly FileChild[];
readonly text: string;
readonly initials?: string;
readonly author?: string;
readonly date?: Date;
@ -118,7 +120,7 @@ export class CommentReference extends XmlComponent {
}
export class Comment extends XmlComponent {
public constructor({ id, initials, author, date = new Date(), children }: ICommentOptions) {
public constructor({ id, initials, author, date = new Date(), text }: ICommentOptions) {
super("w:comment");
this.root.push(
@ -130,9 +132,7 @@ export class Comment extends XmlComponent {
}),
);
for (const child of children) {
this.root.push(child);
}
this.root.push(new Paragraph({ children: [new TextRun(text)] }));
}
}
export class Comments extends XmlComponent {

View File

@ -1,13 +1,29 @@
// http://www.datypic.com/sc/ooxml/e-w_fldChar-1.html
import { XmlAttributeComponent, XmlComponent } from "@file/xml-components";
// <xsd:complexType name="CT_FldChar">
// <xsd:choice>
// <xsd:element name="fldData" type="CT_Text" minOccurs="0" maxOccurs="1" />
// <xsd:element name="ffData" type="CT_FFData" minOccurs="0" maxOccurs="1" />
// <xsd:element name="numberingChange" type="CT_TrackChangeNumbering" minOccurs="0" />
// </xsd:choice>
// <xsd:attribute name="fldCharType" type="ST_FldCharType" use="required" />
// <xsd:attribute name="fldLock" type="s:ST_OnOff" />
// <xsd:attribute name="dirty" type="s:ST_OnOff" />
// </xsd:complexType>
enum FieldCharacterType {
BEGIN = "begin",
END = "end",
SEPARATE = "separate",
}
class FidCharAttrs extends XmlAttributeComponent<{ readonly type: FieldCharacterType; readonly dirty?: boolean }> {
protected readonly xmlKeys = { type: "w:fldCharType", dirty: "w:dirty" };
class FidCharAttrs extends XmlAttributeComponent<{
readonly type: FieldCharacterType;
readonly dirty?: boolean;
readonly fieldLock?: boolean;
}> {
protected readonly xmlKeys = { type: "w:fldCharType", dirty: "w:dirty", fieldLock: "w:fldLock" };
}
export class Begin extends XmlComponent {

View File

@ -38,7 +38,9 @@ describe("Table Float Properties", () => {
expect(tree).to.deep.equal({
"w:tblpPr": [
{
_attr: {},
_attr: {
overlap: "never",
},
},
{
"w:tblOverlap": {

View File

@ -1,4 +1,4 @@
import { NextAttributeComponent, StringEnumValueElement, XmlComponent } from "@file/xml-components";
import { StringEnumValueElement, XmlAttributeComponent, 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 type ITableFloatOptions = {
export interface ITableFloatOptions {
/* cSpell:disable */
/**
* Specifies the horizontal anchor or the base object from which the horizontal positioning in the
@ -124,7 +124,7 @@ export type ITableFloatOptions = {
*/
readonly rightFromText?: number | PositiveUniversalMeasure;
readonly overlap?: OverlapType;
};
}
// <xsd:complexType name="CT_TblPPr">
// <xsd:attribute name="leftFromText" type="s:ST_TwipsMeasure"/>
@ -139,65 +139,51 @@ export type 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,
overlap,
topFromText,
bottomFromText,
absoluteHorizontalPosition,
absoluteVerticalPosition,
...options
}: ITableFloatOptions) {
super("w:tblpPr");
this.root.push(
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,
},
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,
}),
);
if (overlap) {
if (options.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", overlap));
this.root.push(new StringEnumValueElement<OverlapType>("w:tblOverlap", options.overlap));
}
}
}

View File

@ -1,41 +0,0 @@
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",
},
},
});
});
});
});

View File

@ -92,7 +92,5 @@ export class BuilderElement<T extends AttributeData> extends XmlComponent {
if (options.attributes) {
this.root.push(new NextAttributeComponent(options.attributes));
}
// TODO: Children
}
}

View File

@ -0,0 +1,26 @@
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 });
// });
// });
});

View File

@ -0,0 +1,266 @@
/* 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
src/import-dotx/index.ts Normal file
View File

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

View File

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

View File

@ -1,51 +0,0 @@
import { expect } from "chai";
import { appendContentType } from "./content-types-manager";
describe("content-types-manager", () => {
describe("appendContentType", () => {
it("should append a content type", () => {
const element = {
type: "element",
name: "xml",
elements: [
{
type: "element",
name: "Types",
elements: [
{
type: "element",
name: "Default",
},
],
},
],
};
appendContentType(element, "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "docx");
expect(element).to.deep.equal({
elements: [
{
elements: [
{
name: "Default",
type: "element",
},
{
attributes: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
Extension: "docx",
},
name: "Default",
type: "element",
},
],
name: "Types",
type: "element",
},
],
name: "xml",
type: "element",
});
});
});
});

View File

@ -1,16 +0,0 @@
import { Element } from "xml-js";
import { getFirstLevelElements } from "./util";
export const appendContentType = (element: Element, contentType: string, extension: string): void => {
const relationshipElements = getFirstLevelElements(element, "Types");
// eslint-disable-next-line functional/immutable-data
relationshipElements.push({
attributes: {
ContentType: contentType,
Extension: extension,
},
name: "Default",
type: "element",
});
};

View File

@ -1,370 +0,0 @@
import * as chai from "chai";
import * as sinon from "sinon";
import * as JSZip from "jszip";
import * as chaiAsPromised from "chai-as-promised";
import { ExternalHyperlink, ImageRun, Paragraph, TextRun } from "@file/paragraph";
import { patchDocument, PatchType } from "./from-docx";
chai.use(chaiAsPromised);
const { expect } = chai;
const MOCK_XML = `
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
xmlns:cx="http://schemas.microsoft.com/office/drawing/2014/chartex"
xmlns:cx1="http://schemas.microsoft.com/office/drawing/2015/9/8/chartex"
xmlns:cx2="http://schemas.microsoft.com/office/drawing/2015/10/21/chartex"
xmlns:cx3="http://schemas.microsoft.com/office/drawing/2016/5/9/chartex"
xmlns:cx4="http://schemas.microsoft.com/office/drawing/2016/5/10/chartex"
xmlns:cx5="http://schemas.microsoft.com/office/drawing/2016/5/11/chartex"
xmlns:cx6="http://schemas.microsoft.com/office/drawing/2016/5/12/chartex"
xmlns:cx7="http://schemas.microsoft.com/office/drawing/2016/5/13/chartex"
xmlns:cx8="http://schemas.microsoft.com/office/drawing/2016/5/14/chartex"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:aink="http://schemas.microsoft.com/office/drawing/2016/ink"
xmlns:am3d="http://schemas.microsoft.com/office/drawing/2017/model3d"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:oel="http://schemas.microsoft.com/office/2019/extlst"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:w10="urn:schemas-microsoft-com:office:word"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"
xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml"
xmlns:w16cex="http://schemas.microsoft.com/office/word/2018/wordml/cex"
xmlns:w16cid="http://schemas.microsoft.com/office/word/2016/wordml/cid"
xmlns:w16="http://schemas.microsoft.com/office/word/2018/wordml"
xmlns:w16sdtdh="http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash"
xmlns:w16se="http://schemas.microsoft.com/office/word/2015/wordml/symex"
xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"
xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"
xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
<w:body>
<w:p w14:paraId="2499FE9F" w14:textId="0A3D130F" w:rsidR="00B51233"
w:rsidRDefault="007B52ED" w:rsidP="007B52ED">
<w:pPr>
<w:pStyle w:val="Title" />
</w:pPr>
<w:r>
<w:t>Hello World</w:t>
</w:r>
</w:p>
<w:p w14:paraId="6410D9A0" w14:textId="7579AB49" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="57ACF964" w14:textId="315D7A05" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>Hello {{name}},</w:t>
</w:r>
<w:r w:rsidR="008126CB">
<w:t xml:space="preserve"> how are you?</w:t>
</w:r>
</w:p>
<w:p w14:paraId="38C7DF4A" w14:textId="66CDEC9A" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="04FABE2B" w14:textId="3DACA001" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>{{paragraph_replace}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="7AD7975D" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
<w:p w14:paraId="3BD6D75A" w14:textId="19AE3121" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>{{table}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="76023962" w14:textId="4E606AB9" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:tbl>
<w:tblPr>
<w:tblStyle w:val="TableGrid" />
<w:tblW w:w="0" w:type="auto" />
<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="1"
w:lastColumn="0" w:noHBand="0" w:noVBand="1" />
</w:tblPr>
<w:tblGrid>
<w:gridCol w:w="3003" />
<w:gridCol w:w="3003" />
<w:gridCol w:w="3004" />
</w:tblGrid>
<w:tr w:rsidR="00EF161F" w14:paraId="1DEC5955" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="54DA5587" w14:textId="625BAC60" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>{{table_heading_1}}</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="57100910" w14:textId="71FD5616" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1D388FAB" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
<w:tr w:rsidR="00EF161F" w14:paraId="0F53D2DC" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="0F2BCCED" w14:textId="3C3B6706" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F">
<w:r>
<w:t>Item: {{item_1}}</w:t>
</w:r>
</w:p>
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1E6158AC" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="17937748" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
<w:tr w:rsidR="00EF161F" w14:paraId="781DAC1A" w14:textId="77777777" w:rsidTr="00EF161F">
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="1DCD0343" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3003" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="5D02E3CD" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
<w:tc>
<w:tcPr>
<w:tcW w:w="3004" w:type="dxa" />
</w:tcPr>
<w:p w14:paraId="52EA0DBB" w14:textId="77777777" w:rsidR="00EF161F"
w:rsidRDefault="00EF161F" />
</w:tc>
</w:tr>
</w:tbl>
<w:p w14:paraId="47CD1FBC" w14:textId="23474CBC" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED" />
<w:p w14:paraId="0ACCEE90" w14:textId="67907499" w:rsidR="00EF161F"
w:rsidRDefault="0077578F">
<w:r>
<w:t>{{image_test}}</w:t>
</w:r>
</w:p>
<w:p w14:paraId="23FA9862" w14:textId="77777777" w:rsidR="0077578F"
w:rsidRDefault="0077578F" />
<w:p w14:paraId="01578F2F" w14:textId="3BDC6C85" w:rsidR="007B52ED"
w:rsidRDefault="007B52ED">
<w:r>
<w:t>Thank you</w:t>
</w:r>
</w:p>
<w:sectPr w:rsidR="007B52ED" w:rsidSect="0072043F">
<w:headerReference w:type="default" r:id="rId6" />
<w:footerReference w:type="default" r:id="rId7" />
<w:pgSz w:w="11900" w:h="16840" />
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708"
w:footer="708" w:gutter="0" />
<w:cols w:space="708" />
<w:docGrid w:linePitch="360" />
</w:sectPr>
</w:body>
</w:document>
`;
describe("from-docx", () => {
describe("patchDocument", () => {
describe("document.xml and [Content_Types].xml", () => {
before(() => {
sinon.createStubInstance(JSZip, {});
sinon.stub(JSZip, "loadAsync").callsFake(
() =>
new Promise<JSZip>((resolve) => {
const zip = new JSZip();
zip.file("word/document.xml", MOCK_XML);
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
resolve(zip);
}),
);
});
after(() => {
(JSZip.loadAsync as unknown as sinon.SinonStub).restore();
});
it("should patch the document", async () => {
const output = await patchDocument(Buffer.from(""), {
patches: {
name: {
type: PatchType.PARAGRAPH,
children: [new TextRun("Sir. "), new TextRun("John Doe"), new TextRun("(The Conqueror)")],
},
item_1: {
type: PatchType.PARAGRAPH,
children: [
new TextRun("#657"),
new ExternalHyperlink({
children: [
new TextRun({
text: "BBC News Link",
}),
],
link: "https://www.bbc.co.uk/news",
}),
],
},
// eslint-disable-next-line @typescript-eslint/naming-convention
paragraph_replace: {
type: PatchType.DOCUMENT,
children: [
new Paragraph({
children: [
new TextRun("This is a "),
new ExternalHyperlink({
children: [
new TextRun({
text: "Google Link",
}),
],
link: "https://www.google.co.uk",
}),
new ImageRun({
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
}),
],
},
// eslint-disable-next-line @typescript-eslint/naming-convention
image_test: {
type: PatchType.PARAGRAPH,
children: [
new ImageRun({
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
},
},
});
expect(output).to.not.be.undefined;
});
it("should patch the document", async () => {
const output = await patchDocument(Buffer.from(""), {
patches: {},
});
expect(output).to.not.be.undefined;
});
});
describe("document.xml and [Content_Types].xml with relationships", () => {
before(() => {
sinon.createStubInstance(JSZip, {});
sinon.stub(JSZip, "loadAsync").callsFake(
() =>
new Promise<JSZip>((resolve) => {
const zip = new JSZip();
zip.file("word/document.xml", MOCK_XML);
zip.file("word/_rels/document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`);
resolve(zip);
}),
);
});
after(() => {
(JSZip.loadAsync as unknown as sinon.SinonStub).restore();
});
it("should use the relationships file rather than create one", async () => {
const output = await patchDocument(Buffer.from(""), {
patches: {
// eslint-disable-next-line @typescript-eslint/naming-convention
image_test: {
type: PatchType.PARAGRAPH,
children: [
new ImageRun({
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
},
},
});
expect(output).to.not.be.undefined;
});
});
describe("document.xml", () => {
before(() => {
sinon.createStubInstance(JSZip, {});
sinon.stub(JSZip, "loadAsync").callsFake(
() =>
new Promise<JSZip>((resolve) => {
const zip = new JSZip();
zip.file("word/document.xml", MOCK_XML);
resolve(zip);
}),
);
});
after(() => {
(JSZip.loadAsync as unknown as sinon.SinonStub).restore();
});
it("should throw an error if the content types is not found", () =>
expect(
patchDocument(Buffer.from(""), {
patches: {
// eslint-disable-next-line @typescript-eslint/naming-convention
image_test: {
type: PatchType.PARAGRAPH,
children: [
new ImageRun({
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
},
},
}),
).to.eventually.be.rejected);
});
});
});

View File

@ -1,233 +0,0 @@
import * as JSZip from "jszip";
import { Element, js2xml } from "xml-js";
import { ConcreteHyperlink, ExternalHyperlink, ParagraphChild } from "@file/paragraph";
import { FileChild } from "@file/file-child";
import { IMediaData, Media } from "@file/media";
import { IViewWrapper } from "@file/document-wrapper";
import { File } from "@file/file";
import { IContext } from "@file/xml-components";
import { ImageReplacer } from "@export/packer/image-replacer";
import { TargetModeType } from "@file/relationships/relationship/relationship";
import { uniqueId } from "@util/convenience-functions";
import { replacer } from "./replacer";
import { findLocationOfText } from "./traverser";
import { toJson } from "./util";
import { appendRelationship, getNextRelationshipIndex } from "./relationship-manager";
import { appendContentType } from "./content-types-manager";
// eslint-disable-next-line functional/prefer-readonly-type
type InputDataType = Buffer | string | number[] | Uint8Array | ArrayBuffer | Blob | NodeJS.ReadableStream;
export enum PatchType {
DOCUMENT = "file",
PARAGRAPH = "paragraph",
}
type ParagraphPatch = {
readonly type: PatchType.PARAGRAPH;
readonly children: readonly ParagraphChild[];
};
type FilePatch = {
readonly type: PatchType.DOCUMENT;
readonly children: readonly FileChild[];
};
interface IImageRelationshipAddition {
readonly key: string;
readonly mediaDatas: readonly IMediaData[];
}
interface IHyperlinkRelationshipAddition {
readonly key: string;
readonly hyperlink: { readonly id: string; readonly link: string };
}
export type IPatch = ParagraphPatch | FilePatch;
export interface PatchDocumentOptions {
readonly patches: { readonly [key: string]: IPatch };
}
const imageReplacer = new ImageReplacer();
export const patchDocument = async (data: InputDataType, options: PatchDocumentOptions): Promise<Buffer> => {
const zipContent = await JSZip.loadAsync(data);
const contexts = new Map<string, IContext>();
const file = {
Media: new Media(),
} as unknown as File;
const map = new Map<string, Element>();
// eslint-disable-next-line functional/prefer-readonly-type
const imageRelationshipAdditions: IImageRelationshipAddition[] = [];
// eslint-disable-next-line functional/prefer-readonly-type
const hyperlinkRelationshipAdditions: IHyperlinkRelationshipAddition[] = [];
let hasMedia = false;
for (const [key, value] of Object.entries(zipContent.files)) {
const json = toJson(await value.async("text"));
if (key.startsWith("word/") && !key.endsWith(".xml.rels")) {
const context: IContext = {
file,
viewWrapper: {
Relationships: {
createRelationship: (linkId: string, _: string, target: string, __: TargetModeType) => {
// eslint-disable-next-line functional/immutable-data
hyperlinkRelationshipAdditions.push({
key,
hyperlink: {
id: linkId,
link: target,
},
});
},
},
} as unknown as IViewWrapper,
stack: [],
};
contexts.set(key, context);
for (const [patchKey, patchValue] of Object.entries(options.patches)) {
const patchText = `{{${patchKey}}}`;
const renderedParagraphs = findLocationOfText(json, patchText);
// TODO: mutates json. Make it immutable
replacer(
json,
{
...patchValue,
children: patchValue.children.map((element) => {
// We need to replace external hyperlinks with concrete hyperlinks
if (element instanceof ExternalHyperlink) {
const concreteHyperlink = new ConcreteHyperlink(element.options.children, uniqueId());
// eslint-disable-next-line functional/immutable-data
hyperlinkRelationshipAdditions.push({
key,
hyperlink: {
id: concreteHyperlink.linkId,
link: element.options.link,
},
});
return concreteHyperlink;
} else {
return element;
}
}),
},
patchText,
renderedParagraphs,
context,
);
}
const mediaDatas = imageReplacer.getMediaData(JSON.stringify(json), context.file.Media);
if (mediaDatas.length > 0) {
hasMedia = true;
// eslint-disable-next-line functional/immutable-data
imageRelationshipAdditions.push({
key,
mediaDatas,
});
}
}
map.set(key, json);
}
for (const { key, mediaDatas } of imageRelationshipAdditions) {
// eslint-disable-next-line functional/immutable-data
const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`;
const relationshipsJson = map.get(relationshipKey) ?? createRelationshipFile();
map.set(relationshipKey, relationshipsJson);
const index = getNextRelationshipIndex(relationshipsJson);
const newJson = imageReplacer.replace(JSON.stringify(map.get(key)), mediaDatas, index);
map.set(key, JSON.parse(newJson) as Element);
for (let i = 0; i < mediaDatas.length; i++) {
const { fileName } = mediaDatas[i];
appendRelationship(
relationshipsJson,
index + i,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
`media/${fileName}`,
);
}
}
for (const { key, hyperlink } of hyperlinkRelationshipAdditions) {
// eslint-disable-next-line functional/immutable-data
const relationshipKey = `word/_rels/${key.split("/").pop()}.rels`;
const relationshipsJson = map.get(relationshipKey) ?? createRelationshipFile();
map.set(relationshipKey, relationshipsJson);
appendRelationship(
relationshipsJson,
hyperlink.id,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
hyperlink.link,
TargetModeType.EXTERNAL,
);
}
if (hasMedia) {
const contentTypesJson = map.get("[Content_Types].xml");
if (!contentTypesJson) {
throw new Error("Could not find content types file");
}
appendContentType(contentTypesJson, "image/png", "png");
appendContentType(contentTypesJson, "image/jpeg", "jpeg");
appendContentType(contentTypesJson, "image/jpeg", "jpg");
appendContentType(contentTypesJson, "image/bmp", "bmp");
appendContentType(contentTypesJson, "image/gif", "gif");
}
const zip = new JSZip();
for (const [key, value] of map) {
const output = toXml(value);
zip.file(key, output);
}
for (const { stream, fileName } of file.Media.Array) {
zip.file(`word/media/${fileName}`, stream);
}
return zip.generateAsync({
type: "nodebuffer",
mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
compression: "DEFLATE",
});
};
const toXml = (jsonObj: Element): string => {
const output = js2xml(jsonObj);
return output;
};
const createRelationshipFile = (): Element => ({
declaration: {
attributes: {
version: "1.0",
encoding: "UTF-8",
standalone: "yes",
},
},
elements: [
{
type: "element",
name: "Relationships",
attributes: {
xmlns: "http://schemas.openxmlformats.org/package/2006/relationships",
},
elements: [],
},
],
});

View File

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

View File

@ -1,224 +0,0 @@
import { expect } from "chai";
import { findRunElementIndexWithToken, splitRunElement } from "./paragraph-split-inject";
describe("paragraph-split-inject", () => {
describe("findRunElementIndexWithToken", () => {
it("should find the index of a run element with a token", () => {
const output = findRunElementIndexWithToken(
{
name: "w:p",
type: "element",
elements: [
{
name: "w:r",
type: "element",
elements: [
{
name: "w:t",
type: "element",
elements: [
{
type: "text",
text: "hello world",
},
],
},
],
},
],
},
"hello",
);
expect(output).to.deep.equal(0);
});
it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
{
name: "w:p",
type: "element",
},
"hello",
),
).to.throw();
});
it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
{
name: "w:p",
type: "element",
elements: [
{
name: "w:r",
type: "element",
},
],
},
"hello",
),
).to.throw();
});
it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
{
name: "w:p",
type: "element",
elements: [
{
name: "w:r",
type: "element",
elements: [
{
name: "w:t",
type: "element",
},
],
},
],
},
"hello",
),
).to.throw();
});
});
describe("splitRunElement", () => {
it("should split a run element", () => {
const output = splitRunElement(
{
name: "w:r",
type: "element",
elements: [
{
name: "w:t",
type: "element",
elements: [
{
type: "text",
text: "hello*world",
},
],
},
{
name: "w:x",
type: "element",
},
],
},
"*",
);
expect(output).to.deep.equal({
left: {
elements: [
{
attributes: {
"xml:space": "preserve",
},
elements: [
{
text: "hello",
type: "text",
},
],
name: "w:t",
type: "element",
},
],
name: "w:r",
type: "element",
},
right: {
elements: [
{
attributes: {
"xml:space": "preserve",
},
elements: [
{
text: "world",
type: "text",
},
],
name: "w:t",
type: "element",
},
{
name: "w:x",
type: "element",
},
],
name: "w:r",
type: "element",
},
});
});
it("should try to split even if elements is empty for text", () => {
const output = splitRunElement(
{
name: "w:r",
type: "element",
elements: [
{
name: "w:t",
type: "element",
},
],
},
"*",
);
expect(output).to.deep.equal({
left: {
elements: [
{
attributes: {
"xml:space": "preserve",
},
elements: [],
name: "w:t",
type: "element",
},
],
name: "w:r",
type: "element",
},
right: {
elements: [],
name: "w:r",
type: "element",
},
});
});
it("should return empty elements", () => {
const output = splitRunElement(
{
name: "w:r",
type: "element",
},
"*",
);
expect(output).to.deep.equal({
left: {
elements: [],
name: "w:r",
type: "element",
},
right: {
elements: [],
name: "w:r",
type: "element",
},
});
});
});
});

View File

@ -1,54 +0,0 @@
import { Element } from "xml-js";
import { createTextElementContents, patchSpaceAttribute } from "./util";
export const findRunElementIndexWithToken = (paragraphElement: Element, token: string): number => {
for (let i = 0; i < (paragraphElement.elements ?? []).length; i++) {
const element = paragraphElement.elements![i];
if (element.type === "element" && element.name === "w:r") {
const textElement = (element.elements ?? []).filter((e) => e.type === "element" && e.name === "w:t");
for (const text of textElement) {
if ((text.elements?.[0].text as string)?.includes(token)) {
return i;
}
}
}
}
throw new Error("Token not found");
};
export const splitRunElement = (runElement: Element, token: string): { readonly left: Element; readonly right: Element } => {
let splitIndex = 0;
const splitElements =
runElement.elements
?.map((e, i) => {
if (e.type === "element" && e.name === "w:t") {
const text = (e.elements?.[0].text as string) ?? "";
const splitText = text.split(token);
const newElements = splitText.map((t) => ({
...e,
...patchSpaceAttribute(e),
elements: createTextElementContents(t),
}));
splitIndex = i;
return newElements;
} else {
return e;
}
})
.flat() ?? [];
const leftRunElement: Element = {
...JSON.parse(JSON.stringify(runElement)),
elements: splitElements.slice(0, splitIndex + 1),
};
const rightRunElement: Element = {
...JSON.parse(JSON.stringify(runElement)),
elements: splitElements.slice(splitIndex + 1),
};
return { left: leftRunElement, right: rightRunElement };
};

View File

@ -1,165 +0,0 @@
import { expect } from "chai";
import { replaceTokenInParagraphElement } from "./paragraph-token-replacer";
describe("paragraph-token-replacer", () => {
describe("replaceTokenInParagraphElement", () => {
it("should replace token in paragraph", () => {
const output = replaceTokenInParagraphElement({
paragraphElement: {
name: "w:p",
elements: [
{
name: "w:r",
elements: [
{
name: "w:t",
elements: [
{
type: "text",
text: "hello",
},
],
},
],
},
],
},
renderedParagraph: {
index: 0,
path: [0],
runs: [
{
end: 4,
index: 0,
parts: [
{
end: 4,
index: 0,
start: 0,
text: "hello",
},
],
start: 0,
text: "hello",
},
],
text: "hello",
},
originalText: "hello",
replacementText: "world",
});
expect(output).to.deep.equal({
elements: [
{
elements: [
{
elements: [
{
text: "world",
type: "text",
},
],
name: "w:t",
},
],
name: "w:r",
},
],
name: "w:p",
});
});
// Try to fill rest of test coverage
// it("should replace token in paragraph", () => {
// const output = replaceTokenInParagraphElement({
// paragraphElement: {
// name: "w:p",
// elements: [
// {
// name: "w:r",
// elements: [
// {
// name: "w:t",
// elements: [
// {
// type: "text",
// text: "test ",
// },
// ],
// },
// {
// name: "w:t",
// elements: [
// {
// type: "text",
// text: " hello ",
// },
// ],
// },
// ],
// },
// ],
// },
// renderedParagraph: {
// index: 0,
// path: [0],
// runs: [
// {
// end: 4,
// index: 0,
// parts: [
// {
// end: 4,
// index: 0,
// start: 0,
// text: "test ",
// },
// ],
// start: 0,
// text: "test ",
// },
// {
// end: 10,
// index: 0,
// parts: [
// {
// end: 10,
// index: 0,
// start: 5,
// text: "hello ",
// },
// ],
// start: 5,
// text: "hello ",
// },
// ],
// text: "test hello ",
// },
// originalText: "hello",
// replacementText: "world",
// });
// expect(output).to.deep.equal({
// elements: [
// {
// elements: [
// {
// elements: [
// {
// text: "test world ",
// type: "text",
// },
// ],
// name: "w:t",
// },
// ],
// name: "w:r",
// },
// ],
// name: "w:p",
// });
// });
});
});

View File

@ -1,69 +0,0 @@
import { Element } from "xml-js";
import { createTextElementContents, patchSpaceAttribute } from "./util";
import { IRenderedParagraphNode } from "./run-renderer";
enum ReplaceMode {
START,
MIDDLE,
END,
}
export const replaceTokenInParagraphElement = ({
paragraphElement,
renderedParagraph,
originalText,
replacementText,
}: {
readonly paragraphElement: Element;
readonly renderedParagraph: IRenderedParagraphNode;
readonly originalText: string;
readonly replacementText: string;
}): Element => {
const startIndex = renderedParagraph.text.indexOf(originalText);
const endIndex = startIndex + originalText.length - 1;
let replaceMode = ReplaceMode.START;
for (const run of renderedParagraph.runs) {
for (const { text, index, start, end } of run.parts) {
switch (replaceMode) {
case ReplaceMode.START:
if (startIndex >= start) {
const partToReplace = run.text.substring(Math.max(startIndex, start), Math.min(endIndex, end) + 1);
// We use a token to split the text if the replacement is within the same run
// If not, we just add text to the middle of the run later
const firstPart = text.replace(partToReplace, replacementText);
patchTextElement(paragraphElement.elements![run.index].elements![index], firstPart);
replaceMode = ReplaceMode.MIDDLE;
continue;
}
break;
case ReplaceMode.MIDDLE:
if (endIndex <= end) {
const lastPart = text.substring(endIndex - start + 1);
patchTextElement(paragraphElement.elements![run.index].elements![index], lastPart);
const currentElement = paragraphElement.elements![run.index].elements![index];
// We need to add xml:space="preserve" to the last element to preserve the whitespace
// Otherwise, the text will be merged with the next element
// eslint-disable-next-line functional/immutable-data
paragraphElement.elements![run.index].elements![index] = patchSpaceAttribute(currentElement);
replaceMode = ReplaceMode.END;
} else {
patchTextElement(paragraphElement.elements![run.index].elements![index], "");
}
break;
default:
}
}
}
return paragraphElement;
};
const patchTextElement = (element: Element, text: string): Element => {
// eslint-disable-next-line functional/immutable-data
element.elements = createTextElementContents(text);
return element;
};

View File

@ -1,87 +0,0 @@
import { TargetModeType } from "@file/relationships/relationship/relationship";
import { expect } from "chai";
import { appendRelationship, getNextRelationshipIndex } from "./relationship-manager";
describe("relationship-manager", () => {
describe("getNextRelationshipIndex", () => {
it("should get next relationship index", () => {
const output = getNextRelationshipIndex({
elements: [
{
type: "element",
name: "Relationships",
elements: [
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
],
},
],
});
expect(output).to.deep.equal(2);
});
it("should work with an empty relationship Id", () => {
const output = getNextRelationshipIndex({
elements: [
{
type: "element",
name: "Relationships",
elements: [{ type: "element", name: "Relationship" }],
},
],
});
expect(output).to.deep.equal(1);
});
it("should work with no relationships", () => {
const output = getNextRelationshipIndex({
elements: [
{
type: "element",
name: "Relationships",
elements: [],
},
],
});
expect(output).to.deep.equal(1);
});
});
describe("appendRelationship", () => {
it("should append a relationship", () => {
const output = appendRelationship(
{
elements: [
{
type: "element",
name: "Relationships",
elements: [
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
],
},
],
},
1,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"test",
TargetModeType.EXTERNAL,
);
expect(output).to.deep.equal([
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
{ type: "element", attributes: { Id: "rId1" }, name: "Relationship" },
{
attributes: {
Id: "rId1",
Type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
TargetMode: TargetModeType.EXTERNAL,
Target: "test",
},
name: "Relationship",
type: "element",
},
]);
});
});
});

View File

@ -1,42 +0,0 @@
import { Element } from "xml-js";
import { RelationshipType, TargetModeType } from "@file/relationships/relationship/relationship";
import { getFirstLevelElements } from "./util";
const getIdFromRelationshipId = (relationshipId: string): number => {
const output = parseInt(relationshipId.substring(3), 10);
return isNaN(output) ? 0 : output;
};
export const getNextRelationshipIndex = (relationships: Element): number => {
const relationshipElements = getFirstLevelElements(relationships, "Relationships");
return (
relationshipElements
.map((e) => getIdFromRelationshipId(e.attributes?.Id?.toString() ?? ""))
.reduce((acc, curr) => Math.max(acc, curr), 0) + 1
);
};
export const appendRelationship = (
relationships: Element,
id: number | string,
type: RelationshipType,
target: string,
targetMode?: TargetModeType,
): readonly Element[] => {
const relationshipElements = getFirstLevelElements(relationships, "Relationships");
// eslint-disable-next-line functional/immutable-data
relationshipElements.push({
attributes: {
Id: `rId${id}`,
Type: type,
Target: target,
TargetMode: targetMode,
},
name: "Relationship",
type: "element",
});
return relationshipElements;
};

View File

@ -1,206 +0,0 @@
import { IViewWrapper } from "@file/document-wrapper";
import { File } from "@file/file";
import { Paragraph, TextRun } from "@file/paragraph";
import { IContext } from "@file/xml-components";
import { expect } from "chai";
import * as sinon from "sinon";
import { PatchType } from "./from-docx";
import { replacer } from "./replacer";
const MOCK_JSON = {
elements: [
{
type: "element",
name: "w:hdr",
elements: [
{
type: "element",
name: "w:p",
attributes: { "w14:paraId": "3BE1A671", "w14:textId": "74E856C4", "w:rsidR": "000D38A7", "w:rsidRDefault": "000D38A7" },
elements: [
{
type: "element",
name: "w:pPr",
elements: [{ type: "element", name: "w:pStyle", attributes: { "w:val": "Header" } }],
},
{
type: "element",
name: "w:r",
elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "This is a {{head" }] }],
},
{
type: "element",
name: "w:r",
attributes: { "w:rsidR": "004A3A99" },
elements: [{ type: "element", name: "w:t", elements: [{ type: "text", text: "er" }] }],
},
{
type: "element",
name: "w:r",
elements: [
{ type: "element", name: "w:t", elements: [{ type: "text", text: "_adjective}} dont you think?" }] },
],
},
],
},
],
},
],
};
describe("replacer", () => {
describe("replacer", () => {
it("should return the same object if nothing is added", () => {
const output = replacer(
{
elements: [],
},
{
type: PatchType.PARAGRAPH,
children: [],
},
"hello",
[],
sinon.mock() as unknown as IContext,
);
expect(output).to.deep.equal({
elements: [],
});
});
it("should replace paragraph type", () => {
const output = replacer(
MOCK_JSON,
{
type: PatchType.PARAGRAPH,
children: [new TextRun("Delightful Header")],
},
"{{header_adjective}}",
[
{
text: "This is a {{header_adjective}} dont you think?",
runs: [
{
text: "This is a {{head",
parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }],
index: 1,
start: 0,
end: 15,
},
{ text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 },
{
text: "_adjective}} dont you think?",
parts: [{ text: "_adjective}} dont you think?", index: 0, start: 18, end: 46 }],
index: 3,
start: 18,
end: 46,
},
],
index: 0,
path: [0, 0, 0],
},
],
{
file: {} as unknown as File,
viewWrapper: {
Relationships: {},
} as unknown as IViewWrapper,
stack: [],
},
);
expect(JSON.stringify(output)).to.contain("Delightful Header");
});
it("should replace document type", () => {
const output = replacer(
MOCK_JSON,
{
type: PatchType.DOCUMENT,
children: [new Paragraph("Lorem ipsum paragraph")],
},
"{{header_adjective}}",
[
{
text: "This is a {{header_adjective}} dont you think?",
runs: [
{
text: "This is a {{head",
parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }],
index: 1,
start: 0,
end: 15,
},
{ text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 },
{
text: "_adjective}} dont you think?",
parts: [{ text: "_adjective}} dont you think?", index: 0, start: 18, end: 46 }],
index: 3,
start: 18,
end: 46,
},
],
index: 0,
path: [0, 0, 0],
},
],
{
file: {} as unknown as File,
viewWrapper: {
Relationships: {},
} as unknown as IViewWrapper,
stack: [],
},
);
expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph");
});
it("should throw an error if the type is not supported", () => {
expect(() =>
replacer(
{},
{
type: PatchType.DOCUMENT,
children: [new Paragraph("Lorem ipsum paragraph")],
},
"{{header_adjective}}",
[
{
text: "This is a {{header_adjective}} dont you think?",
runs: [
{
text: "This is a {{head",
parts: [{ text: "This is a {{head", index: 0, start: 0, end: 15 }],
index: 1,
start: 0,
end: 15,
},
{ text: "er", parts: [{ text: "er", index: 0, start: 16, end: 17 }], index: 2, start: 16, end: 17 },
{
text: "_adjective}} dont you think?",
parts: [{ text: "_adjective}} dont you think?", index: 0, start: 18, end: 46 }],
index: 3,
start: 18,
end: 46,
},
],
index: 0,
path: [0, 0, 0],
},
],
{
file: {} as unknown as File,
viewWrapper: {
Relationships: {},
} as unknown as IViewWrapper,
stack: [],
},
),
).to.throw();
});
});
});

View File

@ -1,83 +0,0 @@
import { Element } from "xml-js";
import * as xml from "xml";
import { Formatter } from "@export/formatter";
import { IContext, XmlComponent } from "@file/xml-components";
import { IPatch, PatchType } from "./from-docx";
import { toJson } from "./util";
import { IRenderedParagraphNode } from "./run-renderer";
import { replaceTokenInParagraphElement } from "./paragraph-token-replacer";
import { findRunElementIndexWithToken, splitRunElement } from "./paragraph-split-inject";
const formatter = new Formatter();
const SPLIT_TOKEN = "ɵ";
export const replacer = (
json: Element,
patch: IPatch,
patchText: string,
renderedParagraphs: readonly IRenderedParagraphNode[],
context: IContext,
): Element => {
for (const renderedParagraph of renderedParagraphs) {
const textJson = patch.children
// eslint-disable-next-line no-loop-func
.map((c) => toJson(xml(formatter.format(c as XmlComponent, context))))
.map((c) => c.elements![0]);
switch (patch.type) {
case PatchType.DOCUMENT: {
const parentElement = goToParentElementFromPath(json, renderedParagraph.path);
const elementIndex = getLastElementIndexFromPath(renderedParagraph.path);
// eslint-disable-next-line functional/immutable-data, prefer-destructuring
parentElement.elements!.splice(elementIndex, 1, ...textJson);
break;
}
case PatchType.PARAGRAPH:
default: {
const paragraphElement = goToElementFromPath(json, renderedParagraph.path);
replaceTokenInParagraphElement({
paragraphElement,
renderedParagraph,
originalText: patchText,
replacementText: SPLIT_TOKEN,
});
const index = findRunElementIndexWithToken(paragraphElement, SPLIT_TOKEN);
const { left, right } = splitRunElement(paragraphElement.elements![index], SPLIT_TOKEN);
// eslint-disable-next-line functional/immutable-data
paragraphElement.elements!.splice(index, 1, left, ...textJson, right);
break;
}
}
}
return json;
};
const goToElementFromPath = (json: Element, path: readonly number[]): Element => {
let element = json;
// We start from 1 because the first element is the root element
// Which we do not want to double count
for (let i = 1; i < path.length; i++) {
const index = path[i];
const nextElements = element.elements;
if (!nextElements) {
throw new Error("Could not find element");
}
element = nextElements[index];
}
return element;
};
const goToParentElementFromPath = (json: Element, path: readonly number[]): Element =>
goToElementFromPath(json, path.slice(0, path.length - 1));
const getLastElementIndexFromPath = (path: readonly number[]): number => path[path.length - 1];

View File

@ -1,96 +0,0 @@
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: "",
});
});
});
});

View File

@ -1,109 +0,0 @@
import { Element } from "xml-js";
import { ElementWrapper } from "./traverser";
export interface IRenderedParagraphNode {
readonly text: string;
readonly runs: readonly IRenderedRunNode[];
readonly index: number;
readonly path: readonly number[];
}
interface StartAndEnd {
readonly start: number;
readonly end: number;
}
type IParts = {
readonly text: string;
readonly index: number;
} & StartAndEnd;
export type IRenderedRunNode = {
readonly text: string;
readonly parts: readonly IParts[];
readonly index: number;
} & StartAndEnd;
export const renderParagraphNode = (node: ElementWrapper): IRenderedParagraphNode => {
if (node.element.name !== "w:p") {
throw new Error(`Invalid node type: ${node.element.name}`);
}
if (!node.element.elements) {
return {
text: "",
runs: [],
index: -1,
path: [],
};
}
let currentRunStringLength = 0;
const runs = node.element.elements
.map((element, i) => ({ element, i }))
.filter(({ element }) => element.name === "w:r")
.map(({ element, i }) => {
const renderedRunNode = renderRunNode(element, i, currentRunStringLength);
currentRunStringLength += renderedRunNode.text.length;
return renderedRunNode;
})
.filter((e) => !!e)
.map((e) => e as IRenderedRunNode);
const text = runs.reduce((acc, curr) => acc + curr.text, "");
return {
text,
runs,
index: node.index,
path: buildNodePath(node),
};
};
const renderRunNode = (node: Element, index: number, currentRunStringIndex: number): IRenderedRunNode => {
if (!node.elements) {
return {
text: "",
parts: [],
index: -1,
start: currentRunStringIndex,
end: currentRunStringIndex,
};
}
let currentTextStringIndex = currentRunStringIndex;
const parts = node.elements
.map((element, i: number) =>
element.name === "w:t" && element.elements && element.elements.length > 0
? {
text: element.elements[0].text?.toString() ?? "",
index: i,
start: currentTextStringIndex,
end: (() => {
// Side effect
currentTextStringIndex += (element.elements[0].text?.toString() ?? "").length - 1;
return currentTextStringIndex;
})(),
}
: undefined,
)
.filter((e) => !!e)
.map((e) => e as IParts);
const text = parts.reduce((acc, curr) => acc + curr.text, "");
return {
text,
parts,
index,
start: currentRunStringIndex,
end: currentTextStringIndex,
};
};
const buildNodePath = (node: ElementWrapper): readonly number[] =>
node.parent ? [...buildNodePath(node.parent), node.index] : [node.index];

View File

@ -1,599 +0,0 @@
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}}",
},
]);
});
});
});

View File

@ -1,45 +0,0 @@
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));
};

View File

@ -1,50 +0,0 @@
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" }]);
});
});
});

View File

@ -1,30 +0,0 @@
import { xml2js, Element } from "xml-js";
import * as xml from "xml";
import { Formatter } from "@export/formatter";
import { Text } from "@file/paragraph/run/run-components/text";
const formatter = new Formatter();
export const toJson = (xmlData: string): Element => {
const xmlObj = xml2js(xmlData, { compact: false }) as Element;
return xmlObj;
};
// eslint-disable-next-line functional/prefer-readonly-type
export const createTextElementContents = (text: string): Element[] => {
const textJson = toJson(xml(formatter.format(new Text({ text }))));
return textJson.elements![0].elements ?? [];
};
export const patchSpaceAttribute = (element: Element): Element => ({
...element,
attributes: {
"xml:space": "preserve",
},
});
// eslint-disable-next-line functional/prefer-readonly-type
export const getFirstLevelElements = (relationships: Element, id: string): Element[] =>
relationships.elements?.filter((e) => e.name === id)[0].elements ?? [];

View File

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