@devmm/puredocs-word
v1.0.0
Published
TypeScript library for reading and writing Word (.docx) files — zero deps except fflate
Readme
@devmm/puredocs-word
TypeScript library for reading, writing and editing Word (.docx) files. Zero runtime dependencies except
fflate. Works in Node.js, browser, Deno, and Bun.
Packages
| Package | Description |
|---------|-------------|
| @devmm/puredocs-word | Core: read/write/edit .docx — model, lossless round-trip, styles, tables, images, lists, find & replace, comments, track changes, fields, protection |
| @devmm/puredocs-word-mail-merge | Template engine: {{field}} and MERGEFIELD binding (lossless, cross-run) |
| @devmm/puredocs-word-html-import | HTML/Markdown → .docx and .docx → HTML/Markdown |
| @devmm/puredocs-word-angular | Angular 17+ WordService + provideWord() |
| @devmm/puredocs-word-react | React 18+ hooks: useDocument(), useTemplate() |
Installation
npm install @devmm/puredocs-wordArchitecture
The library is built on three layers you can drop into at any level:
- XML DOM (
parseXml/serializeXml) — a faithful, round-trip XML parser. - OPC package (
OoxmlPackage) — holds every part of a .docx; edits to one part leave every other part byte-identical on save (lossless round-trip). - Rich model (
Document,Paragraph,Run,DocTable, …) — an ergonomic builder/reader for authoring and inspecting content.
Lossless editing rule of thumb: to open a real Word file, tweak it, and keep everything else exactly as it was, edit at the package/XML layer (
OoxmlPackage). The rich-model writer (Document.saveAsBuffer) regenerates the document from scratch and is best for authoring new documents.
Quick Start
Create a document
import { Document, WordColor, WordBorderStyle } from '@devmm/puredocs-word';
const doc = Document.create();
doc.addHeading('Sales Report Q2', 1);
doc.addParagraph(p => p
.setAlignment('justify')
.addRun('Revenue increased by ')
.addRun('42%', r => r.setBold(true).setColor(WordColor.green))
.addRun(' compared to last quarter.'));
doc.addList(['APAC up 15%', 'EMEA up 8%', 'Americas up 19%'], 'bullet');
doc.addTable(t => {
t.setColumnWidths([2000, 3000, 2000]);
t.setAllBorders(WordBorderStyle.Single);
t.addHeaderRow(['Product', 'Price', 'Stock']);
t.addRow(r => { r.addTextCell('Laptop Pro'); r.addTextCell('$1,499'); r.addTextCell('42'); });
});
doc.setHeader(h => { h.setAlignment('right'); h.addRun('devmm Company — '); h.addPageNumber(); });
doc.setFooter(f => { f.setAlignment('center'); f.addRun('Confidential'); });
const buffer = doc.saveAsBuffer(); // Uint8Array — browser + Node
await doc.saveToFile('./report.docx'); // Node.js onlyRead an existing file
The reader parses the full document into the model — paragraphs with formatting, tables (incl. merged cells), hyperlinks, inline images, and section/page setup.
import { Document, Paragraph } from '@devmm/puredocs-word';
const doc = await Document.fromFile('./existing.docx'); // Node
// const doc = Document.fromBuffer(await file.arrayBuffer()); // browser
console.log(doc.getText()); // full plain text
for (const p of doc.getParagraphs()) { // deep (descends into tables)
console.log(p.style.styleName, '→', p.getText());
}Find & Replace (across runs)
Matches straddling multiple runs (as Word emits) are handled correctly.
const doc = await Document.fromFile('./letter.docx');
doc.replaceText('{{CustomerName}}', 'Nguyen Van A'); // plain string
doc.replaceText(/\d{4}-\d{2}-\d{2}/g, m => formatDate(m[0])); // regex + function
doc.replaceText('cat', 'dog', { matchCase: false, wholeWord: true });
await doc.saveToFile('./out.docx');Lossless package editing
Open a real document, change one thing, and keep everything else intact:
import { OoxmlPackage, acceptAllRevisions } from '@devmm/puredocs-word';
import { readFileSync } from 'fs';
const pkg = OoxmlPackage.open(readFileSync('./review.docx'));
// Every part is preserved; only what you touch is re-serialized.
const mainDoc = pkg.getMainDocument()!; // parsed word/document.xml (XML tree)
acceptAllRevisions(mainDoc); // resolve tracked changes in place
const out = pkg.toBuffer();Feature guide
Page-break constraints (extractPageConstraints)
Pagination engines that measure rendered DOM can detect overflow breaks but not the breaks Word stores explicitly. This returns those exact constraints so a hybrid engine can combine them with its own measurements.
import { OoxmlPackage, extractPageConstraints } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(readFileSync('./report.docx'));
for (const c of extractPageConstraints(pkg)) {
// c.blockIndex — 0-based index of a direct <w:body> child
// c.breakBefore — this block must start a new page
// c.keepWithNext — w:keepNext: do not orphan from the next block
// c.keepTogether — w:keepLines: do not split this block's lines
}blockIndex counts both <w:p> and <w:tbl> children of <w:body> in document
order; the trailing body-level <w:sectPr> is not a block. Blocks with no
constraints are omitted from the result.
breakBefore merges three sources: a <w:br w:type="page"/> run (the containing
block is marked — mid-paragraph breaks are approximated), w:pageBreakBefore, and
section breaks (the block after a w:sectPr paragraph, unless the section type
is continuous; a missing type means nextPage). Word's stale
lastRenderedPageBreak hints are deliberately ignored.
Headers, footers & document defaults
The reader resolves header/footer references and parses the referenced parts, so their content is available as ordinary model blocks.
const { sections, docDefaults } = readPackage(pkg);
const header = sections[0].headers?.default; // (Paragraph | DocTable)[]
const footer = sections[0].footers?.default;
sections[0].headers?.first; // title-page header, if any
sections[0].headers?.even; // even-page header, if any
docDefaults?.fontFamily; // from w:docDefaults/w:rPrDefault
docDefaults?.fontSizePt;Stylesheet.docDefaults() exposes the same values when you already hold a
stylesheet.
Text boxes & shapes (DocShape)
Text boxes and vector shapes are parsed instead of dropped. Pictures — inline,
floating (wp:anchor) or VML (v:imagedata) — remain DocImage.
import { DocShape, DocImage } from '@devmm/puredocs-word';
for (const child of paragraph.children) {
if (child instanceof DocShape) {
child.kind; // 'textBox' | 'shape'
child.placement; // 'inline' | 'floating'
child.geometry; // 'rect', 'ellipse', … (preset or VML element name)
child.content; // parsed blocks inside a text box
child.getText();
} else if (child instanceof DocImage) {
child.placement; // floating pictures are flagged, not silently inlined
}
}
paragraph.shapes; // just the DocShapes
paragraph.getText(); // text box content is NOT included hereWrite path: shapes are read-only in the model writer. Their raw XML (kept on
shape.rawXml) references relationship ids from the source package, and the model
writer rebuilds relationships from scratch, so re-emitting it verbatim would point
at unrelated parts. To preserve shapes, edit through
OoxmlPackage instead. Floating pictures round-trip
as inline pictures.
Styles (Stylesheet)
Read, query, resolve inheritance, and add custom styles.
import { OoxmlPackage, Stylesheet, RunStyle, WordColor } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
const styles = Stylesheet.parse(pkg.getText('word/styles.xml')!);
styles.get('Heading1')?.name; // "heading 1"
styles.basedOnChain('Heading1'); // ['Heading1', 'Normal']
styles.effectiveRunStyle('Heading1').bold; // merges docDefaults + basedOn chain
const warn = new RunStyle();
warn.bold = true; warn.color = WordColor.fromHex('C00000');
styles.addStyle({ styleId: 'Warning', name: 'Warning', basedOn: 'Normal', run: warn });
pkg.setText('word/styles.xml', styles.toXmlString());Document metadata (DocumentProperties)
import { OoxmlPackage, DocumentProperties } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
const props = DocumentProperties.parse(pkg.getText('docProps/core.xml')!);
props.title = 'Quarterly Report';
props.creator = 'Bill';
props.keywords = 'finance, q2';
pkg.setText('docProps/core.xml', props.toXmlString());Comments, footnotes & endnotes
import { OoxmlPackage, readComments, readFootnotes } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
readComments(pkg.getText('word/comments.xml')!); // [{ id, author, date, initials, text }]
readFootnotes(pkg.getText('word/footnotes.xml')!); // [{ id, text }] (separators skipped)Track changes (readRevisions / accept / reject)
import { OoxmlPackage, readRevisions, acceptAllRevisions, rejectAllRevisions } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
const doc = pkg.getMainDocument()!;
readRevisions(doc); // [{ type: 'insertion'|'deletion', author, date, text }]
acceptAllRevisions(doc); // keep insertions, drop deletions
// rejectAllRevisions(doc); // drop insertions, restore deletions
const out = pkg.toBuffer();Content controls (SDT) & fields
import { OoxmlPackage, readContentControls, readFields, insertTableOfContents } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
const doc = pkg.getMainDocument()!;
readContentControls(doc); // [{ tag, alias, type, text }]
readFields(doc); // [{ instruction: 'MERGEFIELD Name', result: '…' }]
// Insert a Table of Contents (dirty field → Word rebuilds it on open).
insertTableOfContents(doc, { title: 'Table of Contents' });
const out = pkg.toBuffer();To make Word update the TOC automatically on open, set the flag in settings:
import { Settings } from '@devmm/puredocs-word';
const settings = Settings.parse(pkg.getText('word/settings.xml')!);
settings.updateFieldsOnOpen = true;
pkg.setText('word/settings.xml', settings.toXmlString());Document protection (Settings)
import { Settings } from '@devmm/puredocs-word';
const settings = Settings.parse(pkg.getText('word/settings.xml')!);
settings.setProtection('readOnly'); // 'readOnly' | 'comments' | 'trackedChanges' | 'forms'
settings.getProtection(); // { edit: 'readOnly', enforced: true }
pkg.setText('word/settings.xml', settings.toXmlString());Password-based protection (Word's hash scheme) is not implemented; this enforces the edit mode without a password.
Numbering definitions (NumberingDefinitions)
import { OoxmlPackage, NumberingDefinitions } from '@devmm/puredocs-word';
const pkg = OoxmlPackage.open(buffer);
const nd = NumberingDefinitions.parse(pkg.getText('word/numbering.xml')!);
nd.isBullet(1); // true / false
nd.level(2, 0)?.numFmt; // 'decimal', 'lowerRoman', …Mail merge
Lossless template substitution — the template's styling and layout are preserved, and placeholders split across runs are handled.
import { MailMerge } from '@devmm/puredocs-word-mail-merge';
import { readFileSync, writeFileSync } from 'fs';
const template = readFileSync('./template.docx');
// {{CustomerName}} in the template, or Word «MERGEFIELD» display text
const result = MailMerge.merge(template, {
CustomerName: 'Nguyen Van A',
InvoiceDate: '2026-04-01',
TotalAmount: '1,500,000 VND',
});
writeFileSync('./invoice.docx', result);
const buffers = MailMerge.mergeAll(template, records); // one file per record
const combined = MailMerge.mergeAllInOne(template, records); // one file, page breaks
// Options: keepUnmatched leaves {{x}} in place when no data key matches
MailMerge.merge(template, data, { keepUnmatched: true });HTML & Markdown conversion
Import (HTML/Markdown → .docx)
import { HtmlToDocx, MarkdownToDocx } from '@devmm/puredocs-word-html-import';
const doc = new HtmlToDocx().convert('<h1>Title</h1><p><strong>Bold</strong></p>');
await doc.saveToFile('./from-html.docx');
const doc2 = await MarkdownToDocx.fromString('# Hello\n\n- a\n- b');
await doc2.saveToFile('./from-markdown.docx');Export (.docx → HTML/Markdown)
import { Document } from '@devmm/puredocs-word';
import { DocxToHtml, DocxToMarkdown } from '@devmm/puredocs-word-html-import';
const doc = await Document.fromFile('./report.docx');
const html = new DocxToHtml().convert(doc, { fullDocument: true, title: 'Report' });
const md = new DocxToMarkdown().convert(doc);Angular
// app.config.ts
import { provideWord } from '@devmm/puredocs-word-angular';
export const appConfig = { providers: [provideWord()] };
// component.ts
import { Component, inject } from '@angular/core';
import { WordService } from '@devmm/puredocs-word-angular';
@Component({ selector: 'app-export', template: `<button (click)="export()">Export</button>` })
export class ExportComponent {
private word = inject(WordService);
async export() {
const buffer = await this.word.createDocument(doc => {
doc.addHeading('Report', 1);
doc.addParagraph(p => p.addRun('Generated by Angular'));
});
this.word.downloadBlob(buffer, 'report.docx');
}
}React
import { useDocument } from '@devmm/puredocs-word-react';
function ExportButton() {
const { createDocument, downloadBlob, loading } = useDocument();
const handleExport = async () => {
const buffer = await createDocument(doc => {
doc.addHeading('Hello from React', 1);
doc.addParagraph(p => p.addRun('Generated by useDocument hook'));
});
downloadBlob(buffer, 'export.docx');
};
return <button onClick={handleExport} disabled={loading}>Export Word</button>;
}Units Reference
Word OOXML uses two unit systems — use the provided helpers:
import { inchToTwip, cmToTwip, ptToTwip, inchToEmu, cmToEmu } from '@devmm/puredocs-word';
inchToTwip(1) // → 1440 (margins, spacing, indents, column widths)
cmToTwip(2.54) // ≈ 1440
ptToTwip(12) // → 240
inchToEmu(1) // → 914400 (image dimensions)
cmToEmu(10) // → 3600000Capability status
| Area | Status |
|------|--------|
| Read / write / lossless round-trip | ✅ |
| Formatting (runs, paragraphs, sections) | ✅ |
| Tables (merged cells), images, hyperlinks, lists | ✅ |
| Header/footer content, docDefaults | ✅ |
| Page-break constraints for pagination engines | ✅ |
| Text boxes, floating & VML pictures, vector shapes | ✅ read-only |
| Styles (+ basedOn inheritance) | ✅ |
| Find & Replace (cross-run, regex) | ✅ |
| Mail merge | ✅ |
| Metadata, comments, footnotes/endnotes | ✅ |
| Track changes (accept / reject) | ✅ |
| Content controls, fields, Table of Contents | ✅ |
| Document protection | ✅ (no password hash) |
| Export to HTML / Markdown | ✅ |
| PDF / per-page image rendering | ⏳ planned — see ROADMAP |
| Shape / chart / SmartArt authoring, digital signatures | ⏳ planned |
PDF and image output require a layout engine (line breaking, pagination, font metrics) and are tracked as a separate, larger effort in docs/ROADMAP.md.
Development
pnpm install
pnpm build # build all packages (turbo)
pnpm test # run all tests (vitest)Project structure
packages/
├── core/ @devmm/puredocs-word ← main package
├── mail-merge/ @devmm/puredocs-word-mail-merge
├── html-import/ @devmm/puredocs-word-html-import
├── angular/ @devmm/puredocs-word-angular
└── react/ @devmm/puredocs-word-reactLicense
MIT © devmm
