Skip to content

fix(export): lay lines out at the font's unrounded natural height (#850) - #909

Merged
JSv4 merged 4 commits into
mainfrom
fix/850-unrounded-line-pitch
Oct 3, 2026
Merged

JSv4 merged 4 commits into
mainfrom
fix/850-unrounded-line-pitch

Conversation

@JSv4

@JSv4 JSv4 commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Why

Exported pages placed lines at different vertical positions from Word, by amounts that grew down the page and eventually moved page breaks (#850). I measured Chromium directly to find where the error comes from:

Specified line-height (11 pt Carlito) Laid-out pitch
normal 18.0000 px (13.500 pt)
13.8pt (an explicit length) 18.4063 px (13.805 pt), 1/64-px precision
calc(1lh * 1.0792) 19.4219 px, i.e. 18 px × 1.0792

Explicit lengths keep sub-pixel precision. Only line-height: normal is rounded to a whole pixel, and 1lh inherits that rounded value. Carlito's natural line height is 2500/2048 em, which is 17.904 px at 11 pt, but Chromium lays the line out at 18 px. The converter relies on normal for single spacing and on calc(1lh * multiplier) for Word's auto multiples, so every exported line carried the rounding: +0.07 pt per line for 11 pt Carlito, and −0.30 pt per line for 12 pt Liberation Sans.

Exact and at-least line heights had a converter-side quantization of the same kind. They were formatted with one decimal, so w:line="253" (12.65 pt) rendered as 12.7 pt.

What changed

1. An export-only pre-pass, applyUnroundedNormalLineHeights (npm/src/line-metrics.ts). It runs in the isolated render document after fonts load and before the paginator measures anything:

  • It collects every element whose computed line height is normal before changing any. An explicit value on a paragraph would otherwise be inherited by runs that should size lines from their own font.
  • It probes each element's font once, at 1000 px, where pixel rounding is negligible. That is the same natural height Chromium uses, without the rounding.
  • Fallback fonts are kept. Under normal, Chromium grows a line for a taller fallback font: CJK text in a Carlito paragraph lays out at 15.75 pt, not 13.43 pt. An element whose own text goes beyond Latin is therefore probed with those characters, so its height includes the fallback. Latin-only elements share one cached probe.
  • It sets ratio × font size as an explicit px height. The converter's calc(1lh * m) children then multiply a precise base.
  • Both layout attempts (the export lays out twice and requires identical page trees) apply it identically. The values live in the DOM, so the serialized HTML and the PDF print path carry them.

2. Converter: exact and at-least heights use {0:0.0#}pt, which keeps twentieths of a point. Values with one decimal are byte-identical (10.0pt stays 10.0pt). The DOCX → HTML output for all 694 TestFiles fixtures is unchanged, because none uses a two-decimal exact or at-least value.

3. No CI font install. The metric tests load the repository's own test font, docs/demo/fonts/docxodus-canvas-mono.woff2, through the export's font resolver. Installing any font system-wide on the runner (Liberation, and then Carlito alone) changed what the tabs-visual fixtures fall back to and broke their recorded screenshots. An earlier revision of this PR did exactly that, and CI caught it.

The editor's paginated view is unchanged; the pass runs only in the export.

How close to Word this gets, honestly

Hence "Part of #850": #850 stays open, and #908 covers what remains.

Validation

  • New npm/tests/export-line-pitch.spec.ts exports generated documents and measures the mean line pitch per paragraph in the exported HTML:
    • The test font at 12 pt (w:line 240, 259, 276 and 480) and at 14 pt (240 and 480). Expected heights come from the font's own hhea table (1901 + 483 + 0 per 2048 units/em, read with fontTools), not from the code under test; tolerance 0.05 pt per line. Mutation-checked: with the pass disabled, 12 pt single measures 14.25 pt against 13.97 pt and 14 pt single 15.75 pt against 16.30 pt, the whole-pixel rounding.
    • Exact w:line="253": 12.65 pt within 0.02 pt. Red before: 12.703 pt.
    • CJK text in a Carlito paragraph: within one pixel of what Chromium's own normal gives the same text, so the check doesn't depend on which CJK font a machine has. Red with a primary-font-only probe: 13.43 vs 15.75 pt.
  • New LineSpacingPrecisionTests (.NET): exact 253 → 12.65pt, at-least 301 → 15.05pt, exact 200 → 10.0pt. Mutation-checked: the old format fails the two precise cases.
  • Full .NET suite: 4922 passed, 3 skipped.
  • Export specs: standalone-export, export-robustness, export-line-pitch and font-runtime pass (51).
  • @docxodus/export: 51 of 54 pass. The 3 failures are this machine's known Chromium-sandbox restriction, unchanged from main.
  • Not verified:
    • PDF text positions. The issue asks for those, but I assert on the exported HTML, which the PDF is printed from. I haven't checked positions in the PDF itself.
    • The generated-PDF parity ratchet. Locally, the same sandbox restriction blocks it. In CI, the visual-parity workflow currently fails for everyone at its LibreOffice download (also on main), filed as visual-parity workflow: pinned LibreOffice download fails with curl (18) partial file #910. This ratchet result is outstanding, and it is expected to move, since page breaks follow line pitch. If an existing ratchet entry then needs re-recording, I'll list each one and ask before changing it.

Adversarial review round

I reviewed the diff as a hostile reviewer would before opening this PR, with one outside review pass. That review caught two things:

  • The CJK regression: an explicit primary-font height flattened lines that need a taller fallback font. Fixed and tested.
  • The one-decimal exact/at-least format: fixed and tested.

It also made me state the Arial/Word gap plainly instead of implying parity, and correct a test comment that cited the wrong metric table for Carlito. Earlier rounds settled collect-then-set ordering, font-aware probe caching, applying the pass in both attempts, export-only scope, and keeping test expectations independent of the code under test.

Part of #850

🤖 Generated with Claude Code

@JSv4
JSv4 force-pushed the perf/852-export-batch-setup branch from e0ea8de to f3f6626 Compare October 3, 2026 17:32
@JSv4
JSv4 force-pushed the fix/850-unrounded-line-pitch branch from 6e5af1e to 3cb8c6f Compare October 3, 2026 17:32
@JSv4
JSv4 force-pushed the perf/852-export-batch-setup branch from f3f6626 to ce2e179 Compare October 3, 2026 18:01
@JSv4
JSv4 force-pushed the fix/850-unrounded-line-pitch branch from 3cb8c6f to 6fb5e80 Compare October 3, 2026 18:01
@JSv4
JSv4 force-pushed the perf/852-export-batch-setup branch from ce2e179 to 57cf90f Compare October 3, 2026 18:30
@JSv4
JSv4 force-pushed the fix/850-unrounded-line-pitch branch from 6af2220 to 02b2a89 Compare October 3, 2026 18:30
@JSv4
JSv4 changed the base branch from perf/852-export-batch-setup to main October 3, 2026 18:50
@JSv4
JSv4 force-pushed the fix/850-unrounded-line-pitch branch from 02b2a89 to 1a4dada Compare October 3, 2026 18:50
JSv4 added 3 commits October 3, 2026 15:55
Chromium rounds a line-height: normal line to a whole CSS pixel, and the
converter uses normal for single spacing and as the 1lh base of Word's auto
multiples, so every exported line was off by up to 0.375 pt and the error
accumulated down the page. Before pagination the export now gives each
element whose computed line height is normal an explicit height: its own
font's natural line height probed at 1000 px. Converter output and the
editor's paginated view are unchanged. Baseline placement is #908.
#850)

The unrounded-line-height pass measured only the primary font, so a CJK run
that Chromium grows for its taller fallback font was squeezed to the Latin
height (13.43 pt instead of 15.75 pt): an element whose own text goes beyond
Latin is now probed with those characters. Exact and at-least line heights
were formatted with one decimal (w:line=253 rendered 12.7 pt instead of
12.65 pt); they now keep twentieths of a point. Records the Arial-vs-Word
difference that remains, tracked in #908.
Installing fonts-liberation2 changed what "Times New Roman" falls back to on
the runner, which broke the tabs-visual screenshots recorded against its
default fonts. The line-pitch test now checks Carlito at 11 pt and 12 pt only.
@JSv4
JSv4 force-pushed the fix/850-unrounded-line-pitch branch from 6c3f678 to 96527dc Compare October 3, 2026 20:56
Installing any font system-wide on the CI runner (Liberation, then Carlito
alone) changes what the tabs-visual fixtures fall back to and breaks their
recorded screenshots. The metric cases now load the repository's own
Docxodus Canvas Mono through the export's font resolver and check it against
that font's hhea metrics at 12 pt and 14 pt; the CI font step is removed.
@JSv4
JSv4 merged commit ea5ecd0 into main Oct 3, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant