@dealops/doxy
Fill .docx templates with data. Replace {{tags}}, repeat table rows and paragraphs from arrays, insert images, and get a .docx back. No native dependencies: it runs in Node, browsers, and Cloudflare Workers.
npm install @dealops/doxy
import { renderDocx } from '@dealops/doxy';
import { readFileSync, writeFileSync } from 'node:fs';
const output = renderDocx(readFileSync('order-form.docx'), {
customer: { name: 'Acme Corp' },
date: new Date(),
lines: [
{ sku: 'SEAT', qty: 10, price: '$100.00' },
{ sku: 'SUPPORT', qty: 1, price: '$500.00' },
],
notes: ['Net 30', 'Auto-renews annually'],
logo: { type: 'image', data: readFileSync('logo.png'), width: 120 },
});
writeFileSync('acme-order-form.docx', output);
Template syntax
Write tags directly in Word or Google Docs. Tags keep the formatting of the text they replace, and a tag that Word split across several runs (because of spell-check marks or formatting changes) still works.
| Tag | Meaning |
|---|---|
{{name}} / {{customer.name}} / {{Payment Terms}} | Insert a value. Dotted paths walk nested objects; names may contain spaces. |
{{#items}} … {{/items}} | Repeat the enclosed content once per array element. Inside, {{.}} is the element and its properties are in scope. |
{{#flag}} … {{/flag}} | With a non-array value: render once if truthy (objects become the scope), otherwise remove. |
{{^items}} … {{/items}} | Inverted section: render only when the value is missing, falsy, or an empty array. |
{{! comment }} | Removed from the output. |
{{@index}} {{@number}} {{@first}} {{@last}} {{@length}} | Loop metadata (0-based index, 1-based number, booleans, array length). |
Values: strings are inserted as-is (\n becomes a line break, \t a tab), numbers and booleans are stringified, Date becomes YYYY-MM-DD, null/undefined become empty text. Pass formatValue to change any of this.
What gets repeated
The repeat unit is chosen from where the open and close tags sit:
| Open and close tags are… | Repeated unit |
|---|---|
| in the same paragraph | just the content between them (inline) |
| in different cells of the same table row | the whole row |
| in different rows of a table | the rows from the open tag's row to the close tag's row |
| in different paragraphs | the paragraphs from the first to the last |
A paragraph or table row that contains nothing but a section tag is removed, so this table template:
| Product | Qty |
|---|---|
{{#lines}}{{sku}} | {{qty}}{{/lines}} |
renders one row per line item, and this one does the same with the tags on their own rows:
| Product | Qty |
|---|---|
{{#lines}} | |
{{sku}} | {{qty}} |
{{/lines}} |
To force the unit, add a suffix to the open tag: {{#items:p}} repeats the whole paragraph (for bullet lists), {{#items:tr}} the table row, {{#items:tc}} the cell, {{#items:tbl}} the whole table.
Table rows from data
Many real templates (especially Google Docs exports) already contain the table with a header row and a single placeholder row, like:
| Fee Type | Unit Price | Qty | Subtotal |
|---|---|---|---|
{{fees}} |
Pass a rows value and that row is filled once per data row, cell by cell, keeping each cell's formatting. The placeholder row itself is removed:
fees: {
type: 'rows',
rows: [
['Platform Subscription', '$45,000.00', 1, '$45,000.00'],
['Credits', '$1.00', 40000, '$40,000.00'],
[{ text: 'Total', bold: true }, null, null, { text: '$85,000.00', bold: true }],
],
emptyTable: 'remove', // optional: with zero rows, delete the whole table instead of leaving the header
}
Cells accept strings, numbers, { text, bold?, italic?, underline?, strike? }, { runs: [...], align?: 'left' | 'center' | 'right', shading? }, or { paragraphs: [...], shading? } for several paragraphs in one cell (product descriptions with "- " lines, for example). A row can also be { cells: [...], shading: 'efefef' } to fill every cell of that row (hex RGB, # optional); a cell's own shading wins, and either replaces the template cell's background. Use strike: true on runs for removed line items. emptyTable can also be 'remove-with-heading', which additionally removes the heading paragraph just above the table and one blank paragraph below it, so an unused "Credits" section disappears completely.
A rows value may carry columnHeaders: string[] for templates that give no header of their own: a table that is only the placeholder row gets them as a bold first row (only when there are data rows), and a tag in running text prints them as a bold tab-separated line over tab-separated rows. A table with a header row of its own ignores them; with no rows, emptyTable decides.
Removing what a tag sits in
A remove value deletes the structure around the tag. Use it for optional summary rows and optional sections without changing the template:
startup_discount: { type: 'remove', unit: 'row' }, // drop this row of the pricing summary
credits_table: { type: 'remove', unit: 'table-with-heading' }, // drop the table, its heading, and the blank line after it
footnote: { type: 'remove', unit: 'paragraph' },
Units: paragraph, row (the table goes too when it was the last row), table, table-with-heading.
Leaving tags for a later step (e-signature anchors)
Render with onMissing: 'keep' and simply omit those keys from the data: {{sig_customer_0}} stays in the document for DocuSign anchor matching while everything else is filled.
Multi-paragraph blocks
A paragraphs value replaces the tag with real paragraphs (not line breaks), each copying the paragraph and run formatting at the tag position. Use it for clauses and terms:
terms: {
type: 'paragraphs',
paragraphs: [
{ runs: [{ text: 'A. Taxes.', bold: true }, ' Charges exclude taxes.'] },
{ runs: [{ text: 'B. Renewal.', bold: true }, ' Renews for successive 12-month terms.'] },
'Plain paragraph',
],
}
If the tag shares its paragraph with other text or other tags, the paragraph is split around it, so {{clause_a}}{{clause_b}}Fixed text in one paragraph becomes clause A's paragraphs, then clause B's, then the fixed text. An empty paragraphs: [] is a declined clause: the tag is cleared, the paragraph it leaves empty is dropped where the document allows it, and a two-row clause box (label row over one body cell) whose body it leaves empty is removed, so the last of several declined clauses takes the box with it. A box with a printed clause, an image, a nested table or a tag still in its label row stays.
Images
Pass an object instead of a string:
{ type: 'image', data: Uint8Array | ArrayBuffer, width?: number, height?: number, format?: 'png' | 'jpeg' | 'gif' | 'bmp', alt?: string }
Sizes are in pixels at 96 dpi. Give one dimension and the other follows the image's aspect ratio; give none and the intrinsic size is used.
API
renderDocx(template: Uint8Array | ArrayBuffer, data: Record<string, unknown>, options?: RenderOptions): Uint8Array
getTemplateTags(template, options?): TagInfo[] // [{ name, kind: 'value' | 'section' | 'inverted', parents, part }]
DocxTemplate.from(template).render(data, options?) // same thing, object style
RenderOptions:
| Option | Default | Description |
|---|---|---|
delimiters | { start: '{{', end: '}}' } | Custom tag delimiters. |
onMissing | 'empty' | 'empty' inserts nullText, 'keep' leaves the tag in place, 'throw' throws DoxyTemplateError. |
nullText | '' | Text for null/undefined. |
removeEmptyParagraphs | false | Drop a paragraph whose only content was a tag that rendered empty (no blank lines for absent clauses or titles). |
formatValue | (value, tagName) => string | BlockValue | undefined; return undefined to use the default. | |
parts | document, headers, footers, footnotes, endnotes | Predicate over zip entry names to choose which parts are processed. |
Errors in the template (unclosed sections, mismatched close tags) throw DoxyTemplateError with the part name.
Google Docs with tabs
Drive's .docx export of a Doc that has tabs opens with the tab's name as a Title paragraph in a section of its own, which prints as an extra first page. stripTabTitleSection(docx) removes that leading section when the document starts that way and returns the bytes unchanged otherwise. The Worker applies it to every Drive import.
Preparing a Google Docs export (normalizeTemplate)
normalizeTemplate(docx) returns { docx, changed, strippedTitleSection, droppedRuns, cellsWithMargins }: the tab-title section above is stripped, runs that render nothing are dropped (Drive writes a w:rtl-only run into every blank paragraph, and text filled into such a cell would copy that run instead of the paragraph mark's font and size), and every table cell without its own w:tcMar gets the margins its table resolves to from the table style chain, the default table style and the table's w:tblCellMar (Google's .docx import ignores style-level padding but keeps per-cell margins). Running it again on its output changes nothing. The pieces are exported too: dropEmptyRuns(root), inlineCellMargins(root, readTableStyles(stylesXml)), stripTabTitleSectionXml(doc).
Rendering twice (renderDocxPasses)
renderDocxPasses(template, data, options, maxPasses) renders, then renders the result again with the same data while it still contains a tag whose top-level key is in data (hasKnownTag), up to maxPasses times. A value like "Payment: {{payment_terms}}" resolves on the second pass; tags the data does not know (e-signature anchors kept with onMissing: 'keep') never trigger one, and a value that refers to itself stops at the bound.
Finishing a rendered document (postprocessDocx)
postprocessDocx(docx, spec) applies, in order:
hideTextPatterns/removeTextPatterns: regular expressions matched against each paragraph's joined text, in the body, table cells, headers and footers. Matches never span an image, a paragraph or a segment. Hidden text becomes white (w:color FFFFFF, not Word's hidden-text property, so it stays in the PDF text layer); removed text is deleted. Removal wins where the two overlap; zero-length matches are ignored.links: the first exact occurrence of eachtextin the body (table cells included, across runs) becomes a hyperlink tourl(http or https), keeping the text and its other formatting.
The result reports hidden, removed and linked.
Notes
- Headers, footers, footnotes and endnotes are processed along with the body.
- Tags inside text boxes work; tags spanning a text box boundary do not.
- Repeated pictures get fresh
docPrids, so Word does not complain about duplicates. - Google Docs: type the tags in the doc, then
File → Download → Microsoft Word (.docx)or export via the Drive API. The companion Worker in this repo does that automatically.
MIT © Dealops
Google Docs API compatible adapter (@dealops/doxy/gdocs)
If you already generate documents with the Google Docs API (documents.get to find indexes, documents.batchUpdate with replaceAllText, insertTableRow, insertText, …), you can point that code at a .docx instead. The adapter exposes the same resource shapes, the same index rules (section break at 0–1, paragraphs end with \n, tables cost one index for the table, one per row, one per cell and one at the end) and the same request objects, and applies them to the Word XML.
import { DocsCompatClient } from '@dealops/doxy/gdocs';
const docs = new DocsCompatClient(); // stands in for google.docs({ version: 'v1', auth })
const documentId = docs.open(templateBytes); // instead of a Drive file id
const { data: doc } = await docs.documents.get({ documentId });
// …walk doc.body.content exactly as before to find '{{recurring_table_0}}' and its table…
await docs.documents.batchUpdate({
documentId,
requestBody: {
requests: [
{ replaceAllText: { containsText: { text: '{{customer_name}}', matchCase: true }, replaceText: 'Acme' } },
{ insertTableRow: { tableCellLocation: { tableStartLocation: { index: tableStart }, rowIndex: 1 }, insertBelow: true } },
{ insertText: { location: { index: cellIndex + 1 }, text: 'Platform' } },
{ updateTextStyle: { range: { startIndex, endIndex }, textStyle: { bold: true }, fields: 'bold' } },
],
},
});
const output = docs.export(documentId); // .docx bytes
Lower-level forms: new DocxDocument(bytes).get() / .batchUpdate(requests) / .toBytes(), and applyDocsRequests(bytes, requests) for one shot. docs.documents.create() starts from a blank Letter page.
Supported requests: replaceAllText, insertText, deleteContentRange, updateTextStyle (bold, italic, underline, strikethrough, smallCaps, fontSize, foreground/background color, font family, baseline offset, link), updateParagraphStyle (named style, alignment, spacing, indents, keep-with-next, borders), insertTable, insertTableRow, insertTableColumn, deleteTableRow, deleteTableColumn, updateTableCellStyle, updateTableColumnProperties, updateTableRowStyle, pinTableHeaderRows, createParagraphBullets / deleteParagraphBullets, insertInlineImage / replaceImage (http(s) and data: URIs; pass fetchImage to control fetching), insertPageBreak, updateDocumentStyle. Headers and footers are addressable through segmentId (header1, footer1, …; see documentStyle.defaultHeaderId).
Anything else (mergeTableCells, named ranges, footnotes, section breaks, comments, tabs, suggestions, people/dates/dropdown chips) throws UnsupportedRequestError naming the request, so a migration fails loudly rather than silently. Differences to keep in mind: text styles are reported from direct run formatting only (not resolved through named styles), documents.get returns no namedStyles, and revisionId is a counter.