rwml 0.1.0

Native Rust toolkit for Microsoft Word — read, write, edit, and render legacy .doc (Word 97-2003, [MS-DOC]) and modern .docx (OOXML): one document model, package-preserving edits, field evaluation, Markdown/HTML export, PDF preview
Documentation

rwml

Crates.io Docs.rs CI License: MIT MSRV

rwml — from WordprocessingML, the OOXML markup for Word documents — is a native Rust toolkit for Microsoft Word documents: read, write, edit, and render, covering both formats: legacy .doc (Word 97–2003 binary, MS-DOC) and modern .docx (OOXML WordprocessingML). No JVM, no Apache POI, no other .docx crate, no subprocess.

One model ties it together. Document::open detects the format from the magic bytes (OLE2 D0CF11E0.doc; ZIP PK.docx) and both backends produce the same DocModel; the Markdown/HTML exporters, the .docx writer, and the PDF renderer all consume that one model, so your code never branches on the format.

 .doc  ┐                          ┌→ text · markdown · html
 .docx ┼→ Document::open → DocModel┼→ write_docx  (styled .docx)
 (build it yourself) ┘             ├→ render_pdf   (typeset PDF)
                                   └→ edit → save  (package-preserving .docx)

Read

// Plain text (search / indexing) — .doc or .docx, detected automatically:
let bytes = std::fs::read("report.docx")?;
let text = rwml::extract_text(&bytes)?;

// Rich model + exporters (identical IR for .doc and .docx):
let doc   = rwml::Document::open(&bytes)?;
let md    = doc.to_markdown();   // # headings, **bold**, | tables |, lists, links, page breaks
let html  = doc.to_html();       // <h1>, <strong>, <table colspan>, <img>, <a>, page breaks
let model = doc.model();         // typed IR: Vec<Block> (Paragraph | Table | Image | PageBreak | SectionBreak)
let hregs = model.source_regions(rwml::SourceRegionKind::HeaderFooter);
let imgs  = doc.images();        // extracted raster bytes (like POI getAllPictures)
let info  = doc.report();        // format, stats, edit state, feature inventory incl. notes/text boxes/metafiles
let json  = info.to_json();      // compact diagnostics JSON for scripts/CLI
let edit  = doc.edit_capability(); // package-preserving edit availability
let props = doc.core_properties(); // title/creator/etc. from docProps/core.xml when present
let comments = doc.comments();   // .docx comments + recovered .doc annotations
let notes = doc.notes();         // .docx + recovered .doc footnote/endnote records with anchors
let boxes = doc.text_boxes();    // .docx + recovered .doc text-box records
let shapes = doc.floating_shapes(); // .docx wp:anchor geometry/layout/anchor/preset/color/simplePos/effect/wrap-distance/polygon records
let hfs   = doc.header_footers(); // .docx part/type records + recovered .doc regions
let flds  = doc.fields();        // .docx + recovered .doc fields
let revs  = doc.revisions();     // .docx tracked changes (kind, metadata, text)
let hdr   = doc.header_text();   // running header/footer text when modeled
let orig  = doc.main_text_with_revision_view(rwml::RevisionView::Original);

Write — author a styled .docx

Build a document with DocBuilder or the lower-level DocModel structs, then serialize it to a clean, Office-openable .docx. Character formatting (font, size, color, bold/italic, highlight, super/subscript), paragraph layout (named styles, headings, alignment, spacing, indent, shading, page-break-before), leveled lists, bordered tables with width, fixed layout, alignment, indentation, uniform/per-side border widths, styles, and colors, and per-cell shading / width / margins / vertical alignment, images with alt text, explicit pixel size, inline rotation, and page-relative floating offsets, simple fields with cached results, PAGEREF helper runs, dirty TOC heading-range fields, run-anchored comments with reply parent ids and commentsExtended metadata, tracked insertion/deletion runs, run-level content controls, bookmarked runs, authored footnotes/endnotes, string custom document properties, raw custom XML data-store items, generated core metadata (title, subject, creator, description, keywords, category, content status, last modified by, created, modified, last printed, revision, and version), explicit Word document ids, web-extension task pane package shells, page setup with section columns, document grids, text direction, title pages, and page-number restarts/formats, explicit page breaks and next/even/odd section breaks, styled default/first/even running headers/footers, and page numbers all round-trip. Content controls can include tag/alias and data-binding metadata.

ChartBuilder authors the current core OOXML chart families with embedded workbook-backed data: bar / column / line / area (each in plain, stacked, 100%-stacked, and 3-D variants), radar (plain, with-markers, filled), scatter (line-only, smooth, smooth-markerless, marker-only), bubble (2-D and 3-D), pie / doughnut (plain, exploded, 3-D), surface / 3-D surface, high-low-close stock and stock, and pie-of-pie / bar-of-pie. It also authors the newer chart-ex extension families — waterfall, treemap, sunburst, histogram, box & whisker, and funnel — emitted as chartEx parts. wireframe() styling is available for surface-family charts and ChartShape styling (cylinder/pyramid) for 3-D bar/column-family charts. See examples/report.rs.

