fix(export): lay lines out at the font's unrounded natural height (#850) - #909
Merged
Merged
Conversation
JSv4
force-pushed
the
perf/852-export-batch-setup
branch
from
October 3, 2026 17:32
e0ea8de to
f3f6626
Compare
JSv4
force-pushed
the
fix/850-unrounded-line-pitch
branch
from
October 3, 2026 17:32
6e5af1e to
3cb8c6f
Compare
JSv4
force-pushed
the
perf/852-export-batch-setup
branch
from
October 3, 2026 18:01
f3f6626 to
ce2e179
Compare
JSv4
force-pushed
the
fix/850-unrounded-line-pitch
branch
from
October 3, 2026 18:01
3cb8c6f to
6fb5e80
Compare
JSv4
force-pushed
the
perf/852-export-batch-setup
branch
from
October 3, 2026 18:30
ce2e179 to
57cf90f
Compare
JSv4
force-pushed
the
fix/850-unrounded-line-pitch
branch
from
October 3, 2026 18:30
6af2220 to
02b2a89
Compare
JSv4
force-pushed
the
fix/850-unrounded-line-pitch
branch
from
October 3, 2026 18:50
02b2a89 to
1a4dada
Compare
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
force-pushed
the
fix/850-unrounded-line-pitch
branch
from
October 3, 2026 20:56
6c3f678 to
96527dc
Compare
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.
This was referenced Oct 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
line-height(11 pt Carlito)normal13.8pt(an explicit length)calc(1lh * 1.0792)Explicit lengths keep sub-pixel precision. Only
line-height: normalis rounded to a whole pixel, and1lhinherits 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 onnormalfor single spacing and oncalc(1lh * multiplier)for Word'sautomultiples, 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:normalbefore changing any. An explicit value on a paragraph would otherwise be inherited by runs that should size lines from their own font.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.pxheight. The converter'scalc(1lh * m)children then multiply a precise base.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.0ptstays10.0pt). The DOCX → HTML output for all 694TestFilesfixtures 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 thetabs-visualfixtures 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
hheametrics, 13.80 pt at 12 pt. Paginated export: line pitch quantized to CSS pixels and first-baseline offset vs Word #850 reports Word at 13.68 pt. That is 0.12 pt per line long, where before it was 0.18 pt per line short. That still misses the issue's 0.05 pt target, and the error has changed direction. None of the font's metric sets (win13.41,typo13.06) reproduces Word's figure, and Paginated export: line pitch quantized to CSS pixels and first-baseline offset vs Word #850 has no recorded fixture for it. I added this to Paginated export: place the baseline at the ascent and the extra leading of auto line spacing below the text, as Word does #908 rather than guessing.automultiples below the text; CSS splits it): not attempted. Paginated export: place the baseline at the ascent and the extra leading of auto line spacing below the text, as Word does #908 records the blockers (which ascent the model means, since the candidates differ by more than the effect) and the mechanism precedent (the converter's top-aligned run boxes for exact spacing).Hence "Part of #850": #850 stays open, and #908 covers what remains.
Validation
npm/tests/export-line-pitch.spec.tsexports generated documents and measures the mean line pitch per paragraph in the exported HTML:w:line240, 259, 276 and 480) and at 14 pt (240 and 480). Expected heights come from the font's ownhheatable (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.w:line="253": 12.65 pt within 0.02 pt. Red before: 12.703 pt.normalgives 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.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.standalone-export,export-robustness,export-line-pitchandfont-runtimepass (51).@docxodus/export: 51 of 54 pass. The 3 failures are this machine's known Chromium-sandbox restriction, unchanged frommain.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:
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