@dealops/doxy

中文文档
0.1.5发布于 2026/10/27MITTypeScriptESM + CJS近 30 天下载 2288

Fill .docx templates: replace {{tags}}, repeat table rows and paragraphs, insert images. Includes a Google Docs API compatible adapter (documents.get / batchUpdate) that edits .docx files. Runs in Node, browsers, and Cloudflare Workers.

原始使用文档
文档目录14 节
  1. Template syntax
  2. What gets repeated
  3. Table rows from data
  4. Removing what a tag sits in
  5. Leaving tags for a later step (e-signature anchors)
  6. Multi-paragraph blocks
  7. Images
  8. API
  9. Google Docs with tabs
  10. Preparing a Google Docs export ( normalizeTemplate )
  11. Rendering twice ( renderDocxPasses )
  12. Finishing a rendered document ( postprocessDocx )
  13. Notes
  14. Google Docs API compatible adapter ( @dealops/doxy/gdocs )

@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.

TagMeaning
{{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 paragraphjust the content between them (inline)
in different cells of the same table rowthe whole row
in different rows of a tablethe rows from the open tag's row to the close tag's row
in different paragraphsthe 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:

ProductQty
{{#lines}}{{sku}}{{qty}}{{/lines}}

renders one row per line item, and this one does the same with the tags on their own rows:

ProductQty
{{#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 TypeUnit PriceQtySubtotal
{{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:

OptionDefaultDescription
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.
removeEmptyParagraphsfalseDrop 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.
partsdocument, headers, footers, footnotes, endnotesPredicate 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 each text in the body (table cells included, across runs) becomes a hyperlink to url (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 docPr ids, 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.

版本与发布记录

@dealops/doxy 共发布 7 个版本,首次发布于 2026/10/20;过去 12 个月发布了 7 个版本。

版本发布日期距今
0.1.5latest2026/10/2717 天前
0.1.42026/10/2123 天前
0.1.32026/10/2123 天前
0.1.22026/10/2024 天前
0.1.12026/10/2024 天前
0.1.02026/10/2024 天前

关于 @dealops/doxy 的常见问题

@dealops/doxy 是什么?

Fill .docx templates: replace {{tags}}, repeat table rows and paragraphs, insert images. Includes a Google Docs API compatible adapter (documents.get / batchUpdate) that edits .docx files. Runs in Node, browsers, and Cloudflare Workers.

如何安装 @dealops/doxy?

在项目目录运行 npm install @dealops/doxy,或使用 pnpm add / yarn add / bun add。

@dealops/doxy 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@dealops/doxy 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@dealops/doxy 可以商用吗?

@dealops/doxy 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@dealops/doxy 的下载量有多少?

根据 npm 官方下载统计,@dealops/doxy 在 2026/10/12 – 2026/11/10 这 30 天内被下载 2,288 次,日均约 76 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@dealops/doxy 还在维护吗?

最新版本 0.1.5 发布于 2026/10/27(17 天前);至今共发布 7 个版本,其中过去 12 个月发布了 7 个;首次发布于 2026/10/20。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。