Skip to content
Merged
6 changes: 4 additions & 2 deletions docs/usage/patcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fs.writeFileSync("My Patched Document.docx", doc);

Use `patchDetector({ data })` to find the placeholder keys in a template before calling `patchDocument`. It returns distinct keys without the `{{` and `}}` delimiters. This is useful when templates differ: an expensive patch, such as downloading images, only needs to be built when its placeholder is present.

This discovery workflow supports only `{{...}}` placeholders. `patchDetector` does not accept the custom `placeholderDelimiters` supported by `patchDocument`.
If the template's placeholders have other delimiters, such as `<<name>>`, give `patchDetector` the same `placeholderDelimiters` as `patchDocument`, such as `{ start: "<<", end: ">>" }`.

If the template's placeholders can have spaces inside their braces, such as `{{ name }}`, give both `patchDetector` and `patchDocument` `trimPlaceholders: true`. `patchDetector` then gives the key as `name`, and `patchDocument` patches the placeholder with the patch `name`. See [Spaces inside placeholders](usage/templates.md#spaces-inside-placeholders).

Expand Down Expand Up @@ -57,7 +57,7 @@ const doc = await patchDocument({ outputType: "nodebuffer", data, patches: Objec
fs.writeFileSync("My Patched Document.docx", doc);
```

This example patches only keys with a registered builder; other placeholders remain in the document. If every placeholder must be resolved, validate the detected keys against your builders and report unsupported keys before patching. Detection discovers keys; it does not generate replacement content or call `patchDocument` for you.
This example patches only keys with a registered builder; other placeholders remain in the document. To find patches whose placeholder isn't in the template, which `patchDocument` leaves out without an error, see [A placeholder isn't replaced](usage/templates.md#a-placeholder-isnt-replaced). If every placeholder must be resolved, validate the detected keys against your builders and report unsupported keys before patching. Detection discovers keys; it does not generate replacement content or call `patchDocument` for you.

## Patches

Expand All @@ -81,6 +81,8 @@ A paragraph in a patch can be a heading. Its style is added to a template that d

The patcher also takes in a `keepOriginalStyles` boolean, which will preserve the styles of the patched text when set to true.

It also takes in `placeholderDelimiters`, for a template whose placeholders aren't in double curly braces, such as `{ start: "<<", end: ">>" }` for `<<name>>`.

It also takes in a `trimPlaceholders` boolean, which patches a placeholder with spaces inside its delimiters, such as `{{ name }}`, as if it had none. See [Spaces inside placeholders](usage/templates.md#spaces-inside-placeholders).

It also takes in `footnotes` and `endnotes`, for the notes that patches refer to with a `FootnoteReferenceRun` or an `EndnoteReferenceRun`. See [Footnotes and endnotes](usage/templates.md#footnotes-and-endnotes).
Expand Down
99 changes: 99 additions & 0 deletions docs/usage/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,65 @@ fs.writeFileSync("output.docx", doc);
- Only the placeholders of the patches given lose their spaces. Other text that looks like a placeholder, such as `{{ not_patched }}`, is left as it is.
- [`patchDetector`](usage/patcher.md#discover-placeholders-before-building-patches) takes `trimPlaceholders` too, and then gives the key of `{{ customer_name }}` as `customer_name`.

### Placeholders in links, content controls and tracked changes

A placeholder is patched wherever it is in a paragraph's text: in a link's text, in a content control, in text typed with Track Changes on, or in a field's result. The patch's content goes where the placeholder was, so the text of a link stays linked, and a tracked insertion stays tracked:

```ts live
import * as fs from "fs";
import { Document, ExternalHyperlink, InsertedTextRun, Packer, Paragraph, patchDocument, PatchType, TextRun } from "docx";

// The template, made here as it would be in Word: a link whose text is a placeholder, and a placeholder typed with Track Changes on
const template = new Document({
sections: [
{
children: [
new Paragraph({
children: [
new TextRun("Our website: "),
new ExternalHyperlink({
link: "https://example.com",
children: [new TextRun({ text: "{{site}}", style: "Hyperlink" })],
}),
],
}),
new Paragraph({
children: [
new InsertedTextRun({
text: "Dear {{customer_name}},",
id: 1,
author: "Template author",
date: "2024-05-01T00:00:00Z",
}),
],
}),
],
},
],
});

const doc = await patchDocument({
outputType: "nodebuffer",
data: await Packer.toBuffer(template),
patches: {
site: { type: PatchType.PARAGRAPH, children: [new TextRun("example.com")] },
customer_name: { type: PatchType.PARAGRAPH, children: [new TextRun("John Smith")] },
},
});

fs.writeFileSync("output.docx", doc);
```

Where the content can't go in what holds the placeholder, it goes as close as it can, so Word opens the document:

- **A link in a tracked insertion**: a tracked insertion can't hold a link, so the link goes between two parts of the insertion and isn't tracked, as when a link is added with Track Changes off.
- **A link in a link**: the patch's link takes the place of the template's link where the placeholder was, and the text around it keeps the template's link.
- **A plain text content control**: given anything but text, such as a link, a picture, a field or a symbol, it becomes a rich text content control, as Word won't open a plain text one holding it.
- **A content control showing its placeholder text**: if `{{name}}` is the control's placeholder text, the control shows the patch's content as its own, with the control's formatting rather than the grey of placeholder text.
- **A content control bound to custom XML or a document property**: the binding is removed, as Word fills a bound control in from its data when it opens the document, which would put the placeholder back.

Deleted text, which Word shows struck through when Track Changes is on, isn't the document's text, so a placeholder in it isn't patched.

## Basic Usage

```ts live
Expand Down Expand Up @@ -776,6 +835,46 @@ const doc = await patchDocument({
fs.writeFileSync("invoice-output.docx", doc);
```

## A placeholder isn't replaced

`patchDocument` doesn't throw for a patch whose placeholder isn't in the template, as one set of patches can be given to templates that don't all have every placeholder. The placeholder is left as it is. To find such patches, compare their keys with the placeholders [`patchDetector`](usage/patcher.md#discover-placeholders-before-building-patches) finds, giving it the same `placeholderDelimiters` and `trimPlaceholders` as `patchDocument`:

```ts live
import * as fs from "fs";
import { Document, Packer, Paragraph, patchDetector, patchDocument, PatchType, TextRun } from "docx";

// The template, with one placeholder typed in single braces
const template = await Packer.toBuffer(
new Document({
sections: [{ children: [new Paragraph("Dear {{customer_name}},"), new Paragraph("Your order: {order_number}")] }],
}),
);

const patches = {
customer_name: { type: PatchType.PARAGRAPH, children: [new TextRun("John Smith")] },
order_number: { type: PatchType.PARAGRAPH, children: [new TextRun("12345")] },
};

// The patches whose placeholder isn't in the template. Here it's ["order_number"], as the template has {order_number}
const found = await patchDetector({ data: template });
const notFound = Object.keys(patches).filter((key) => !found.includes(key));
Comment thread
dolanmiu marked this conversation as resolved.
Resolved
console.log("No placeholder for:", notFound);

const doc = await patchDocument({ outputType: "nodebuffer", data: template, patches });

fs.writeFileSync("output.docx", doc);
```

A patch whose placeholder isn't found is usually one of these:

- **The key has the delimiters**: the key of `{{customer_name}}` is `customer_name`, not `"{{customer_name}}"`, which is looked for as `{{{{customer_name}}}}`.
- **The key isn't spelled as the placeholder is**: keys match exactly, including upper and lower case.
- **The template has other delimiters**, such as `{name}`, `<<name>>` or `[name]`: give `patchDocument` and `patchDetector` the template's delimiters, such as `placeholderDelimiters: { start: "<<", end: ">>" }`.
- **Spaces inside the delimiters**, such as `{{ name }}`: see [Spaces inside placeholders](#spaces-inside-placeholders).
- **The placeholder is only in a picture's or a chart's alt text**: only an [`ImagePatch`](#replacing-a-picture) or a `ChartDataPatch` patches it, and a text patch leaves it as it is. `patchDetector` finds placeholders in alt text too, so the check above doesn't list this patch.

A placeholder in deleted text, which Word shows struck through when Track Changes is on, isn't patched or found, nor is one in a chart's own text, such as its title.

## Tips

- **Placeholder naming**: Use descriptive names like `customer_name` instead of `x1`
Expand Down
6 changes: 2 additions & 4 deletions src/patcher/from-docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { patchStyles } from "./styles";
import { patchTableRows } from "./table-rows";
import { readThemeColors } from "./theme-colors";
import { trimPlaceholders as trimPlaceholdersIn } from "./trim-placeholders";
import { decodeUtf8, toJson } from "./util";
import { checkPlaceholderDelimiters, decodeUtf8, toJson } from "./util";
import { removeShapeTypesIn } from "./vml-shape-types";

/**
Expand Down Expand Up @@ -382,9 +382,7 @@ export const patchDocument = async <T extends PatchDocumentOutputType = PatchDoc

const binaryContentMap = new Map<string, Uint8Array>();

if (!placeholderDelimiters?.start.trim() || !placeholderDelimiters?.end.trim()) {
throw new Error("Both start and end delimiters must be non-empty strings.");
}
checkPlaceholderDelimiters(placeholderDelimiters);
const { start, end } = placeholderDelimiters;

for (const [key, patch] of Object.entries(patches)) {
Expand Down
20 changes: 10 additions & 10 deletions src/patcher/paragraph-split-inject.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";

import { findRunElementIndexWithToken, splitRunElement } from "./paragraph-split-inject";
import { findRunElementPathWithToken, 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(
describe("findRunElementPathWithToken", () => {
it("should find the path to a run element with a token", () => {
const output = findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand All @@ -30,12 +30,12 @@ describe("paragraph-split-inject", () => {
},
"hello",
);
expect(output).to.deep.equal(0);
expect(output).to.deep.equal([0]);
});

it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand All @@ -47,7 +47,7 @@ describe("paragraph-split-inject", () => {

it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand All @@ -65,7 +65,7 @@ describe("paragraph-split-inject", () => {

it("should throw an exception when ran with empty elements", () => {
expect(() =>
findRunElementIndexWithToken(
findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand All @@ -89,7 +89,7 @@ describe("paragraph-split-inject", () => {

it("should continue if text run doesn't have text", () => {
expect(() =>
findRunElementIndexWithToken(
findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand All @@ -113,7 +113,7 @@ describe("paragraph-split-inject", () => {

it("should continue if text run doesn't have text", () => {
expect(() =>
findRunElementIndexWithToken(
findRunElementPathWithToken(
{
name: "w:p",
type: "element",
Expand Down
39 changes: 16 additions & 23 deletions src/patcher/paragraph-split-inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type { XmlElement as Element } from "@util/xml";

import { renderParagraphNode } from "./run-renderer";
import { createTextElementContents, patchSpaceAttribute } from "./util";

export class TokenNotFoundError extends Error {
Expand All @@ -16,42 +17,34 @@ export class TokenNotFoundError extends Error {
}

/**
* Finds the index of the run element containing a specific token.
* Finds the run element containing a specific token.
*
* Searches through all run (w:r) elements in a paragraph to find which one
* contains the specified token text. This is used to locate where new content
* should be injected during replacement operations.
* Searches through all run (w:r) elements in a paragraph, including those in a link,
* content control or tracked insertion, to find which one contains the specified
* token text. This is used to locate where new content should be injected during
* replacement operations.
*
* @param paragraphElement - The paragraph element to search
* @param token - The token text to find
* @returns The index of the run element containing the token
* @returns The position indices from the paragraph to the run element containing the token
* @throws Error if the token is not found in any run
*
* @example
* ```typescript
* const index = findRunElementIndexWithToken(paragraph, "ɵ");
* // Returns the index of the run containing the split token
* const path = findRunElementPathWithToken(paragraph, "ɵ");
* // Returns [0] for the paragraph's first run, or [2, 1] for the second run in a link that is its third element
* ```
*/
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");
export const findRunElementPathWithToken = (paragraphElement: Element, token: string): readonly number[] => {
const run = renderParagraphNode({ element: paragraphElement, index: 0, parent: undefined }).runs.find(({ parts }) =>
parts.some(({ text }) => text.includes(token)),
);

for (const text of textElement) {
if (!text.elements?.[0]) {
continue;
}

if ((text.elements[0].text as string)?.includes(token)) {
return i;
}
}
}
if (!run) {
throw new TokenNotFoundError(token);
}

throw new TokenNotFoundError(token);
return run.path;
};

// cspell:words Helloɵworld
Expand Down
22 changes: 11 additions & 11 deletions src/patcher/paragraph-token-replacer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ describe("paragraph-token-replacer", () => {
runs: [
{
end: 4,
index: 0,
path: [0],
parts: [
{
end: 4,
Expand Down Expand Up @@ -123,9 +123,9 @@ describe("paragraph-token-replacer", () => {
renderedParagraph: {
text: "Hello {{name}} World",
runs: [
{ text: "Hello ", parts: [{ text: "Hello ", index: 0, start: 0, end: 5 }], index: 1, start: 0, end: 5 },
{ text: "{{name}} ", parts: [{ text: "{{name}} ", index: 0, start: 6, end: 14 }], index: 2, start: 6, end: 14 },
{ text: "World", parts: [{ text: "World", index: 0, start: 15, end: 19 }], index: 3, start: 15, end: 19 },
{ text: "Hello ", parts: [{ text: "Hello ", index: 0, start: 0, end: 5 }], path: [1], start: 0, end: 5 },
{ text: "{{name}} ", parts: [{ text: "{{name}} ", index: 0, start: 6, end: 14 }], path: [2], start: 6, end: 14 },
{ text: "World", parts: [{ text: "World", index: 0, start: 15, end: 19 }], path: [3], start: 15, end: 19 },
],
index: 0,
pathToParagraph: [0, 1, 0, 0],
Expand Down Expand Up @@ -252,7 +252,7 @@ describe("paragraph-token-replacer", () => {
runs: [
{
end: 0,
index: 0,
path: [0],
parts: [
{
end: 0,
Expand All @@ -266,7 +266,7 @@ describe("paragraph-token-replacer", () => {
},
{
end: 5,
index: 1,
path: [1],
parts: [
{
end: 5,
Expand Down Expand Up @@ -332,9 +332,9 @@ describe("paragraph-token-replacer", () => {
renderedParagraph: {
text: "{{name}} world",
runs: [
{ text: "{{na", parts: [{ text: "{{na", index: 0, start: 0, end: 3 }], index: 0, start: 0, end: 3 },
{ text: "me}}", parts: [{ text: "me}}", index: 0, start: 4, end: 7 }], index: 1, start: 4, end: 7 },
{ text: " world", parts: [{ text: " world", index: 0, start: 8, end: 13 }], index: 2, start: 8, end: 13 },
{ text: "{{na", parts: [{ text: "{{na", index: 0, start: 0, end: 3 }], path: [0], start: 0, end: 3 },
{ text: "me}}", parts: [{ text: "me}}", index: 0, start: 4, end: 7 }], path: [1], start: 4, end: 7 },
{ text: " world", parts: [{ text: " world", index: 0, start: 8, end: 13 }], path: [2], start: 8, end: 13 },
],
index: 0,
pathToParagraph: [0],
Expand Down Expand Up @@ -373,7 +373,7 @@ describe("paragraph-token-replacer", () => {
{ text: "Name:", index: 0, start: 0, end: 4 },
{ text: "{{name}}", index: 2, start: 5, end: 12 },
],
index: 0,
path: [0],
start: 0,
end: 12,
},
Expand All @@ -400,7 +400,7 @@ describe("paragraph-token-replacer", () => {
{
text: "{{name}} {{name}}",
parts: [{ text: "{{name}} {{name}}", index: 0, start: 0, end: 16 }],
index: 0,
path: [0],
start: 0,
end: 16,
},
Expand Down
Loading
Loading