let model = rwml::DocBuilder::new()
    .title("분기 운영 리포트")
    .creator("rwml")
    .margins_pt(54.0)
    .header_runs([rwml::RunBuilder::new("분기 운영 리포트").bold().build()])
    .footer_runs([rwml::RunBuilder::new("Page ").italic().build()])
    .page_numbers()
    .paragraph_style(
        rwml::ParagraphStyleBuilder::new("RiskCallout", "Risk callout")
            .based_on("Normal")
            .shading(rwml::Color::rgb(0xFE, 0xF2, 0xF2))
            .run_bold()
            .run_color(rwml::Color::rgb(0xC0, 0x00, 0x00)),
    )
    .heading(1, "분기 운영 리포트")
    .paragraph("작성일 2026-06-22")
    .rich_paragraph(rwml::ParagraphBuilder::new().runs([
        rwml::RunBuilder::new("주의 필요")
            .comment(
                rwml::CommentBuilder::new("담당자 확인 필요")
                    .author("Reviewer")
                    .initials("RV"),
            )
            .build(),
        rwml::RunBuilder::new(" - ").build(),
        rwml::RunBuilder::new("가이드")
            .hyperlink("https://example.com/guide")
            .underline()
            .build(),
        rwml::RunBuilder::new("추가 문장")
            .revision(
                rwml::RevisionBuilder::insertion()
                    .author("Reviewer")
                    .date("2026-06-24T01:00:00Z"),
            )
            .build(),
        rwml::RunBuilder::new("승인 필요")
            .content_control(
                rwml::ContentControlBuilder::new()
                    .alias("Approval")
                    .tag("approval-required"),
            )
            .build(),
    ]).style("RiskCallout"))
    .numbered_list(["문서 변환 점검", "릴리스 노트 작성"])
    .bullet_list_level(1, ["담당자 확인"])
    .field("FILENAME \\p", "report.docx") // writes a simple field cached result
    .hyperlink("프로젝트 링크", "https://example.com/")
    .rich_table(
        rwml::TableBuilder::new()
            .header_rows(1)
            .col_widths_pct([0.7, 0.3])
            .row([
                rwml::CellBuilder::text("작업")
                    .shading(rwml::Color::rgb(0x1F, 0x38, 0x64)),
                rwml::CellBuilder::text("담당 부서")
                    .shading(rwml::Color::rgb(0x1F, 0x38, 0x64)),
            ])
            .row([
                rwml::CellBuilder::text("문서 변환 점검"),
                rwml::CellBuilder::text("플랫폼팀"),
            ]),
    )
    .section_break()
    .clear_header()
    .page_size_pt(792.0, 612.0)
    .landscape()
    .header_runs([rwml::RunBuilder::new("후속 조치").bold().build()])
    .heading(2, "후속 조치")
    .build();

std::fs::write("out.docx", rwml::write_docx(&model))?;   // opens in Word & LibreOffice

The output is validated to re-open in Word (verified via python-docx reading back the named styles, run colors, fonts, and table shading) and LibreOffice.

Edit — open, change, save (package-preserving)

Document::open keeps the whole package, so save() re-emits it with everything rwml doesn't model preserved verbatim (themes, settings, fonts, comments, custom XML, charts, embeddings, unknown parts). A no-op open→save is byte-stable per part.

let mut doc = rwml::Document::open(&std::fs::read("in.docx")?)?;

// Element-tree edit: preserves fields, content controls, shapes, comments…
doc.replace_body_text("DRAFT", "FINAL")?;
doc.set_field_result(0, "7")?;                  // cached result for body field index 0
doc.fill_content_controls_by_tag([
    ("client-name", "Acme & Co"),
    ("project-name", "Roadmap"),
])?;
doc.fill_template_fields([
    ("client-name", "Acme & Co"),
    ("project-name", "Roadmap"),
])?; // body/note/header/footer content controls + MERGEFIELD cached results
doc.accept_all_revisions()?;                    // accept tracked body/note/header/footer changes
// doc.reject_all_revisions()?;                 // or reject tracked body/note/header/footer changes
doc.set_hyperlink_target(0, "https://example.com/final")?; // body hyperlink rel
doc.set_comment_text("7", "Updated note")?;     // existing comment body text
doc.add_comment_on_text("Clause", "Check this", "Reviewer")?; // exact body run anchor
doc.set_table_cell_text(0, 0, 1, "Updated")?;   // top-level table/row/logical column
doc.replace_header_footer_text("DRAFT", "FINAL")?;
doc.replace_text_in_part("word/header2.xml", "DRAFT", "FINAL")?; // explicit WML part
doc.add_footnote_on_text("Clause", "Source note")?; // exact body run anchor
doc.add_endnote_on_text("Clause", "Appendix note")?; // exact body run anchor
doc.replace_note_text("DRAFT", "FINAL")?;       // existing footnote/endnote text
doc.add_image_png(&png_bytes, "image1.png")?;   // media + content-type + rId, atomic
doc.replace_image_png(&new_png, "image1.png")?; // existing word/media/*.png bytes
doc.add_image_jpeg(&jpg_bytes, "photo.jpg")?;   // validated JPEG media insert
doc.replace_image_jpeg(&new_jpg, "photo.jpg")?; // existing word/media/*.jpg bytes
doc.add_image_gif(&gif_bytes, "anim.gif")?;     // validated GIF media insert
doc.replace_image_gif(&new_gif, "anim.gif")?;   // existing word/media/*.gif bytes
doc.add_image_bmp(&bmp_bytes, "bitmap.bmp")?;   // validated BMP media insert
doc.replace_image_bmp(&new_bmp, "bitmap.bmp")?; // existing word/media/*.bmp bytes
doc.add_image_tiff(&tiff_bytes, "scan.tiff")?;  // validated TIFF media insert
doc.replace_image_tiff(&new_tiff, "scan.tiff")?; // existing word/media/*.tif/.tiff bytes
doc.add_image_webp(&webp_bytes, "pic.webp")?;   // validated WebP media insert
doc.replace_image_webp(&new_webp, "pic.webp")?; // existing word/media/*.webp bytes
doc.set_core_property(rwml::CoreProperty::Title, "Final report")?;

let touched = doc.edited_parts();               // package parts dirtied by edits
std::fs::write("out.docx", doc.save()?)?;        // untouched parts preserved

Every one of the edit methods above mutates live WordprocessingML element trees or media parts in place, so everything they don't touch — including content the lossy model can't represent (fields, content controls, shapes, comments, tracked changes) — is preserved byte-for-byte; save() re-serializes only the parts you changed. Regenerated relationship parts are validated before save, so internal relationship targets must point at retained package parts unless they are explicitly external. Document::new() starts from a bundled blank template. To author a document from data (or convert a .doc), build a DocModel and use write_docx instead. Call edit_capability() or inspect report().edit before editing if you need machine-readable read-only reasons such as legacy .doc, incomplete retained packages, or lossy OPC metadata. Call edited_parts() after edits to inspect the sorted package part names that will be reserialized or regenerated; the same list is included in report().edited_parts and diagnostics JSON. Core metadata from core_properties() is included in report().core_properties; parsed string custom properties are included in report().custom_properties.

Render — typeset to PDF

