feat: add support for custom patch delimiters in PatchDocumentOptions (#3036)

* feat: add support for custom patch delimiters in PatchDocumentOptions

* chore: add validation for placeholder delimiters
This commit is contained in:
Venkateshwar Reddy
2025-04-16 12:48:55 +05:30
committed by GitHub
parent 4d1a351649
commit 5af1045a59
4 changed files with 275 additions and 1 deletions

View File

@ -0,0 +1,168 @@
// Patch a document with patches
import * as fs from "fs";
import {
ExternalHyperlink,
HeadingLevel,
ImageRun,
Paragraph,
patchDocument,
PatchType,
Table,
TableCell,
TableRow,
TextDirection,
TextRun,
VerticalAlign,
} from "docx";
patchDocument({
outputType: "nodebuffer",
data: fs.readFileSync("demo/assets/simple-template-4.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({
type: "png",
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({
type: "jpg",
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,
}),
],
}),
],
}),
],
},
},
placeholderDelimiters: { start: "<<", end: ">>" },
}).then((doc) => {
fs.writeFileSync("My Document.docx", doc);
});

Binary file not shown.

View File

@ -288,6 +288,101 @@ describe("from-docx", () => {
});
expect(output).to.not.be.undefined;
});
it("should patch the document", async () => {
const output = await patchDocument({
outputType: "uint8array",
data: Buffer.from(""),
placeholderDelimiters: { start: "{{", end: "}}" },
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({
type: "png",
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
}),
],
},
// eslint-disable-next-line @typescript-eslint/naming-convention
image_test: {
type: PatchType.PARAGRAPH,
children: [
new ImageRun({
type: "png",
data: Buffer.from(""),
transformation: { width: 100, height: 100 },
}),
],
},
},
});
expect(output).to.not.be.undefined;
});
it("should patch the document", async () => {
const output = await patchDocument({
outputType: "uint8array",
data: Buffer.from(""),
patches: {},
});
expect(output).to.not.be.undefined;
});
it("throws error with empty delimiters", async () => {
await expect(() =>
patchDocument({
outputType: "uint8array",
data: Buffer.from(""),
patches: {},
placeholderDelimiters: { start: "", end: "" },
}),
).rejects.toThrow();
});
it("throws error with whitespace-only delimiters", async () => {
await expect(() =>
patchDocument({
outputType: "uint8array",
data: Buffer.from(""),
patches: {},
placeholderDelimiters: { start: " ", end: " " },
}),
).rejects.toThrowError();
});
});
describe("document.xml and [Content_Types].xml with relationships", () => {

View File

@ -55,6 +55,10 @@ export type PatchDocumentOptions<T extends PatchDocumentOutputType = PatchDocume
readonly data: InputDataType;
readonly patches: Readonly<Record<string, IPatch>>;
readonly keepOriginalStyles?: boolean;
readonly placeholderDelimiters?: Readonly<{
readonly start: string;
readonly end: string;
}>;
};
const imageReplacer = new ImageReplacer();
@ -64,6 +68,7 @@ export const patchDocument = async <T extends PatchDocumentOutputType = PatchDoc
data,
patches,
keepOriginalStyles,
placeholderDelimiters = { start: "{{", end: "}}" } as const,
}: PatchDocumentOptions<T>): Promise<OutputByType[T]> => {
const zipContent = await JSZip.loadAsync(data);
const contexts = new Map<string, IContext>();
@ -132,8 +137,14 @@ export const patchDocument = async <T extends PatchDocumentOutputType = PatchDoc
};
contexts.set(key, context);
if (!placeholderDelimiters?.start.trim() || !placeholderDelimiters?.end.trim()) {
throw new Error("Both start and end delimiters must be non-empty strings.");
}
const { start, end } = placeholderDelimiters;
for (const [patchKey, patchValue] of Object.entries(patches)) {
const patchText = `{{${patchKey}}}`;
const patchText = `${start}${patchKey}${end}`;
// TODO: mutates json. Make it immutable
// We need to loop through to catch every occurrence of the patch text
// It is possible that the patch text is in the same run