# AGENTS.md
Instructions for AI coding agents working in this repository. `AGENTS.md` is the
tool-neutral filename, so every agent reads the same source of truth — `CLAUDE.md`
is a pointer to this file, not a second copy. Add guidance here, never there.
## Project
**dxpdf** — a fast DOCX-to-PDF converter in Rust, powered by Skia. Four interfaces: CLI tool, Rust library, Python package (via PyO3/maturin), and Go bindings (via a C ABI and cgo).
## Build & Test Commands
```bash
cargo build # Debug build
cargo build --release # Release build
cargo test --all # Run all tests
cargo test <test_name> # Run a single test by name
cargo bench # Run Criterion benchmarks
cargo clippy --all-targets -- -D warnings # Lint (CI enforces zero warnings)
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps # Doc links (CI enforces zero warnings)
cargo fmt --all -- --check # Format check
cargo fmt --all # Auto-format
```
System dependencies (Linux): `libfontconfig1-dev`, `libfreetype-dev`. Requires `clang` for Skia. Toolchain is pinned to 1.95.0 via `rust-toolchain.toml`.
**Cargo features**: `subset-fonts` (default, via `fontcull`) runs the font-subsetting pass; `python` gates the PyO3 bindings; `capi` gates the Go bindings' C ABI (`src/capi.rs`). Build with `--no-default-features` to skip subsetting (the pass is `#[cfg(feature = "subset-fonts")]`-gated in `render_with_font_mgr`).
Benchmarking: `cargo bench` for Criterion benchmarks (`benches/convert_bench.rs`, `benches/parse_bench.rs`). `RUST_LOG=debug` for per-phase timing from CLI; `RUST_LOG=warn` surfaces unsupported-feature warnings logged by parse/layout.
CLI usage: `cargo run -- input.docx [-o output.pdf] [--image-dpi 300]` (release binary: `dxpdf`). `--image-dpi` sets the resolution embedded raster images are downsampled to — default 220 (matching Word), valid range 1–2400.
Python bindings: `maturin develop --features python` builds and installs into the active venv. The `python` feature is gated in `Cargo.toml`.
Go bindings (`go/`): a nested Go module (`module github.com/nerdy-pro/dxpdf/go`) that reaches the engine via cgo against `src/capi.rs`'s C ABI, statically linked — `crate-type` includes `staticlib` for exactly this. Build the library and generate the header with `cargo build --release --features capi` and `scripts/generate_capi_header.sh` (needs `cargo install cbindgen`; `tests/capi_header.rs` catches the header going stale). `go/internal/capi/lib/<os>_<arch>/libdxpdf.a` is **committed**, one per supported platform, so `go get github.com/nerdy-pro/dxpdf/go` needs no separate fetch step — the tradeoff, and what keeps them from drifting from the Rust source silently, are both in `go/internal/capi/lib/README.md`; `tests/capi_lib_freshness.rs` is the staleness guard (a source-hash check, since nothing here can cross-compile all four platforms to verify them directly). Not Git LFS: `go get`'s module fetch reads raw git blobs the way the module proxy builds its zip, not through an LFS-aware checkout, so an LFS-tracked file resolves to a useless pointer stub unless the fetching machine happens to have `git-lfs` installed — confirmed broken against golang/go's own issue tracker, not assumed. linux/amd64 and linux/arm64's unsplit archive (~120-127 MB) is over GitHub's 100 MB single-file limit regardless — neither stripping (~3-7%) nor LTO (which made it *larger*: embedded LLVM bitcode a foreign linker like cgo's can never exploit) closes that gap, so `scripts/split_capi_lib.py` splits the **archive**, not the source, into `libdxpdf_part1.a`/`part2`/... under the limit, linked inside one `-Wl,--start-group`/`--end-group` in those two platforms' `go/cgo_linux_*.go` (required, not decorative — GNU ld resolves each archive in one left-to-right pass by default, and splitting one archive scatters mutually-referencing objects across the pieces). The total across all four platforms (~420 MB) is comfortably under the Go module proxy's own 500 MB module-zip cap. `tests/capi_lib_freshness.rs` accepts either a whole `libdxpdf.a` or a complete `libdxpdf_partN.a` sequence per platform, never both. Supported today: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64. **Not yet Windows** — static-linking a Skia-containing archive through cgo on MSVC is a combination this repo has not exercised anywhere else (the existing Windows Python wheel links dynamically), so it is deferred rather than guessed at; picking it up means building and committing a `windows_amd64` library and adding a matching `go/cgo_windows_amd64.go` once someone can iterate against a real MSVC link. The `#cgo LDFLAGS` system-library list and the committed library itself are verified by an actual link + `go test`/`-race` run on darwin/arm64 (native), linux/arm64 (a native-arm64 `rust:1.95-bookworm` container) and linux/amd64 (the same container under QEMU emulation); darwin/amd64 is cross-compiled from darwin/arm64 and symbol-checked (`nm` shows all five `dxpdf_*` C entry points defined) but not itself executed — see the per-platform `cgo_*.go` files' own comments for which is which.
`vendor/quick-xml-0.41.0/` is a vendored, patched fork of `quick-xml`, wired in via `[patch.crates-io]` in the root `Cargo.toml` — see that directory's `PATCH.md` for why (two independent fixes: a same-local-name collision across two different XML namespaces, `w:shadow` vs. Word-2010's `w14:shadow`, that stock quick-xml's serde support cannot distinguish; and `xml:space="preserve"` being silently ignored when scanning for a struct's next field, which used to lose whitespace-only text runs — this replaced `src/docx/whitespace_workaround.rs`, now deleted) and its real limit (`cargo publish` strips `[patch]` sections, so `dxpdf` consumed as a Rust library from crates.io does not get either fix — accepted, not missed).
Note: `Cargo.toml` excludes dev-only paths — `test-files/`, `scripts/`, `output/`, `vendor/` — from the published crate. Any local-only scratch corpora are excluded there too, so nothing local can be published by accident.
## Architecture
The converter follows a **parse → resolve → layout → (subset) → paint** pipeline, orchestrated in `src/lib.rs::convert()` (parse + render) and `src/render/mod.rs::render_with_font_mgr()` (resolve → layout → subset → paint).
1. **Parse** (`src/docx/`) — Declarative XML parsing of DOCX (ZIP of XML) via serde schemas on `quick_xml::de`. `zip.rs` handles ZIP extraction; `relationships.rs` parses rels. `parse/primitives/` holds shared schema atoms (unit wrappers, `HexColor`, `OnOff`, `ST_*` enum catalog). `parse/properties/` holds `PPr`/`RPr`/`TblPr`/`SectPr` schemas shared across body, styles, and numbering. Part-specific schemas live under `body.rs`+`body_schema.rs`, `drawing/` (DrawingML — `anchor`, `inline`, `picture`, `shape`, `fill`, `stroke`, `geometry`), `styles.rs`, `numbering.rs`, `theme/`, `notes.rs`, `settings.rs`, `vml/`. Each schema type is `pub(crate)` and suffixed `Xml`; `From<XxxXml> for ModelType` is the XML→domain seam. Outputs an immutable `Document` model.
2. **Model** (`src/model/`) — Pure data types with no parsing logic. `types/` contains the ADT: `Document` → `Vec<Block>` (`Paragraph | Table | SectionBreak`) → `Vec<Inline>`. `Inline` has 17 variants — text and drawing (`TextRun`, `Image`, `Pict`, `Symbol`, `AlternateContent`), fields (`Field`, `FieldChar`, `InstrText`), notes (`FootnoteRef`, `EndnoteRef`, `FootnoteRefMark`, `EndnoteRefMark`, `Separator`, `ContinuationSeparator`), and navigation (`Hyperlink`, `BookmarkStart`, `BookmarkEnd`). `dimension.rs` and `geometry.rs` provide the type-safe unit system. `src/field/` contains the OOXML field instruction parser (PAGE, TOC, HYPERLINK, etc.).
3. **Resolve** (`src/render/resolve/`) — Flattens style inheritance, splits sections, extracts font families, pre-loads images, resolves conditional formatting and colors. `shape_geometry/` generates DrawingML preset/custom shape paths (guide-formula evaluation under `guides.rs`). Produces a `ResolvedDocument` with fully-resolved styles and sections.
4. **Layout** (`src/render/layout/`) — Measures text with Skia font metrics (`measurer.rs`) and fits content into pages. `build/` orchestrates the constraint cascade: page → section → table → cell → paragraph (`block.rs`, `table.rs`, `floating.rs`, `convert.rs`, `list_label.rs`). `fragment/` breaks inline content into measurable units for line fitting, using the `unicode-*` crates for grapheme and script handling; emoji clusters are shaped GSUB-aware through Skia's own HarfBuzz (`render/emoji/shape.rs`). Three passes then run over the **finished** fragment vector, in a fixed order that `build/block.rs::resolve_paragraph_bidi` owns: `bidi.rs` (UAX #9 levels, splitting at level boundaries) → `fallback.rs` (issue #139 per-glyph font fallback, splitting at coverage boundaries) → `shape.rs` (which runs last because it re-measures against the resolved typeface, so it must see the family fallback may have changed). `paragraph/` handles line emission and paragraph borders. `table/` handles 3-pass table layout (`measure.rs` → `grid.rs` → `emit.rs`, with `split.rs` for row splitting across pages). `borders.rs` is §17.4.66, and it answers **two** questions from one pass of declarations: how much of each edge is charged to each cell, which insets its content box (`resolve_table_cell_borders`), and what line stands on each line of the table's grid, which is what reaches the page (`plan_table_borders` → `BorderPlan`). A collapsed border sits on an edge two cells *share*, so it belongs to neither — the plan is indexed by grid line and has no notion of an owning cell, and `rasterize_border_grid` paints it as junction squares plus the segments between them, every rect disjoint from every other by construction. A junction is the **product** of its two axes' §17.18.2 rules and goes to the **heavier** line, the horizontal breaking a tie — measured off a Word render rather than reasoned from the spec, which settles neither (`junction_axes`). §17.18.2's `w:sz` is the width of **one rule**, so a `double` is three times as wide as a `single` of the same `w:sz` — which is also why §17.4.66 weighs it threefold, one fact rather than two (`drawn_width`). A §17.4.45-spaced table has no shared edges and takes the other constructor: `emit_cell_frame` per cell, plus `emit_table_outline` for the rectangle its cells no longer reach. `section/` stacks blocks into pages: `stacker.rs` is the shared vertical-flow core used by *both* page and table-cell layout, while `layout.rs` owns the page-level algorithm (`layout_section`, keepNext chains, paragraph splitting, columns, footnotes). `float.rs` handles text wrapping around floating images. `header_footer.rs` renders headers/footers in a second pass (after total page count is known). Outputs `Vec<LayoutedPage>` of positioned `DrawCommand`s.
5. **Subset** (`src/render/subset/`, default `subset-fonts` feature) — Between layout and paint: `collect.rs` walks draw commands recording **codepoint** usage per resolved typeface (keyed by `TypefaceId`, so substituted and direct requests for the same face merge), `apply.rs` subsets each typeface via `fontcull`, splices the original `name` table back in, validates that every kept codepoint still shapes to a non-`.notdef` glyph, and swaps the bytes into the `FontRegistry`. Every failure mode is an explicit `SubsetOutcome` variant; a typeface that can't be subsetted keeps its original bytes.
6. **Paint** (`src/render/painter.rs`) — Iterates draw commands and emits PDF bytes via `skia_safe::pdf`. This is the only f32/Skia boundary. `skia_conv.rs` handles Pt-to-Skia conversions. `emf.rs` handles EMF (Enhanced Metafile) image rendering. `emoji/` is a separate color-emoji pipeline (UAX #29 / UTS #51 cluster classification in `cluster.rs`, host-OS color-typeface resolution in `resolve.rs`, GSUB shaping via Skia's HarfBuzz in `shape.rs`, Skia raster rasterization with a per-render cache in `raster.rs`).
### Key Design Patterns
- **Type-safe dimensions** (`src/model/dimension.rs`): Generic `Dimension<U>` parameterized by a unit marker (grep `impl Unit` for the full list — `Twips`, `Emu`, `HalfPoints`, `Pt`, etc.). `i64` storage for lossless OOXML round-tripping; `Pt` is the `f32` rendering unit. Prevents accidental unit mixing at compile time.
- **Generic geometry** (`src/model/geometry.rs`): `Offset<U>`, `Size<U>`, `Rect<U>`, `EdgeInsets<U>` parameterized over dimension units.
- **Spec-faithful ADT modeling**: All parsed values use typed enums/structs per OOXML spec sections. No raw strings for enumerated attributes — each gets a Rust enum. Typed identifiers (`RelId`, `StyleId`, `VmlShapeId`, `BookmarkId`) prevent mixing. Catch-all branches log warnings for unparsed elements; invalid enum values produce parse errors.
- **Two-pass rendering**: Layout runs first to determine total page count, then headers/footers are rendered in a second pass so PAGE/NUMPAGES fields resolve correctly.
- **Font resolution** (`src/render/fonts/`): A request is a name plus two **tri-state** §17.7.2 toggles (`Toggle::{Absent, Off, On}`), not a name plus a `FontStyle` — `Absent` asks for no weight, which is what lets a face name keep its own. `catalog.rs` turns the host font system and the DOCX's embedded fonts into one list of `FaceRecord`s, reading each face's own `name`/`OS/2`/`fvar`/`STAT` through the hand-written readers in `opentype/` (one table at a time via `copy_table_data`, never `to_font_data`). `resolve.rs` is a **pure function** over a request and a catalogue, running eight steps in order: embedded face, embedded family, host family, host face name, other metadata alias, parsed family+weight-word, metric-compatible substitute, host default. Everything down to step 5 is evidence the font asserts about itself; step 6 is the first guess. Ambiguous names are reported, not guessed. `resolve_exact`/`resolve_system_only` are the narrow variants the emoji pipeline needs. Because a request cannot know its own text, coverage is a *separate* question answered after layout has fragments to ask about — `layout/fragment/fallback.rs`, issue #139. It carries its answer as a **family name**, because `DrawCommand::Text` holds a name and both the painter and `subset::collect` re-resolve from it; `pin_system_face` is what makes that name authoritative, since a host's last-resort face (macOS `.LastResort`) is not reachable by name at all. `FontRegistry` is the single source of truth for typeface bytes and is owned **per render** — the subset pass mutates it in place after layout, so a process-wide (`thread_local!`) typeface cache would leak subsetted faces across documents and must not be reintroduced; the same rule binds the catalogue.
- **Text shaping & emoji**: Grapheme and script handling uses `unicode-segmentation`/`unicode-properties`/`unicode-normalization`; emoji clusters are shaped through **Skia's HarfBuzz** (`skia-safe`'s `textlayout` feature) driven by a `Typeface`, never by extracted font bytes — `Typeface::to_font_data()` on a 183 MB emoji font costs ~549 MB of unreleasable RSS, which is why the pure-Rust shaper it replaced is gone; color emoji is handled by the dedicated `render/emoji/` pipeline via typed ADTs (no font-name allowlists, no bundled emoji fonts — it resolves the host OS color-emoji typeface at render time).
## OOXML Reference
**There is no reference directory.** `docs/` was removed deliberately, page by page: a prose page describing behaviour drifts from the behaviour, and the second copy is the one that goes stale. WHY the engine makes a choice — which is generally not re-derivable from the source — belongs in the **module doc or the comment at the site that makes the choice**, where it is next to the thing it explains and moves when that moves. The "No doc yet" list below is now simply the entry-point list.
Working notes — designs, profiling analyses, branch reviews — are kept **local and uncommitted** (`/plans/`, gitignored), because they describe a point in time rather than current behaviour. Nothing tracked may link to them, and no code comment may cite them: a fresh clone does not have them. That cuts both ways, and it is the rule most easily broken by accident: **anything in `/plans/` that must outlive the work has to be moved out before the file is deleted** — into the code it describes, into this file, or into a GitHub issue. A note that exists only there is one `rm -rf` from gone, and nothing will warn you.
### Known-unimplemented work
Open engineering units are tracked as GitHub issues, not here — this file goes stale the moment one closes. Everything that is *not* a tracked unit is recorded where it applies: each ambiguity ECMA-376 cannot settle is stated in a comment at the site that makes the choice, saying what the choice is, why the spec does not decide it, and what evidence would. Grep for "Word reference render" to find them. Where a capability boundary is deliberate, the code says so at the boundary rather than deferring to the tracker — `SubsetOutcome::VariableInstanceNotBaked` states why a variable instance cannot be baked into embedded PDF bytes and names its two candidate routes; `register_embedded` states which faces of an embedded collection a given platform will open; `src/render/fonts/request.rs` states why `Toggle::Off` and `Toggle::Absent` select the same face today. One larger question is a decision rather than a gap: whether to take on a CLDR/ICU dependency for the i18n gaps tracked in issue #124.
### Decided — do not redo
Work deliberately *not* done. It is here rather than at a site because there is no site: no code was written, so a comment has nowhere to live. Reopen any of it with evidence, not with reasoning — each was closed against a measurement.
**Rejected optimisations.** Layout is 1–3.5 ms on 25 of 33 corpus documents, and that is the budget every one of these competed for: font-family interning · `Vec::with_capacity` seeding in the hot builders · `format!` per footnote number (which would also have cost an `itoa` dependency) · the per-run `RunProperties` clone. Reopen only with a profile showing layout is the bottleneck for the workload in question. `PTabLeader`'s pass-through enum is a separate "no": a distinct per-spec-type enum is what the spec-faithful ADT convention prescribes, so the extra layer is intentional — unlike `PTabAlignment`, which earns its enum by driving distinct layout math.
**Investigated and rejected.** Sharing `PackageContents` parts to remove the last package→media image copy: a parse-only probe peaks at 135 MB on the largest corpus document against 217 MB for the full render, so that duplicate never sets the peak. The keepNext double-layout and the floating-table double-measure: both measured in microseconds, and not worth the pagination risk.
**Inherited § citations are suspect until checked.** The retired findings cited `a:bodyPr/@vertOverflow` as §20.1.10.85, but [`drawing.rs`](src/model/types/drawing.rs) already annotates §20.1.10.85 as `ST_TextWrappingType` — the `wrap` attribute. Both cannot be right, and the conflict was resolved by *not* citing the disputed number: the code names the attribute (`a:bodyPr/@vertOverflow`, `ST_TextVertOverflowType`), which is unambiguous. Confirm any § against the spec before adding it, and never inherit one from a document.
**Worth knowing.** The `textlayout` feature costs binary size — the release binary is 15.9 → 28.2 MB (+77%) for ICU plus SkShaper/HarfBuzz. The same Skia build independently improved PDF font embedding: corpus output fell 28.98 → 27.22 MB (−6.1%), one document by −88%. That is Skia's PDF backend, not this engine's subset pass; don't attribute it here.
**When a fix is written from the symptom rather than the spec, it tends to be wrong.** The last attempt to do so prescribed *clipping text Word draws* (`@vertOverflow`). Three times a written plan was itself wrong — the MCE ADT, the `bar` tab's semantics, and a locale unit's premise that its ambiguity blocked implementation — and writing the tests from the spec first is what exposed it each time. Treat any plan, including one in this file, as a starting point to verify rather than a specification to implement.
**No doc yet** — start from the module docs at these entry points: character spacing and distributed alignment (`src/render/spacing.rs` — §17.3.2.35 and §17.3.1.13 share one unit, the UAX #29 grapheme cluster; the module doc says why it is that and not a shaped cluster), color-emoji pipeline (`src/render/emoji/mod.rs`), parse/serde schemas (`src/docx/parse/`, the `XxxXml` → domain seam), text shaping & fragments (`src/render/layout/fragment/`), per-glyph font fallback (`src/render/layout/fragment/fallback.rs` — why the fallback is carried as a name and not a resolved face, and why the early-out is load-bearing), paint & PDF emission (`src/render/painter.rs`), EMF images (`src/render/emf.rs`), numbering & list labels (`src/docx/parse/numbering.rs`, `src/render/layout/build/list_label.rs`), VML fallback (`src/docx/parse/vml/`).
## Test Organization
- **Unit tests**: `#[cfg(test)]` modules within source files.
- **Integration tests** (`tests/`): `integration.rs` (in-memory DOCX build + parse), `parse_test_files.rs` (parse real DOCX files from `test-files/`), `render_integration.rs` (layout + rendering validation), `emoji_e2e.rs` (color-emoji pipeline end-to-end), `header_footer_selection.rs` and `header_part_rels.rs` (header/footer resolution), `serde_spike.rs` (mixed-content parsing), `table_border_conflict.rs` (§17.4.66 nil-vs-none, conflict resolution), `table_row_height.rs` (§17.4.80/§17.4.84 row heights plus every malformed `w:vMerge` shape whose cell content used to vanish — a `restart` with nothing continuing it, a `continue` with no `restart` above it, and the two that only a **spanning** cell can express: a `continue` beside the column a `gridSpan` restart anchors on, and one below a `continue` wider than its own restart. Written with the bare `<w:vMerge/>` spelling, which is the only one the corpus contains), `table_auto_width.rs` (§17.4.63/§17.4.52 — how wide a `w:type="auto"` table may get; the guard is drawn at the paper edge and the file records why the obvious clamp to the text column is refuted by 40 tables of real Word output in the corpus), `table_style_whole_table.rs` (§17.7.6 `wholeTable` cascade), `table_style_cascade.rs` (§17.7.2/§17.7.4.3/§17.7.4.17 — what a table *style* declares reaching the table, asserted as parity against the same property written directly on the `<w:tbl>`; and, for the six `CT_TblPrBase` elements [MS-OI29500] §2.1.250(a)/§2.1.249(a) say Word does not read from a style, the *inverse* parity — declaring one there changes nothing while the same element on the `<w:tbl>` still applies. The band sizes are the case that runs the other way, §2.1.164(a), and `build_table` holds the whole split), `table_conditional_grid.rs` (§17.7.6/§17.4.16 — conditional regions follow the **grid column**, not the cell's index in its row, which `w:gridSpan`/`w:gridBefore` separate; the oracle is Word's own `w:cnfStyle` in `sample-docx-files-sample1.docx`, asserted against the fixture by `render::resolve::conditional`'s unit tests), `table_grid_seating.rs` (§17.4.48/§17.4.71 — the grid must have a column for every cell, and what `seat_every_cell` does when it does not. Both halves are pinned: a grid that *can* seat every cell is scaled and otherwise untouched, which is all 398 corpus tables and so the trap-detector for the repair's gate; and a grid that cannot grows rather than laying the overrun cell out at zero width. The deliberate non-repairs are pinned too — a row *shorter* than its grid, and a `w:gridAfter` overrunning it, both of which lose no content and are left alone), `table_geometry_sizing.rs` (§17.4.63/§17.4.48/§17.4.80 — the width `w:tblW` resolves to *on the page*: a `dxa` width scales the declared grid rather than truncating it, a `pct` one is that fraction of the width offered, and the full-width cell-margin extension applies to a left-aligned table and not to a centred one, which is the whole of `extends_for_alignment` and is asserted as the 10.8 pt between the same table both ways. Plus `w:trHeight` from the section layer: `exact` is a height whatever the row holds — the overflow it does not clip is left unasserted, being an open question — and `atLeast` is bracketed by two equalities, indistinguishable from `exact` under its minimum and from no rule at all over it), `table_geometry_paint.rs` (§17.18.2/§17.4.83 — the same geometry once it reaches the page rather than a `TableSlice`: a `w:val="double"` edge arrives as two lines of `w:sz / 3`, that far apart, asserted against the same table drawn `single` so the band is a *relation* between the styles and not four literals — and, incidentally, so a failure is about the style rather than about `w:sz`'s eighths of a point; and `w:vAlign` places content 0, half and all of a row's spare height, measured across two `hRule="exact"` rows of different heights holding identical content, so the content height cancels and no glyph metric has to be known. A row that *is* its content is the control: every other assertion is a difference, which a renderer that always bottom-aligned would satisfy), `table_border_corners.rs` (§17.4.38/§17.4.66 — the structural invariants of a table's border network, which neither spec settles and which three separate reports have found broken. Asserts *properties* rather than any one shape of them, over every page of every committed fixture: **no junction square is painted by nobody**, **no two border rects overlap**, and **no line is broken by a gap narrower than itself**. It knows nothing about which cell — or which table — a rect came from, which is the point and also the one limit: the junction and gap audits have twins over the untracked `test-cases/` corpus, but the overlap audit does not, because a nested table flush with its parent's edge draws that edge and so does the parent, and from a command stream that is indistinguishable from a junction overrunning a segment inside one table. One overlap is allowed and named: two parallel lines closer together than they are thick, which a `hRule="exact"` row shorter than its own borders or an empty `<w:tr/>` writes, and which no decomposition can separate. A fourth test runs the junction audit over the third reporter's own document, and skips when it is absent), `table_border_junctions.rs` (§17.4.66/§17.18.2 — the square where a vertical border crosses a horizontal one, which neither spec settles and which `test-files/border-junction-colour.docx` was authored to ask. Word's render answers three ways: the **heavier** line takes the crossing, the **horizontal** breaks a tie (so colour never decides), and two `double`s cross as a 2 × 2 lattice with both gaps running through. Asserted as relations between two renders of one document — a table against the same table with its two colours exchanged, and a `double` table against the same table drawn `single` — so no page origin or coordinate is pinned; the crossing points are recovered from the render as the product of the tall rects' x-bands and the wide ones' y-bands. The lattice case reads all nine thirds of a crossing, because the four edge midpoints are what separate a lattice from the two other shapes that also leave a hole in the middle), `table_cell_content_box.rs` (§17.4.39/§17.4.66 against §17.4.41/§17.4.42 — where a cell's content box begins, and that the row is measured from *that* box. A border is drawn inside the cell, so the inset is `max(border, margin)` per side; the reported defect was that only the two horizontal sides were charged to the measurement while all four were charged to the placement, so a row whose top border outweighed its top cell margin overflowed its own box by the difference and its last line was painted through the bottom border. Every assertion is a difference between two renders of one document, so no glyph metric is pinned: `w:sz` 4→48 is 5.5pt, and that is the only literal. The controls are what keep the rule honest — a margin as thick as the border absorbs it, a bottom border grows the box without moving the content, and a bottom-aligned cell is lifted clear of it), `table_shading_seams.rs` (§17.4.33 — the pale hairline two abutting same-colour fills leave under a rasterizer that anti-aliases each one separately. Not a spec question: the ideal geometry of the pair is that of the single rect covering both, and only the raster differs. The audit is over the command stream — no two **consecutive** rects of one colour share an edge — because such a pair is always safe to fuse, and `coalesce_abutting_rects` fuses it. The file records what that leaves open, measured: §17.3.2.32 run shading interleaves fill/text/fill/text, so its pairs are not adjacent and settling them needs horizontal bounds on `DrawCommand`), `table_leading_margin.rs` (§17.4.28/§17.4.50 — which margin a table measures from, which is stated logically (`start`/`end`, "Table Indent from **Leading** Margin") and so needs a direction. Two elements give one: §17.4.1 `w:bidiVisual` on the table and §17.6.6 `w:bidi` on the section, the table's winning where present. The load-bearing case is `jc="left"` right-aligning a table, since Transitional `left` is Strict `start` — the same reading `paragraph::line_emit::align_offset` already applies to lines; `center` is the control that must not move. The `bidiVisual` half was got wrong first time — the element was read as scoping to cell order — and `test-files/bidi-visual-table.docx` in Word is what refutes that, since both its tables have RTL cell paragraphs and only the one with the element moves. One case has no render behind it and says so: `w:bidiVisual w:val="0"` inside a `w:bidi` section), `table_bidi_visual.rs` (§17.4.1 — a table whose columns run right to left. Every assertion is a *reflection* relation between the same table with and without the element, measured as a distance from **each table's own** edges rather than a shared span — the two do not share one, since the element also moves the table to the right margin (`table_leading_margin.rs`), and reflecting about a shared span is what pinned that defect in place. So no page origin or glyph metric is pinned; the declared grid is deliberately unequal, because three equal columns cannot tell a correct mirror from one that reverses the cells and leaves the slot widths where they were. `gridSpan`, `gridBefore`, `vMerge` and the logical start border each get their own case, and one test pins the *ordering* the design rests on — §17.7.6 `firstColumn` still means the logical first column, because conditional formatting is resolved before `mirror_columns` runs), `table_bidi_visual_row.rs` (§17.4.60 `w:tblPrEx/w:bidiVisual` — a *single row* flipped against an otherwise left-to-right table, over `test-files/issue-157-tblprex-bidi.docx`. Where `table_bidi_visual.rs` reflects a whole table about its own edges, a lone flipped row cannot share that grid with its neighbours at all — `RowBidiOverride` is its own design, not a scoped version of `mirror_columns` — so every assertion here is instead against the fixture's fixed page geometry directly: cell `A` keeps its own 50pt rather than the 150pt slot it visually lands in, its row's own right edge reaches the *page's* content width rather than the table's, and — measured, not something the fixture's own heading asked about — the flipped row paints *after* the row that would otherwise follow it. Rows are told apart by y-band rather than by fill colour, since `A`/`B`/`C` repeat down all three rows and only position separates them here), `table_grid_gap_borders.rs` (§17.4.36 against §17.4.15/§17.4.14 — what is painted on the vertical edge where a row's cells stop short of the grid, which `gridBefore`/`gridAfter` create. §17.4.36 gives `insideV` to "interior vertical edges" and never defines *interior*, and the answer turned out to be about the row's **cells** rather than the grid: a row's first `<w:tc>` takes the table's `w:left` and its last takes `w:right`, wherever across the grid those edges fall, because §17.4.66 resolves an edge against "cell borders and outer table borders" and a gapped row's first cell has no cell facing it. **Measured, not reasoned** — Word renders the fixture with 3pt red on the leading edge of `D`, `F`, `G` and 3pt green on the trailing edge of `E`, `F`. Two other readings were held here first and both were wrong, which is why the file records them: `insideV` (grid columns exist to the left) was refuted by `bidi-visual-table.docx`, whose `nil` outer border leaves the edge bare; *nothing at all* (§17.4.35 places `w:left` "around the table", and this edge is 50pt inside it) was argued from the spec's wording, fitted every measurement then available, and was refuted by this render. The `nil` table is kept as the control that holds one rule to both documents. Also covers the horizontal twin: rows 2 and 3 gap at *opposite* ends, and §17.4.66 gives a shared edge to one row while a cell paints one border across its whole width, so a run could resolve to a line and have no owner — cell `E`'s top was painted only over the part `D` covered. That run had no owner because ownership was per *row*; it has none to fall between now that `BorderPlan` indexes the boundary per grid column. Also pins the placement rule the grid rasterizer rests on: a collapsed border **straddles the line it stands on**, with no exception for the table's own edges, so a 1pt `insideV` and a 3pt `w:left` meeting on one line come out concentric on it and the 3pt `w:left` on the outer line renders 69..72 rather than the 72..75 this entry once claimed. The exception was argued from the spec — nothing shares an outer line, so there is nothing to straddle — and `border-outer-box.docx` refuted it in Word. What keeps the first cell's text on the indent is not the border being drawn inside the box but `build_table` shifting the whole table half a leading border left. The **horizontal** outer edges are a separate question and still go inside, which the file's row-boundary test pins and `borders.rs` marks as awaiting its own render), `table_cell_spacing.rs` (§17.4.45/§17.4.43 — how wide a gap `w:tblCellSpacing` opens and which level declares it, measured off a Word render of `issue-165-cellspacing-scale.docx`. The declared value is a **half**-gap: every gap is twice it, the same at the table's own edge as between two cells, which is the reading no earlier fixture could take. A row-level value supersedes the table's and governs its edge inset too. Both refuted a written finding — the factor had been argued away from ONLYOFFICE's renderer, with Word's doubling put down to it summing two declarations, and two of these tables declare the spacing at table level only. Geometry is read as **boxes, not ink**, because 1pt borders are 2.9px on the render that settled it; and the tables are found by their border bands rather than their cell text, since at 13.3pt the labels wrap to one glyph per line — in Word too, which is what makes the fourth table's measurement unambiguous), `floating_table_pagination.rs` (§17.4.57 anchor/spillover termination), `font_resolution.rs` (§17.8 face resolution against the committed fixture fonts), `font_fallback.rs` (issue #139 per-glyph fallback — asserted structurally, never by face name, since which face covers a script is a property of the host).
- **Test helpers**: `make_docx()` and `simple_docx()` in `tests/integration.rs` build minimal in-memory DOCX archives.
- **Visual diffing**: `scripts/compare_pdfs.py` diffs generated PDFs against references. `scripts/verify_wheel.py` checks that FreeType is embedded in built wheels (run by the CI wheel job). `scripts/make_font_fixtures.py` rebuilds the font fixtures under `test-files/fonts/` (needs `fonttools`). **`scripts/verify_docx.py` checks that a `.docx` is a sound OPC package** — run it on any hand-built fixture before committing. This engine's parser is deliberately tolerant and will happily read a package Word refuses to open: three `issue-165-*` fixtures were rejected by Word with "unreadable content" while dxpdf, `textutil` and the whole test suite saw nothing wrong, over a single `.rels` part declaring the relationship-*type* URI (`.../officeDocument/2006/relationships`) as its `xmlns` instead of the relationship-*part* one (`.../package/2006/relationships`). `scripts/make_font_fallback_fixture.py` rebuilds `test-files/issue-139-minimal.docx`, `scripts/make_issue165_fixtures.py` the three issue #165 probes, `scripts/make_hidden_text_fixture.py` the `w:vanish` fixture, and `scripts/make_bidi_visual_fixture.py` the `w:bidiVisual` one, `scripts/make_issue157_probes.py` the two issue #157 probes, `scripts/make_grid_gap_borders_fixture.py` the `gridBefore`/`gridAfter` border probe, and `scripts/make_border_geometry_probes.py` the three §17.4.66 border-geometry probes.
## Public API
- **Rust**: `convert(&[u8])` uses default options; `convert_with_options(&[u8], &RenderOptions)` is the full entry point. `RenderOptions` is a builder (`with_image_dpi`) with `DEFAULT_IMAGE_DPI = 220.0`; non-finite or non-positive requests are clamped up to `MIN_IMAGE_DPI`.
- **Python** (`--features python`, built with maturin via `pyproject.toml`): `convert(docx_bytes, image_dpi=220)` and `convert_file(input, output, image_dpi=220)`. Type stubs and the `py.typed` marker live in `python/dxpdf/`.
- **Go** (`--features capi`, consumed as `github.com/nerdy-pro/dxpdf/go`): `Convert(docxBytes)`/`ConvertWithOptions(docxBytes, imageDPI)` and `ConvertFile(input, output)`/`ConvertFileWithOptions(input, output, imageDPI)`, deliberately shaped to match the Python surface one-for-one (`go/dxpdf.go`'s doc comment says so explicitly, so the two can be read side by side). `DefaultImageDPI`/`MinImageDPI` mirror the Rust constants. Unlike Python, file I/O is pure Go on top of the byte-in/byte-out call — there is no C-side `convert_file`, keeping `src/capi.rs`'s `unsafe` surface to one function.
## Working in this repo
**Test corpus** — `test-files/` holds the committed DOCX fixtures, and is the corpus to use for reproductions and regression work:
| File | Exercises |
|---|---|
| `sample-docx-files-sample1`…`sample4` | General documents — text, tables, images, sections. `sample4` (14 MB) is the large-document/perf case |
| `sample-docx-files-sample-4`…`sample-6` | Small focused samples |
| `font_scaling.docx` | Font sizing and scaling |
| `sample-emoji.docx` | Color-emoji pipeline |
| `fonts/*.ttf`, `fonts/*.ttc` | §17.8 face resolution — built by `scripts/make_font_fixtures.py`, exercised by `tests/font_resolution.rs`. Regenerate rather than hand-edit; the build is deterministic |
| `comment-reference.docx` | `<w:commentReference>` inside a run (parsed, not rendered) |
| `russian-numbering.docx` | `russianUpper` list numbering (А, Б, В…) |
| `numbering-direct-indent.docx` | Direct paragraph `w:ind` overriding the numbering level's indentation (suffix-tab position) |
| `centered-numbered-heading.docx` | Numbered heading with `jc=center` (suffix tab must not suppress alignment) |
| `issue-126-minimal.docx` | The reporter's document from issue #126 — a paragraph before an explicit page break. Renders 4 pages, matching the LibreOffice reference attached to that issue |
| `issue-139-minimal.docx` | The reproduction from issue #139 — one paragraph naming **no font anywhere**, mixing `① ア ๑` (which the spec-fallback face cannot draw) against `א` (which it can, so it is the control that must not move). Built by `scripts/make_font_fallback_fixture.py`; exercised by `tests/font_fallback.rs` |
| `issue-165-vmerge.docx`, `issue-165-cellspacing.docx`, `issue-165-floatv.docx` | The three issue #165 probes — documents authored so that each candidate reading of an ECMA-376 ambiguity predicts a *different measurement* off the rendered page (vMerge overflow distribution, `tblCellSpacing` at the table edges, vertical `inside`/`outside`). Built by `scripts/make_issue165_fixtures.py`, alongside `issue-165-cellspacing-scale.docx` — four otherwise-identical tables at four spacings, which asks the follow-up probe B turned up: whether Word's gap is the declared value or twice it, whether the spacing is carved out of `tblW` or added to it, and whether a row-level value supersedes the table-level one as §17.4.45 says. They answer nothing on their own — each needed a Word render to measure against, which is what #165 tracked. **All four have now been measured**; the readings and what they settled are recorded where the code acts on them (`table/grid.rs` for vMerge, `table/borders.rs` for cell spacing at the edges, `build/floating.rs`'s test module for the vertical mirror, and `build::table::resolve_cell_spacing` for the magnitude), `issue-165-floatv.docx` is asserted end-to-end by `tests/floating_anchor_parity.rs` and the scale probe by `tests/table_cell_spacing.rs`. The scale render answered all three of its questions at once and **refuted two written findings**: the declared value is a *half*-gap, so every gap is twice it at the table's own edges and between cells alike; the spacing is carved out of `tblW` as before; and §17.4.43's row-level value does supersede, governing the table's edge inset too. What it killed was the argument that there is no factor — taken from ONLYOFFICE, an independent Word-compatible implementation, with Word's doubled gaps explained instead as Word *summing* the table-level and row-level declarations `issue-165-cellspacing.docx` happens to carry. Two of the scale tables declare the spacing at table level **only** and Word doubles them anyway, so there is nothing to sum. Its fourth table is what separates supersede (80pt gaps, 13.3pt cells — Word draws 13.5) from table-wins and from summing, which would not fit on the page |
| `issue-159-minimal.docx` | The reporter's document from issue #159 — four `w:fldSimple` DATE fields whose `\@` pictures cover an escaped space, no escape, an escaped letter, and no picture. Each carries a deliberately wrong cached result (`CACHED`) so a renderer that fails to evaluate is obvious. Exercised by `tests/date_field_picture.rs` |
| `footer-path-wrap.docx` | A token UAX #14 cannot break (a Windows path) in a footer-table cell far narrower than it. Built by `scripts/make_footer_path_fixture.py`; exercised by `tests/footer_path_wrap.rs` |
| `hidden-text.docx` | §17.3.2 `w:vanish` in every position that resolves differently — a hidden run between two visible ones (with a control paragraph that has no hidden run, so the geometry is comparable without measuring a glyph), a character style that hides, a run un-hiding itself with `w:val="0"`, a paragraph whose every run is hidden, a hidden tab-and-break, and a hidden `w:sym` that **still draws** — the known limit, since the model drops a run's properties on its non-text children. Built by `scripts/make_hidden_text_fixture.py`; exercised by `tests/hidden_text.rs` |
| `bidi-visual-table.docx` | §17.4.1 `w:bidiVisual` — two tables differing **only** in that element, so every claim is the second being the first reflected about its own edges. One unequal grid (1000/2000/3000 twips, so reversing the cells without the slot widths is visibly wrong) carrying `w:gridSpan`, `w:gridBefore`, `w:vMerge` and a `w:tcBorders/w:left` that has to come out on the visual right. Both tables' cell paragraphs are RTL, which makes the fixture a controlled experiment for the *placement* half too: Word puts the `bidiVisual` one at the right margin and the control at the left, so paragraph direction cannot be what moves it. Carries a `styles.xml` it does not need for rendering, because it is meant to be **measured against a Word render**: §17.7.2 makes an absent `w:docDefaults` application-defined, so a package that declares no face, size or spacing is read one way here (ECMA's Times New Roman 10pt, zero spacing) and another by Word (its template's Calibri 11pt with `after=160`, `line=259` — about twice the row height), with nothing in the file to decide between them. Same for `w:tblCellMar`, which Word supplies from `TableNormal` and dxpdf leaves at zero when no such style exists. The nine other fixtures with no styles part carry the same offset and have not been given one. Built by `scripts/make_bidi_visual_fixture.py`; exercised by `tests/table_bidi_visual.rs` and `tests/table_leading_margin.rs` |
| `grid-gap-borders.docx` | §17.4.36/§17.4.15/§17.4.14 — the vertical edge where a row's cells stop short of the grid. Part reproduction, part **probe**: the same four rows twice, once with visible outer borders and once with `w:left`/`w:right` set to `nil`. The nil table is the reported symptom with no `w:bidiVisual` and no Hebrew in it; the visible one is what separates the two readings the report could not, since each vertical border is identifiable by colour *and* weight (3pt red `w:left`, 3pt green `w:right`, 1pt blue `insideV`, 1pt grey horizontals) and row 1 spans the grid as a legend showing all three. Row 4 gaps a row at both ends, which is the case a one-sided fix fails, and row 5 gaps the start but then holds **two** cells, so its `G|H` boundary is interior by every reading and must survive — the trap-detector for a fix that gives every cell of a gapped row the outer border. Rows 2 and 3 gap at *opposite* ends, which is what exposes the horizontal defect as well (see the test list). **Measured in Word**, and the measurement settled a question two readings had been guessed at: the gap-facing edge takes the table's `w:left`/`w:right`. States its own font for the same reason `bidi-visual-table.docx` does. Built by `scripts/make_grid_gap_borders_fixture.py`; exercised by `tests/table_grid_gap_borders.rs` |
| `issue-157-tblprex-bidi.docx`, `issue-157-empty-row-edge.docx` | The two issue #157 **probes** — neither pins behaviour, each asks a question ECMA-376 does not answer and is authored so the candidate readings predict a *different measurement*. `tblprex-bidi` flips one row with `w:tblPrEx/w:bidiVisual` inside an unequal grid: cell `A` comes out 50pt if a flipped row keeps its declared widths, 150pt if it takes the slot's. **Measured 2026-09-05** (pixel-counted off a fresh render, calibrated against the page's own margins): Word draws it at 50pt — a flipped row keeps each cell's own declared width rather than resizing it to the grid slot it visually lands in, confirming the first reading. The same render answered two questions the fixture's own heading never asked, and both are now implemented alongside the one it did: the row is positioned as if it were a mini right-to-left table of its own, its own right edge reaching the *page's* content width rather than the table's — the same basis §17.4.1 places a whole `bidiVisual` table against (`table_leading_margin.rs`) — and it paints **after** the row that would otherwise follow it rather than between its neighbours (row 2 of 3 renders third, not second). `build::table` implements all three: a flipped row's cells are reversed into visual order at their own widths (`RowBidiOverride`, which carries the offset too), excluded from the shared collapsed-border grid and drawn as a closed frame per cell instead (`borders.rs`'s `bidi_override` checks, `emit_cell_frame`), and swapped with the following `TableRowInput` at the same seam `mirror_columns` uses for a whole-table flip — early enough that every downstream pass (measurement, border resolution, splitting, painting) needs no separate notion of document order at all. Both the position and the paint-order findings are measured from this one arrangement only — a single flipped row with exactly one row after it — and are not verified for two flipped rows, a flipped row with nothing following it, `vMerge` crossing it, or a `gridSpan` cell on it; `RowBidiOverride`'s doc names these as open. Exercised end-to-end by `tests/table_bidi_visual_row.rs`, parsed directly from the committed fixture rather than added to `parse_test_files.rs`'s `ALL_FILES`, the same way `bidi-visual-table.docx` is. **Carries a `styles.xml` now, added the same day and for the reason `bidi-visual-table.docx` does**: the first Word render used a package with none, and every row's height disagreed between the two renderers for a reason that had nothing to do with `bidiVisual` — an absent `w:docDefaults` is application-defined (§17.7.2), so this engine read Times New Roman 10pt/zero spacing while Word read its own template's Calibri 11pt with non-zero paragraph spacing. Stating the defaults explicitly does not change the width, position or paint-order findings above — none of them depend on the font — only the row heights, which the two renderers now read identically instead of from two different defaults. `empty-row-edge` asks what stands on a boundary where a row of no height puts two edges at one y: a two-row control beside the same table with such a row between the rows, so the middle rule is one 3pt line or two. Built by `scripts/make_issue157_probes.py`. **`empty-row-edge` was rebuilt on 2026-08-19 because Word would not open it.** It asked its question with a cell-less `<w:tr/>`, and Word refuses any document containing one — isolated against two variants differing in exactly one thing each: the same package with the row deleted opens, the same row in a fuller package does not. `CT_Row` makes the cell group `minOccurs="0"`, so the element is schema-valid and Word's reader is stricter than the schema, the same class of rejection the three `issue-165-*` fixtures hit over a `.rels` namespace — and one `verify_docx.py` cannot catch, since the package is sound and it is the content Word declines. It now asks the same question in the two spellings Word accepts, both of which a producer can emit and both of which reach the same merge in `table::borders`: a row of `hRule="exact"` at 0, whose two boundaries coincide, and one at 2pt, which is shorter than its own two 3pt borders. dxpdf draws a 6pt band at the first and 3pt/2pt/3pt at the second against a 3pt control, so every candidate reading predicts a different picture. Tables 4 and 5 ask what §17.4.80's value **measures**, which the first three cannot: their middle rows declare 40pt against 6pt of borders — `exact` and `atLeast` respectively — so no floor or collapse is involved and the readings differ by a clean 6pt. **Measured 2026-08-19, and re-measured 2026-09-05** (pixel-counted off a fresh render and calibrated against the table's own fixed-layout width — 200pt against a measured 750px, a 3.75px/pt ratio precise enough to separate the two candidate readings — rather than eyeballed): `atLeast` draws the full 40pt, rules outside as the first measurement found; `exact` does not — it draws **~37pt**, short by exactly half of each of its two 3pt interior rules. `AtLeast` is a floor on content and has nothing to do with the borders around it; `Exact` pins the row's total box, and an interior rule eats into that box from both sides the same way a shared vertical eats into a cell's width (`border-content-charge.docx`), just halved rather than taken whole. That is the second reading this fixture has refuted and the first it has confirmed: the first render's "40pt in both, one rule covers both" is itself now the wrong one, alongside the earlier-shipped "the rules are wholly inside" reading `border-content-charge.docx`'s literal analogy produced. Both wrong readings share a cause the fixture's own module doc warns about — tables 2 and 3 are too small to referee this by eye, and each mismeasurement came from a single render rather than a calibrated one. The charge is computed at `measure_table_rows`'s call into `RowHeightRule::content_height`, from the row's two *interior* boundaries only (`plan.horizontal`, not `resolved_borders`, for the reason the vertical charge already reads from the plan — resolution clears the losing side of a shared edge to `Absent`) — a table's own outer top and bottom are excluded and hang wholly outside every row's box regardless of `height_rule`, unchanged from `border-outer-box.docx`. Table 3's 2pt-against-6pt-of-rule row now floors at zero rather than passing 2pt through unmodified, which is the same rule rather than a separate collapse, and fits the fresh measurement better too: its rendered band reads as a hairline, not a clean 2pt. **Table 2 carried the real defect**: `hRule="exact"` with `w:val="0"`, which Word renders as a full row of cell because zero is the marker for *unconstrained*, not a height — [MS-OI29500] §2.4.77(c) records the same from the producing side. A literal reading draws a flat row and loses the cell, which this engine did until that render. It carries its own `styles.xml`, for the reason `bidi-visual-table` does. What is **not** asked any more is whether a *cell-less* row separates its neighbours: a document holding one is one Word itself calls corrupt, so no fidelity target exists and dxpdf's tolerance there is a robustness decision, which `table::borders` and `table::measure` say where they decide it. `empty-row-edge` is in `parse_test_files.rs`'s `ALL_FILES` now that every row of it has a cell — `table_rows_have_cells` is the invariant the old shape violated on purpose |
| `border-content-charge.docx`, `border-outer-box.docx`, `border-junction-colour.docx` | The three §17.4.66 **probes** — each asks a question ECMA-376 does not answer, and is authored so the candidate readings predict a *different picture*. ECMA specifies no stroke geometry for table borders at all, so where a border sits on its edge, how much of it is inside a cell, and what happens where two cross are this engine's own conventions. Two have now been **measured in Word** and one is still open. `content-charge` steps a shared border 0.5 → 12pt with zero cell margins and asks whether the cell's glyph is painted over, flush against, or clear of it; Word draws it **flush**, so a shared border is charged **half** to each of the cells that meet on it (`measure_table_rows`, `tests/table_cell_content_box.rs`) — which refuted the engine's own two answers, since it charged the winner's cell the full width and the facing cell nothing. `junction-colour` settled three things and left one open, and `borders::junction_axes` / `borders::drawn_width` hold them with `tests/table_border_junctions.rs` pinning them. Tables 1 and 2 tie two 12pt borders on weight and style and swap which axis is darker; Word draws the pale crossing then the dark one, so at equal weight the **horizontal** takes the square and colour never decides. Table 5 pairs a 12pt vertical with a 3pt horizontal and Word draws the vertical through it, so the **heavier** line wins and the horizontal only breaks a tie — §17.4.66's weight step and nothing after it. Table 3 crosses two `double`s and Word draws the **2 × 2 lattice** with both gaps running through ("the borders are negative space, so it looks like every cell has its own border"), so a crossing is the **product** of its two axes' §17.18.2 rules whichever won it; that render also fixed `w:sz` as the width of **one rule**, making a `double` three times as wide as a `single` of equal `w:sz` rather than the same band split in thirds. Table 4 is the one still open: a `single` horizontal crossing a `double` vertical, where the product punches the double's gap through the solid line and the rival reading runs it through unbroken. `outer-box` has now been measured too, and refuted the guess it was built to test: two tables of one `tblInd` and one `tblW` side by side at 0.5pt and 12pt outer borders, against a paragraph whose left edge is the margin. Word draws the thick table's left border at **60–72** and its right at **360–372** — the ink lies outside the box on both sides, and the two tables' right edges coincide. So a table's own edges **straddle** their grid line like any other collapsed border, and the whole table sits half a leading border left of its indent, because `w:tblInd` measures to the **first cell's text edge** — which is what `build_table` already assumed for the full-width case and now applies generally. Two readings died first: *inside the box*, which pushed the first column's text in by a whole border, and *wholly outside*, which put the frame right but left the interior lines in the wrong place and painted vertically through the paragraph above. Pinned by `tests/table_outer_box.rs`. Built by `scripts/make_border_geometry_probes.py`. This engine's answers are *charged half*, *heavier wins with the horizontal breaking ties*, *the product of both axes*, *`w:sz` is one rule*, and *the box straddles its borders* — all measured |
| `equations-omml.docx` | §22.1 Office Math: an `m:sSup` equation and an `m:f` chain — the two OMML constructs the renderer models. Parsed by `tests/parse_test_files.rs`, rendered end-to-end by `tests/render_integration.rs` (`equations_render_math_glyphs_and_fraction_bars`) |
| `universal-measures.docx` | §22.9.2.15/§22.9.2.9 — every measurement spelled with an explicit unit (`w:pgSz w:w="595.30pt"`, margins in `cm`/`in`) or as a percentage (`w:tblW w:w="50%"`), the spellings a Word "Strict Open XML" export writes and Transitional admits equally. The A4 geometry is exact on purpose (595.3pt × 20 = 11906 twips even), so the conversion is pinned with no rounding tolerance hiding in it. Built by `scripts/make_universal_measures_fixture.py`; asserted value-by-value in each target's native unit by `tests/universal_measures.rs` |
| `duplicate-children.docx` | Every property bag (`pPr`, `rPr`, `tblPr`, `trPr`, `tcPr`) plus a VML `<v:roundrect>` repeating a child the schema allows once, each pair disagreeing so the fixture pins *which* wins, plus a style name whose byte 4 splits a codepoint. Built by `scripts/make_duplicate_children_fixture.py`; exercised by `tests/duplicate_children.rs` |
`tests/parse_test_files.rs` parses these and validates the resulting `Document`, so anything added here becomes part of the test suite. Add a new fixture when reproducing a bug — a committed fixture is what makes a fix verifiable by anyone.
`output/` holds generated PDFs. Scratch only, gitignored; never commit generated PDFs.
**Render-and-verify loop.** Rendering changes need visual confirmation, not just green tests:
```bash
cargo build --release
./target/release/dxpdf test-files/sample-docx-files-sample1.docx -o output/sample1.pdf
# Targeted before/after on a single page:
pdftoppm -png -r 150 -f 1 -l 1 output/sample1.pdf /tmp/after
magick compare -metric AE /tmp/before-1.png /tmp/after-1.png null:
```
`scripts/compare_pdfs.py` batch-diffs rendered output against `*_real.pdf` reference files (needs poppler + Pillow). It reads a local reference corpus that is not part of the repo, so it reports "No test pairs found" unless you have those references locally.
For any paint or subset change, pixel-diff before vs after — a passing test suite does not prove the output is unchanged.
**But a clean pixel diff does not prove it either, and one class is invisible to this loop.** `pdftoppm` composites two abutting fills cleanly; CoreGraphics — macOS Preview, Quick Look, Safari — does not, and leaves a pale hairline wherever the shared edge falls on a fractional device pixel. A seam reported in the `MediumShading2-Accent5` header of `sample-docx-files-sample1.docx` was present at every zoom in Preview and at *no* resolution in poppler. `tests/table_shading_seams.rs` audits the command stream for the pairs instead of looking for their pixels, which is the only check that can see them; when a rendering question is about compositing rather than geometry, rasterize with CoreGraphics (a 30-line `CGContextDrawPDFPage` program) rather than trusting poppler.
**Debian package** (issue #92) — `cargo deb` builds it from `[package.metadata.deb]` in `Cargo.toml`; `scripts/verify_deb.py` checks the result and `tests/packaging.rs` checks the inputs (man page in step with `--help`, `debian/changelog` in step with the version). CI builds amd64 on every PR and `deb.yml` builds both architectures per release.
It has to be built **inside a `debian:12` container**, and both halves of that matter. `depends = "$auto"` runs `dpkg-shlibdeps`, which resolves the binary against the packages of whatever distribution it runs on. And glibc is a floor, not a ceiling: built on `ubuntu-latest` the package requires glibc 2.39 and will not install on Debian 12 at all. Bookworm's 2.36 reaches Debian 12 and 13, Ubuntu 24.04+, and derivatives — moving that base later silently drops users who have already installed. Use `clang-19`, not bookworm's default clang 14, which cannot compile Skia m150's C++20 `<ranges>` against GCC 12's libstdc++.
```bash
docker run --rm -v "$PWD:/w" -w /w debian:12-slim bash -c '
apt-get update && apt-get install -y --no-install-recommends \
build-essential clang-19 libclang-19-dev ninja-build python3 curl \
ca-certificates git pkg-config libfontconfig1-dev libfreetype-dev lintian
curl -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none
. "$HOME/.cargo/env"; export CC=clang-19 CXX=clang++-19
cargo install cargo-deb --locked && cargo deb
python3 scripts/verify_deb.py target/debian/*.deb
lintian --fail-on error,warning target/debian/*.deb'
```
Two things that are not obvious from the files. The `assets` list is explicit because cargo-deb's default set also picks up C-ABI libraries, and `crate-type = ["rlib", "cdylib", "staticlib"]` means a release build emits `libdxpdf.so` and `libdxpdf.a` — the PyO3 extension body and the Go bindings' cgo target, neither of which has any business in `/usr/lib`. And when testing an install in a Debian *container*, delete `/etc/dpkg/dpkg.cfg.d/docker` first: it sets `path-exclude /usr/share/man/*`, so dpkg drops the man page and the test appears to prove the package has none.
**How a change is built here.** Three conventions that the CI commands below do not enforce and that reviewers assume have happened:
- **Tests written from the spec, first, and watched fail.** Not written against current output, which only pins the bug. Where a change touches uncovered code, the characterization tests land first as their own commit.
- **A mutation check on every new test.** A test written alongside its implementation passes on the first run, which proves nothing. Break the implementation deliberately and confirm the right tests fail — if none does, the test is decoration.
- **Pixel-diff any change that moves geometry**, across `test-files/` + `test-cases/`, and *explain* every diff rather than merely observing it. A document that changes is either the fix working or a regression, and only reading the pixels says which.
**Before handing work back**, run what CI runs (`.github/workflows/ci.yml`):
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings # CI enforces zero warnings
cargo test --all
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps # CI enforces zero doc warnings
cargo build --no-default-features # `subset-fonts` off still compiles
cargo build --release --features capi # the Go bindings' C ABI still compiles
```
The doc check catches dangling `[`links`]`, links from public docs to private
items, and prose rustdoc reads as HTML (`Vec<Thing>` outside backticks). Link to
a private item with a plain code span, not `[`brackets`]`.
**Logging**: `RUST_LOG=debug` gives per-phase timing — parse/render/total from `convert`, then resolve, registry, layout, subset and paint from `render_with_font_mgr` — plus the font-resolution decision for every requested family; `RUST_LOG=warn` surfaces unsupported-feature warnings from parse and layout. Prefer these numbers to intuition. The registry build used to be the largest cost on an ordinary document — a fixed 78–95 ms on every render regardless of document size — because it indexed the whole host font system up front. It is now tiered and lazy: a document whose fonts are all present or embedded costs ~3 ms, and one that has to reach the metadata index costs ~105 ms, paid once. See `src/render/fonts/catalog.rs`'s module doc for the per-operation breakdown; `FontMgr::match_family` dominates, at 28 ms across a 210-family host.