Lay any model out to a paginated PDF with native typesetting — parley shapes and line-breaks (Korean/CJK UAX #14 line-breaking + script font fallback), krilla emits the PDF with subsetted embedded fonts and selectable text. Rich runs (color/size/font, caps/small-caps), lists with real autonumber labels and indentation, bordered tables with shaded, vertically-aligned cells and authored column widths, images, and clickable hyperlink annotations are drawn; page size/orientation and per-side margins come from the document; multi-page tables repeat their header rows and a row taller than a page splits across pages. Behind the render feature.

Scope: this is a fast, in-process preview / report renderer, not a Word layout engine. It is faithful to the model and selectable, but it does not match LibreOffice fidelity — exact pagination, exact floating-object layout, unknown fields, unresolved or unsupported remaining value-changing REF cases beyond the deterministic note/comment-reference mark subset, remaining advanced TOC/REF computed field evaluation, and pixel layout differ. Opened-document renders draw bounded approximate overlay boxes for recovered .docx floating-shape geometry on the recovered top-level body block page when available, and compact placeholder lines for preserved charts, OLE objects, WMF/EMF/EMZ/WMZ images, image nodes whose bytes are unavailable, raster images the PDF backend cannot decode, and any floating-shape markers without recovered geometry rather than drawing those objects exactly. Measured against LibreOffice on a real corpus it reaches ~0.93 text recall with close page counts; for archival or Word-exact PDF, render via LibreOffice. (See Scope & parity.)

rwml = { version = "0.1", features = ["render"] }
let pdf = rwml::render_pdf(&model);                 // uses system fonts
let pdf = rwml::try_render_pdf(&model)?;             // fallible variant
// On a headless host without CJK fonts, supply your own:
let kr  = std::fs::read("NotoSansKR-Regular.otf")?;
let pdf = rwml::render_pdf_with_fonts(&model, &[kr]);

let rendered = rwml::render_pdf_with_report(&model);
eprintln!(
    "pages={} render_warnings={}",
    rendered.report.pages,
    rendered.report.warnings.len()
);

For portable Korean PDF rendering, enable bundled-fonts to opt into the separate OFL-licensed rwml-fonts crate: it registers a Noto Sans KR subset covering KS X 1001 Hangul + hanja plus Latin while rwml itself remains MIT-licensed. Other scripts still use system font fallback, matching render_pdf_with_fonts.

layout_pages_with_fonts exposes layout-derived page numbers from the same preview-grade pagination: the page count plus the page each body PAGE field and top-level block lands on — matching rwml's own PDF output, not Microsoft Word's pagination. Supplied fonts are used strictly (system fonts disabled), so identical document + font bytes give identical pages; results live in a separate LayoutPages record and never overwrite reader-path computed_result semantics.

You can also convert a parsed document straight to PDF: Document::open(&bytes)?.to_pdf() / try_to_pdf(), pass font blobs with to_pdf_with_fonts() / try_to_pdf_with_fonts(), or use to_pdf_with_report() / to_pdf_with_fonts_and_report() when you want page count and renderer warnings tied to the opened document's feature inventory. The opened-document paths use that inventory for visible placeholder lines when unsupported preserved objects are present.

CLI (examples)

rwml extract  file.docx                                # plain text
rwml convert  file.doc md                              # Markdown / html / txt
rwml diagnose file.docx                                # JSON feature report
rwml to-docx legacy.doc out.docx                       # .doc → clean .docx
rwml to-pdf file.docx out.pdf --report-json render.json # PDF + render report

cargo run --bin rwml -- diagnose file.docx             # same CLI from source
cargo run --features render --bin rwml -- to-pdf file.docx out.pdf --report-json render.json
cargo run --example report   -- report.docx            # author a styled report
cargo run --features render --example to_pdf -- file.docx out.pdf
cargo run --features render --example to_pdf -- file.docx out.pdf --report-json render.json
python scripts/render_validate.py --json --min-mean-recall 0.90 --max-skipped 0 corpus/public/**/*.docx > render.json
python scripts/bench_vs_mature.py --corpus "$RWML_BENCH_CORPUS" --json \
  --version 0.1.0 --git-rev "$(git rev-parse HEAD)" \
  --min-poi-recall-mean 0.95 --min-poi-f1-mean 0.95 --max-errors 0 --min-scored 1 \
  --output dist/extract-benchmark.json
python scripts/public_hygiene_audit.py --json > dist/public-hygiene.json
python scripts/release_manifest.py --version 0.1.0 --git-rev "$(git rev-parse HEAD)" \
  --release-policy public-release \
  --enforce-policy-inputs \
  --hygiene-report dist/public-hygiene.json \
  --corpus-manifest corpus/public/MANIFEST.tsv --corpus-manifest corpus/public/RENDER_MANIFEST.tsv \
  --validation-report render.json --benchmark-report dist/extract-benchmark.json \
  --output dist/rwml-release-manifest.json dist/*

Cargo features

feature default pulls in enables
docx zip, quick-xml, flate2 .docx read, write_docx, and package-preserving edit/save
render parley, krilla render_pdf / to_pdf (MSRV 1.92)
bundled-fonts render, rwml-fonts render_pdf_bundled with an OFL Noto Sans KR subset covering KS X 1001 Hangul + hanja

The library also emits an rlib plus cdylib; on wasm32 it uses a target-specific wasm-bindgen dependency for the thin rwml::wasm read/report adapter (extractText, markdown, html, reportJson). examples/wasm-demo is a static browser inspector over that adapter: it opens local files, shows text/Markdown/HTML preview, and exposes the same diagnostics JSON without adding an editing UI.

For a dependency-light, legacy-only build (just cfb + encoding_rs + thiserror): rwml = { version = "0.1", default-features = false } (reads .doc, emits text/markdown/html).

Why one crate? (and how this relates to docx-rs)

The mature docx-rs proves there is real demand for Rust-native .docx authoring. rwml aims higher than a writer-only surface: legacy .doc (no comparable pure-Rust option exists) and modern .docx produce the identical DocModel and share one read/write/edit/render/report surface, with no JVM, no subprocess, and no second Word parser in the tree.

How it works

A .docx is a ZIP of XML parts. rwml reads word/document.xml with quick-xml by recursive descent (paragraphs → runs with w:rPr; tables w:tbl with gridSpan/vMerge → real colspan/rowspan), resolves heading levels from word/styles.xml (w:pStyle / Heading N / 제목 N), ordered-vs-bullet from word/numbering.xml, and hyperlink targets + image bytes from word/_rels/document.xml.rels + word/media/*. Running headers/footers are resolved from the sectPr references (word/header*.xml / footer*.xml, each with its own rels) into section-break setup plus the final DocSetup, including default, first-page, and even-page variants where present, and text-box text (w:txbxContent, DrawingML or VML, single-branch on mc:AlternateContent) is folded back into the body. Recursion is depth-capped, XML external entities are never resolved (XXE-safe), and per-entry decompression is size-capped (zip-bomb guard).

.doc is an OLE2 compound file. rwml opens it with cfb, parses the FIB by navigating its variable-length sub-structures (never hard-coded offsets) to the piece table and sub-document char counts, decodes each piece as UTF-16LE or 8-bit text in the document's ANSI codepage derived from the FIB language id (lid) — so Korean (0x0412 → cp949), Japanese, Cyrillic, etc. decode correctly. The rich model is a lazy second pass: the CHPX character-property bins (bold/italic/ underline/strike, font name from SttbfFfn, half-point size, color), the STSH style sheet + outline levels (headings), sprmTDefTable (merge-aware tables), list autonumbers, hyperlink field marks, and PICF inline images.

The .docx writer is the inverse of the reader, part by part: document.xml (w:rPr/w:pPr with the full property set), a synthesized styles.xml (Normal + Heading1–6 with outlineLvl), numbering.xml, header/footer parts wired through sectPr, media parts + relationships for images, and external relationships for hyperlinks. The renderer flows the model into A4 pages and draws each page's glyph runs, table grids, shading, and images with krilla.

Encrypted / XOR-obfuscated documents and pre-Word-97 (Word 6/95) files are detected and reported as distinct Errors rather than silently emitting garbage. Every read is bounds-checked: malformed input returns an Error, never a panic — safe to run untrusted files in-process (#![forbid(unsafe_code)], fuzzed).

Scope & parity

Reading. Flat text targets POI WordExtractor.getText(). .doc validated on private Korean-language .doc fixtures at ~97.4% whitespace-insensitive parity vs POI (body text ~100%). .docx validated against python-docx on the 127-file Apache POI corpus (87 with comparable text): 98.6% mean word recall, 100% median, 85/87 ≥ 99%, whole corpus panic-free (incl. clusterfuzz inputs). The two sub-99% files are a duplicate-document.xml zip-confusion container and a heavy tracked-changes doc (accept-changes view) — explained, not extraction bugs.

Writing. read → model → write_docx → read round-trips structure and the rich character/paragraph/table formatting (covered by unit + integration tests); the authored report opens in Word and LibreOffice.

Editing (package-preserving). Document::open retains the whole package and save() re-emits it with every unmodeled part preserved verbatim — a no-op open→save is part-payload byte-stable (the ZIP container metadata is normalized). Edits go through live WordprocessingML element trees or media-part replacement (the methods shown under Edit — open, change, save), so unmodeled body content (fields, shapes, content controls, comments, tracked changes) survives. edited_parts() reports touched package parts, and Document::report() includes core metadata, edit capability, and edited part names; it emits PackageReadOnly when preservation edits are refused. Validated on the 127-file corpus with python-docx as the strict external checker: passthrough is part-payload byte-stable; the element-tree image insert produces a package python-docx opens with the inline image present on every openable file; both fail cleanly (no panic) on a pathologically-deep file and a structurally-broken original. To author/convert from a DocModel, use write_docx (it regenerates a fresh package, lossy w.r.t. unmodeled content).

Rendering. scripts/render_validate.py compares the renderer to LibreOffice per document on three metrics (text recall, page-count ratio, average-hash visual similarity) plus rwml render-warning counts/kinds, and can emit a JSON report for release tracking. Its --soffice auto default uses a local soffice when available and otherwise falls back to the Docker lo-cli backend. The public synthetic corpus also includes a render manifest checked by cargo test --features render. rwml is a preview-grade renderer, faithful to the model but not a LibreOffice replacement. Generated running footer page numbers and body PAGE fields are computed from the emitted PDF page list; section-aware default/first/even running header/footer variants are selected with first-page variants scoped to each section and even variants based on emitted page parity; field-code HYPERLINK runs render as link annotations for target/anchor, tooltip/frame, and documented \m/\n no-op switch tails, and malformed hyperlink syntax reports UnsupportedSwitch.

Field evaluation is deterministic and source-order stable, and applies identically in the reader, the render model, and side-table text surfaces (comment bodies/anchors, tracked-change text, note anchors, floating-shape and text-box text, TOC heading sources). Every FieldKind is distinguished from an unknown field; where a value can't be computed deterministically the cached result text is preserved (including inline tabs, line breaks, and no-break/soft hyphens for simple and common complex fields) and a reason is reported. The four diagnostic reasons — UnknownField, UnresolvedBookmark, UnsupportedSwitch, NoComputedResult — are surfaced with both field-kind counts and reason counts, and malformed instruction syntax for any supported family reports UnsupportedSwitch.

Family (fields) Computed subset Cached / ceiling
Document-info / date / stat (AUTHOR, TITLE, SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, CATEGORY, VERSION, NUMPAGES, NUMWORDS, NUMCHARS, EDITTIME, TEMPLATE, FILESIZE, CREATEDATE, SAVEDATE, PRINTDATE, DOCPROPERTY, DOCVARIABLE, INFO, …) Metadata-backed values from docProps/core.xml / custom.xml / app.xml and word/settings.xml, with simple numeric \@ date pictures (y/M/d/H/h/m/s, English MMM/MMMM, ddd/dddd, AM/PM), \* number formats, and FILESIZE \k/\m switches; direct USERNAME/USERINITIALS/USERADDRESS literal overrides Cached date/user/unmapped fields render warning-free when syntax is valid
Formula / expression (=, IF, QUOTE, COMPARE, FILLIN, ASK, SET, NEXT, NEXTIF, SKIPIF) Literal arithmetic (+ - * / ^, parens, unary), scalar functions (ABS, AND, AVERAGE, COUNT, DEFINED, IF, INT, MAX, MIN, MOD, NOT, OR, PRODUCT, ROUND, SIGN, SUM, TRUE/FALSE), comparisons, non-spanning table-position formulas (A1/RnCn/LEFT/RIGHT/ABOVE/BELOW), literal QUOTE/IF/COMPARE (incl. ?/* wildcards), FILLIN/ASK default responses, SET bookmark assignments feeding later REF/comparisons; \# numeric pictures and neutral/text-format tails Bookmark/reference formula expressions, broader picture syntax, and non-literal forms report NoComputedResult
PAGE Current page from trusted leading structural / source-rendered context, section w:pgNumType restarts + supported page-number format styles, page-number and field-result format switches Broader layout-derived current-page cases keep cached text
PAGEREF Page numbers from leading page breaks / pageBreakBefore / section starts, restart labels + supported w:fmt styles, \* number formats, \p relative (above/below/on page N) Remaining layout-dependent references keep cached text; missing targets → UnresolvedBookmark
REF / direct bookmark Bookmark text (incl. hidden targets, multi-paragraph ranges), \* Upper/Lower/Caps/FirstCap, \# numeric picture on numeric bookmark text, \p relative, numbered-paragraph \n/\r/\w (+ \p/\t), \f note-reference marks, neutral \h/\!, text-neutral \d "sep" Value-changing \d separators, non-numeric \# targets, and broader REF semantics keep cached text
NOTEREF / FTNREF Footnote/endnote reference marks (honoring settings.xml numStart/numFmt and skipping w:customMarkFollows auto-numbering), \h, \f, \p above/below, number/text format switches Missing targets → UnresolvedBookmark; no note mark or custom-mark target → NoComputedResult; per-page note restart is layout-dependent
STYLEREF Nearest styled paragraph/run text by style id or name (backward-then-forward), \p above/below, numbered \n/\r/\w/\t Page-aware / header-footer / layout-dependent lookup keeps cached text
TOC / TC / SEQ Default, \b bookmark-scoped, \o/\u outline, \t custom-style, \f from TC markers, \c/\a caption entries from SEQ; source-order SEQ recompute; \h/\z/\w/\x/\n/\p/\s/\d and \* switches Advanced/layout-dependent TOC cases keep cached text; missing \b scope → UnresolvedBookmark
SECTION / SECTIONPAGES / REVNUM Current structural section number; structurally bounded section page counts; REVNUM from cp:revision; page-number and field-result format switches Layout-dependent section page counts keep cached text
Display / layout (EQ, SYMBOL, ADVANCE) EQ fractions/radicals/brackets/boxes/lists/arrays/scripts/integrals/overstrikes as plain text; SYMBOL decimal/hex/ANSI/\u/font \f/size \s incl. common Symbol/Wingdings; ADVANCE hidden with validated movement switches Layout offsets, broader equation formatting, and rarer font mappings keep cached text
Numbering / list (AUTONUM, AUTONUMLGL, AUTONUMOUT, BIDIOUTLINE, LISTNUM) Source-order counter values with common number/text formats and \s separators/starts; level-1 LISTNUM NumberDefault/LegalDefault Richer outline semantics and named/multi-level lists keep cached text
Legacy form (FORMTEXT, FORMCHECKBOX, FORMDROPDOWN) w:ffData checkbox checked/default states, dropdown result/default selections, non-empty text-input current results or empty-current w:textInput defaults Broader protected-form behavior keeps cached text
Action / automation (GOTOBUTTON, MACROBUTTON, PRINT) Display text without executing navigation/macros; PRINT printer-control groups render hidden without executing instructions Broader forms keep cached text
Inserted content, mail-merge helpers, reference/index, compatibility/private, barcode Named separately from unknown fields; simple literal RD/TA/XE markers render hidden Cached text preserved; linked/external/opaque payloads not evaluated → NoComputedResult

Volatile DATE/TIME (explicit \@ picture) and USERNAME-family fields are computed deterministically by supplying caller inputs via FieldContext/fields_with_context — the context is an input, so identical document plus identical context always yields identical results.

Authored charts render as native vector preview charts (see chart families). On a real .docx corpus it reaches ~0.93 mean text recall (extracting headers/footers, text boxes, nested tables, real list labels, caps; model-driven page geometry makes .doc page counts line up — mean .doc render recall ~0.96). It still trails LibreOffice on exact pagination, exact floating-object layout, remaining layout-derived PAGEREF page-reference computation beyond trusted source markers, advanced TOC/REF/NOTEREF computed fields, and pixel-level visual fidelity; those are inherent to a compact native renderer, not bugs to be closed to parity. For Word-exact or archival PDF, render via LibreOffice. rwml aims to match specialist extractors on text/model recovery while staying dependency-light; render fidelity remains below LibreOffice. render_pdf_with_report / to_pdf_with_report expose the emitted page count and renderer warnings for unsupported fields, floating shapes, charts, OLE objects, WMF/EMF/EMZ/WMZ images, image nodes whose bytes are unavailable, and raster images skipped because the PDF backend cannot decode their bytes. Document::report().features.metafiles exposes metafile part path, format, stored byte size, compression flag, and header-derived dimensions when a raw or gzip-wrapped EMF header or placeable WMF header makes that cheap to recover. Floating-shape feature counts use the same accepted/current revision and single-branch mc:AlternateContent policies as floating_shapes(), so direct, inserted, and moved-to shapes count, deleted and moved-from old-only anchors or markers are omitted, Choice/Fallback serializations of one shape count once, and unrecovered alternate-content shape markers still count as one marker. Opened-document PDF rendering draws approximate overlay boxes for recovered .docx wp:anchor geometry and anchor layout metadata, including enabled wp:simplePos absolute placement, relative z-order, behind/in-front flags, anchor dist* margins, wp:effectExtent bounds, wrap-element dist* margins, wrap policy, wp:wrapPolygon point counts, and a best-effort visible top-level body block anchor page, including body blocks wrapped by transparent content controls, custom XML, smart tags, single-branch mc:AlternateContent, or accepted/current revision wrappers, while omitting deleted and moved-from old-only shape anchors. It surfaces recovered containing-block anchor text, zero-width anchor character offsets inside that text, DrawingML preset geometry names, simple sRGB solid fill/outline colors, anchor-distance and wrap-distance labels, wrap-polygon point counts, and text-bearing shape body text in preview labels. It appends compact placeholder lines for preserved-but-unmodeled chart parts, OLE objects, unsupported metafile images, image nodes whose bytes are unavailable, skipped raster images whose bytes the PDF backend cannot decode, and shape markers without recovered geometry. Exact body-page anchoring beyond that best-effort block page, real text-wrap reflow, and non-text Office-Art drawing contents remain out of scope. scripts/bench_vs_mature.py emits a schema-tagged JSON extraction benchmark report against local Apache POI and LibreOffice goldens and can enforce release thresholds for mean POI recall/F1, mean LibreOffice recall, scored-file counts, and extractor errors. Render-validation JSON also carries a compact gate section for per-document recall plus optional mean recall, page-ratio, aHash, warning, and skipped-file thresholds. Release manifests embed public corpus manifest totals plus public hygiene, render-validation, and benchmark summaries/gates without copying row data, plus the named public-release policy: required public hygiene audit, fmt/clippy/default/no-default/render test gates, and selected optional local thresholds (0.95 POI recall/F1, at least one scored benchmark file, 0.90 render mean recall, 0 extractor errors/skips). Corpus TSV summaries reject empty manifests, duplicate columns or paths, negative numeric counts, and duplicate warning tokens before embedding totals. The same manifest records a compact release_evidence section so consumers can tell whether strict local evidence was enforced, whether existing valid inputs were provided without enforcement, and which strict public-release inputs are still missing; public corpus evidence is incomplete when the manifests are missing, invalid, their document path lists do not match, or their listed documents are absent. The hygiene audit covers normal text files, bounded decoded byte text views from legacy .doc files, and Office package member paths plus textual parts from .docx, .xlsx, and related OPC packages, including internal names, core metadata, relationships, content types, WordprocessingML XML, and embedded Office package XML such as chart workbooks, while leaving binary media payloads opaque. Oversized legacy binary documents block the audit instead of passing uninspected. Add --enforce-policy-inputs when generating a strict public manifest from local evidence: the command then requires a passing public hygiene report, render validation, an rwml.benchmark-report.v1 / extract-vs-mature extraction benchmark, and exactly the public MANIFEST.tsv plus RENDER_MANIFEST.tsv corpus manifests with matching document paths whose listed documents exist, and rejects hygiene, validation, or benchmark reports whose compact gates failed or were generated with weaker thresholds than the named public-release policy. The release workflow intentionally emits the non-strict policy manifest from the packaged .crate artifact, public hygiene report, and public corpus manifests, then uploads the manifest and crate package as workflow artifacts before publishing. The renderer also maps a small common Symbol/Wingdings display subset to Unicode, including the Symbol 0xB7 bullet, before PDF shaping; text extraction and exporters still preserve the source code points.

Still out of scope:

  • Both formats (read/render): metafile images (WMF/EMF/EMZ/WMZ), OLE-embedded objects, and exact floating Office-Art layout (.docx wp:anchor geometry, z-order metadata, enabled wp:simplePos absolute points, wp:effectExtent visual bounds, anchor dist* margins, wrap-element dist* margins, wrap policy, best-effort visible top-level body block page including transparent body content-control, custom XML, smart-tag, single-branch mc:AlternateContent, and accepted/current revision wrappers, omitting deleted and moved-from old-only shape anchors, containing-block anchor text plus zero-width anchor character offsets, DrawingML preset geometry names, simple sRGB solid fill/outline colors, and text-bearing shape body text are exposed through floating_shapes() and rendered as approximate preview overlays, not Word-exact anchored/wrapped Office-Art content; metafile metadata is exposed in diagnostics with bounded header inflation, and a single full-frame embedded DIB wrapped in a metafile is extracted and rendered as a raster image, but general vector metafile payloads are not rendered); unknown or broader fields' computed values (cached result text is kept, including inline tabs, line breaks, and no-break/soft hyphens for simple and common complex body fields; .docx REF/TOC cases listed above plus recovered .doc field instructions are exposed through fields()/diagnostics); complete symbol-font (Symbol/Wingdings) glyph coverage beyond the common deterministic mapped subset; encrypted files (detected and rejected).
  • .doc read only: per-instance list overrides (LFOLVL start-at); Word 6/95. Header, footnote/endnote, annotation, and text-box text appears in text() and dedicated region text APIs backed by DocModel::regions, with DocModel::source_region_kind_text() available for model-level region text. Non-empty annotation regions are exposed through comments() as best-effort recovered comments with source-region anchors, and footnote/endnote regions are exposed through notes() as best-effort recovered note records. A single unambiguous legacy footnote or endnote marker anchors to its containing body text; broader ambiguous note/endnote cases keep source-region anchors. Text-box regions are exposed through text_boxes() as best-effort recovered text-box records with source-region anchors. Header/footer regions are exposed through header_footers() as best-effort recovered records; when legacy PlcfHdd story boundaries are available, rwml splits stories and classifies exact even-page, odd-page, and first-page header/footer variants, otherwise it falls back to Unknown kind. DocSetup mirrors the first recovered default, even-page, and first-page legacy header/footer variants when story indexes are available, and falls back to a default running header for unsplit recovered header/footer text. Exact multi-note/endnote reference markers and exact text-box shape anchors are not yet fully promoted, so non-body regions still remain in the flat block stream; Document::report() emits LegacyDocFlattenedSubdocuments when FIB subdocument counts show that promotion is still incomplete.
  • .docx read only: an original-view DocModel (accepted-current is the only modeled block view; original tracked-change text is exposed via main_text_with_revision_view() and revisions(), comments via comments()); accepted main_text()/DocModel content includes inline and block-level w:ins/w:moveTo current-content wrappers while omitting w:del/w:moveFrom old-content wrappers. Comment anchors plus fields()/floating_shapes() follow that same accepted-current policy, and fields() also uses the single-branch mc:AlternateContent policy so redundant Choice/Fallback field serializations do not duplicate side-table fields; style-inherited emphasis (only direct w:rPr is read, matching the .doc CHPX behavior). Headers/footers, text boxes, footnotes/endnotes, and per-level numbering labels are now extracted; header_footers() exposes .docx referenced header/footer part records with part#type ids and default/even/first-page variants, while DocSetup/SectionSetup model default, first-page, and even-page variants for paragraph section breaks and the final section, including inherited defaults when a later section omits them; notes() exposes .docx footnote/endnote side-table records with Word ids, reference-id anchors, and normalized containing body block text for matched direct or accepted-current wrapped references; text_boxes() exposes .docx accepted-current body/note/header/footer text-box side-table records from w:txbxContent, and unambiguous anchored text boxes include containing body anchor text; text() includes headers/footers, main_text() is body-only; core_properties() exposes supported docProps/core.xml metadata fields including descriptive, package, timestamp, revision, and version values, while report().custom_properties exposes parsed string custom document properties.
  • Write/edit: editing an opened .docx preserves arbitrary OOXML parts verbatim and the writer/edit surfaces are broad (see Write and Edit above). The remaining gaps are broader structural editing (the element-tree edit covers focused text, template/content-control filling, revision acceptance, field/comment/image/note operations, and gridSpan/vMerge-aware cell replacement — not arbitrary block restructuring) and newer extension chart families beyond the current authored set.
  • Render: preview-grade vs LibreOffice (see above); right-to-left scripts; no embedded CJK font is bundled - install a system CJK font or pass one to render_pdf_with_fonts.

Roadmap

The long-term native Word engine roadmap is summarized below.

Current maturity work is concentrated in deeper compatibility rather than new top-level APIs. The remaining native Word-engine work is tracked as bounded R2 sub-buckets: field report/evaluator parity, layout-derived PAGE/PAGEREF, remaining REF/NOTEREF/FTNREF/TOC policy, non-deterministic field families that stay cached/reportable, and legacy .doc anchors/header-footer behavior. Each slice should move only after focused reader/report evidence proves either deterministic computation or precise cached-result diagnostics.

  • Codepage-aware .doc text; encryption / Word 6/95 detection gates
  • Full read model: runs (CHPX incl. font/size/color), headings (STSH), tables (sprmTDefTable), list autonumbers, hyperlinks, inline images
  • Unified .docx reader into the same model (98.6% recall vs python-docx)
  • .docx writer - styled authoring (named styles, rich tables with typed nested cell blocks, page setup, styled runs, leveled lists, paragraph page-break-before, simple fields, PAGEREF helper runs, dirty TOC heading-range fields, run-anchored comments with reply parent ids and commentsExtended metadata, tracked insertion/deletion runs, run-level content controls with data-binding metadata, bookmarked runs, authored footnotes/endnotes, inline/standalone hyperlinks, string custom document properties, raw custom XML data-store items, explicit Word document ids, web-extension task pane package shells, styled default/first/even headers/footers + page numbers, section columns, document grids, text direction, title pages, page-number restarts/formats, next/even/odd section breaks, images with inline rotation and page-relative floating offsets, table width, fixed-layout tables, table alignment, indentation, uniform/per-side border widths, styles, and colors, per-cell table margins, and the core OOXML chart families with embedded workbook-backed data) via DocBuilder, ParagraphBuilder, RunBuilder, CommentBuilder, RevisionBuilder, ContentControlBuilder, TableBuilder, CellBuilder, ImageBuilder, ChartBuilder, DocModel, and write_docx
  • PDF renderer - parley + krilla with rich text/tables/images/lists/ hyperlinks, paragraph page-break-before, header-row repeat, oversized-row split, font registration
  • Reader: .docx headers/footers, text boxes (w:txbxContent incl. run-level mc:AlternateContent) including text_boxes() records, footnotes/endnotes including notes() records, per-level numbering labels, caps
  • Renderer: model-driven page geometry (size/orientation/per-side margins); running headers/footers; nested-table-cell text; common Symbol/Wingdings display mapping
  • Reader: .docx comments with body/note/header/footer anchors, body/note/header/footer tracked-change views and side-table extraction, core document metadata, body/note/header/footer field detection, body/note/header/footer floating-shape geometry and containing-block anchor text capture, trusted body PAGE computation plus FILENAME/MERGEFIELD render support, document-info/date/stat cached-display support, deterministic literal arithmetic formula fields, literal QUOTE, literal IF, literal COMPARE, explicit-default FILLIN/ASK, and literal SET bookmark assignments feeding later plain REF/direct bookmark references plus source-order bookmark-backed IF/COMPARE/NEXTIF/SKIPIF comparisons and ordinary document-bookmark-backed IF/COMPARE/NEXTIF/SKIPIF comparisons, dynamic/control, inserted-content, and mail-merge helper field diagnostics, reference/index field diagnostics, numbering/list field diagnostics, document-structure field diagnostics, display/layout field diagnostics, action/automation field diagnostics, compatibility/private field diagnostics, barcode field diagnostics, legacy form field diagnostics plus deterministic checkbox checked/default states, dropdown result/default selections, explicit non-empty text-input current results, and empty-current text-input default results, unambiguous .docx REF bookmark text computation including Word-generated hidden bookmark targets and multi-paragraph bookmark ranges plus inline tabs, line breaks, and no-break/soft hyphens for simple and common complex body fields plus deterministic REF \* Upper/REF \* Lower/REF \* Caps/REF \* FirstCap text format switches, source-order REF \p relative-position results, explicit numbered-paragraph REF \n labels from single-branch source paragraphs including \n \p relative suffixes and \n \t numeric-text suppression, REF \r relative-context labels including \r \p relative suffixes and \r \t numeric-text suppression, REF \w full-context labels including \w \p relative suffixes and \w \t numeric-text suppression, REF \f note-reference marks for bookmarks around body footnote/endnote references with generated REF note marks counted in source order plus common field-result number/text format switches, text-neutral REF \d "separator" bookmark text while value-changing sequence/page separator cases preserve cached text, direct bookmark-name field computation with supported text-format switches, neutral \h, explicit-number \n, \n \t, \r, \r \t, \w, \w \t, note-reference \f, sequence-separator \d, and source-order \p, bookmarked NOTEREF/legacy FTNREF footnote/endnote reference marks with neutral \h, note-reference-style \f, source-order \p above/below results, and common field-result number/text format switches, bare default TOC, standalone bookmark-scoped default TOC \b, plus explicit TOC \o heading-outline computation, including omitted all-level ranges and common \o/\u combinations, with neutral \h/\z switches, text-preserving \w/\x switches normalized to plain text, text-neutral \n no-page-number, \p entry/page separator, and \d sequence/page separator switches, \s sequence-number page prefixes, deterministic TOC \* Upper/\* Lower/ \* Caps/\* FirstCap field-result format switches, neutral TOC \* MERGEFORMAT/\* MERGEFORMATINET/\* CHARFORMAT, plus quoted or switch-delimited unquoted TOC \t custom-style entries, TOC \f entries from matching TC "Text" markers with optional \f type identifiers, \l levels, and common marker text-format tails, TOC \c full-caption entries and TOC \a label/number-omitted caption-text entries from paragraphs containing matching SEQ Identifier fields, with simple or common complex dirty/stale SEQ caption numbers recomputed from source order, standalone TOC \u explicit paragraph outline-level computation and TOC \b bookmark-scoped computation when the bookmark range is recoverable, including empty computed results for existing scopes with no matching entries, with normalized simple inline heading/caption tabs, line breaks, no-break/soft hyphens, and supported literal symbols for simple and common complex fields, body PAGE trusted current-page computation with page-number and field-result format switches, named PAGEREF classification with leading hard-break, paragraph page-break-before, structural section-start, default next-page section-start, deterministic section page-number restart labels, supported section page-number format styles, source rendered page-break, and trusted rendered-context hard-break target computation, deterministic page-number and field-result format switches, trusted leading-structural, source-marker, hard-break-after-target, and paragraph-end section-break \p relative-position computation, plus cached page-reference result preservation for remaining layout-dependent cases, cached field result preservation for inline tabs, line breaks, and no-break/soft hyphens in simple and common complex body fields, .docx running header/footer default selection/inheritance, first/even-page variant modeling and authoring, plus section-aware first/even-page render selection, and Symbol/Wingdings glyph mapping
  • Reader R2-a: field report/evaluator parity for value-changing fields where duplicated syntax checks or document-report/render-report diagnostics can drift from computed-result behavior. Verified parity coverage now locks PAGEREF, REF, NOTEREF/FTNREF, and TOC computed/gap buckets across opened-document and render-model reports, and empty unsupported simple/complex field instructions plus supported hidden RD/TA/XE marker fields stay reportable in model/render inventories; the remaining R2-a work is limited to newly proven parser/evaluator/report drift or exact duplicated syntax logic.
  • Reader R2-b: layout-derived PAGE/PAGEREF current-page, page-number, and relative-position computation beyond trusted leading/source-rendered, section-start, paragraph-end section-break target, source-marker, and hard-break contexts
  • Reader R2-c: deterministic value-changing REF (incl. \# numeric picture and \!), NOTEREF/FTNREF (incl. settings.xml numStart/numFmt and customMarkFollows), and TOC heading-source NOTEREF/SEQ resolution; the remaining REF/NOTEREF/TOC cases are layout- or Word-behavior-dependent and stay cached-with-reason
  • Reader R2-d: non-deterministic data-, source-, layout-, action-, generated-, barcode-, compatibility-, and protected-form field families that preserve cached text and stay reportable until deterministic semantics are proven
  • Reader R2-e: exact legacy .doc note/text-box/body anchors and richer legacy multi-section header/footer application semantics beyond recovered global default/first/even running stories
  • Package-preserving edit layerDocument::open→edit→save keeps every unmodeled part verbatim; the element-tree edit methods (text/field/comment/ note/image/content-control/revision/core-property, listed under Edit) preserve fields/shapes/ content-controls/comments/revisions; edited_parts exposes touched package parts; edit_capability / report().edit expose read-only reasons; opc + xmltree internals; fallible try_write_docx
  • Renderer: exact pagination, floating-shape page anchoring/wrap reflow, full layout-derived PAGE/PAGEREF values beyond trusted source markers, remaining render-time TOC/REF/NOTEREF policy where layout context is required, bundled-font feature, and RTL
  • Authoring API, native PDF preview rendering, and embedded workbook-backed data for the core OOXML chart families
  • Wireframe styling for authored surface and 3-D surface charts
  • Shape styling for authored 3-D bar and 3-D column-family charts
  • Metafile diagnostics for WMF/EMF/EMZ/WMZ path, format, byte size, compression flag, and raw/gzip-wrapped header dimensions
  • Chart-ex extension chart families (waterfall, treemap, sunburst, histogram, box & whisker, funnel) authored as chartEx parts
  • Single-DIB-wrapper metafile (WMF/EMF) raster extraction rendered as images
  • Full vector metafile (WMF/EMF) rendering beyond single-DIB raster extraction and bounded header diagnostics

Contributing

See CONTRIBUTING.md. The full local gate is python3 scripts/public_hygiene_audit.py, cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings, cargo clippy --all-targets --all-features -- -D warnings, cargo test --all-targets, cargo test --no-default-features, cargo test --all-targets --features render, cargo test --doc --all-features, and cargo doc --no-deps --all-features.

License

Licensed under the MIT License. Third-party dependency licenses are listed in THIRD_PARTY_LICENSES.md. This crate implements only the publicly documented MS-DOC / MS-CFB / OOXML specifications and contains no Microsoft source.

Trademarks

rwml — from WordprocessingML, the ECMA-376 markup for word-processing documents — is an independent open-source project, not affiliated with, authorized by, or endorsed by Microsoft. Microsoft, Microsoft Word, and the .doc / .docx file formats are trademarks or registered trademarks of Microsoft Corporation, referenced here only descriptively to indicate file-format compatibility. The crate is built solely from the publicly documented MS-DOC / MS-CFB / OOXML specifications and contains no Microsoft source code.