Skip to content

fix(docx): advance paragraphs by the font's full line box#465

Merged
developer0hye merged 1 commit into
mainfrom
fix/docx-paragraph-boundary-leading
Jul 25, 2026
Merged

fix(docx): advance paragraphs by the font's full line box#465
developer0hye merged 1 commit into
mainfrom
fix/docx-paragraph-boundary-leading

Conversation

@developer0hye

@developer0hye developer0hye commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • span Word's full line advance with the paragraph's line box instead of recovering it as par(leading:)
  • drop the grid-only paragraph-boundary leading top-up, which the new box makes redundant
  • default an unspecified w:spacing w:after to 0pt, per ECMA-376, instead of injecting 8pt
  • replace the list path's now-always-zero metric_leading_pt with a wrapper_spans_full_line flag

Related issue

Fixes #452

Details

Word advances every body line by the full line box - the font's hhea single-spacing line, or the document grid pitch for East Asian text under a w:docGrid - and measures w:spacing w:before/w:after from that box's edges. office2pdf pinned the text edges to the font's typographic ascender/descender and recovered the remainder as par(leading:), but Typst inserts leading only between the lines of one paragraph. Every paragraph came out exactly one leading short however many lines it had: consecutive 9pt Courier New command lines advanced 7.2pt against Word's 10.1pt.

Spanning the whole advance with the box, split by the font's ascender/descender ratio, makes a paragraph's height n * advance exactly as in Word, and puts the block edges where Word measures its gaps from.

The parser's 8pt default w:spacing w:after was the compensating half of the same model: it masked the missing leading. It cannot be left in place alongside the fix without double-counting, so it goes in the same change. It stays an explicit Some(0.0) rather than None so the paragraph block still pins its below and Typst's own 1.2em default block spacing cannot leak in.

Measured against the Word golden PDFs, per-line pitch now matches within 0.15pt and mean absolute vertical drift over the business corpus drops from 14.61pt to 13.52pt:

Fixture before after
04_resume_en 4.90 pt 0.69 pt
09_offer_letter_en 2.57 pt 1.41 pt
07_product_spec_en 4.56 pt 1.20 pt
06_official_letter_ko 6.83 pt 1.86 pt
02_contract_ko 11.94 pt 4.20 pt

Testing

  • cargo fmt --all -- --check
  • cargo test --workspace
  • cargo clippy --workspace --all-targets --all-features
  • python3 scripts/validate_business_golden_mocks.py
  • pdftotext -bbox line-pitch comparison against all 10 Word-produced DOCX golden PDFs
  • pdftoppm -r 150 rendering, matched region crops, and magick compare -metric AE -fuzz 5%

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: every DOCX paragraph's line advance and default after-gap changed.

Visual audit

Visual comparison

GT Before After
GT Before After

Required inspection

  • Rendered all evidence at 150 DPI or higher
  • Stored progressive JPEG quality 86 assets with metadata stripped
  • Inspected matched region crops at full resolution
  • Ran the 5% fuzz pixel-difference sweep
  • Inventoried hairlines and border dash styles
  • Inventoried font weight, italic, and underline emphasis

The 5% fuzz sweep against the size-normalized GT falls from 98,872 to 88,314 differing pixels. Every remaining cluster is explained below. The page's only hairline is the header rule, which matches GT in position, width, and weight on the full-resolution crop; the fixture has no dashed or dotted borders. Bold headings and the monospace runs carry the same weight and style as GT.

Deviation audit

Check Result
Page count/order Matches GT - two pages in the same order
Element presence Matches GT - every heading, paragraph, command line, and the exit-code table are present
Position/size Remaining: #464 - the shaded command paragraphs lose their 12pt w:ind w:left; the page box, text column, and shading band height match GT, and the residual vertical drift comes from #463
Rotation/flip No deviation observed - the fixture contains no rotated or flipped elements
Fill Matches GT - the F4F4F4 code shading is the same color, though its left edge inherits #464
Stroke/border No deviation observed - the header rule matches GT and there are no other strokes
Text content Matches GT - all strings on both pages are preserved
Font family/weight/style Matches GT - Arial body, Courier New code, bold headings, and the italic disclaimer run all match
Text color Matches GT - the navy headings, grey header, and black body all match
Alignment Matches GT - the justified body paragraphs and centered footer match
Line/paragraph spacing Fixed - consecutive command lines now advance 10.20pt against Word's 10.08-10.32pt; Remaining: #463 for the gap between a list and the block after it
Clipping/overflow No deviation observed - no glyph is clipped and nothing overflows the text column

Checklist

  • Commits include a Signed-off-by line
  • PR scope contains one root cause
  • Remaining visual deviations each reference an open issue

Word advances every body line by the full line box - the font's hhea
single-spacing line, or the document grid pitch for East Asian text under
a w:docGrid - and measures w:spacing w:before/w:after from that box's
edges. office2pdf pinned the text edges to the font's typographic
ascender/descender and recovered the remainder as par(leading:), but Typst
inserts leading only *between* the lines of one paragraph. Every paragraph
therefore came out exactly one leading short, however many lines it had:
consecutive 9pt Courier New command lines advanced 7.2pt against Word's
10.1pt, a 28% shortfall that accumulated to 15.5pt down the page.

Span the whole advance with the line box itself, split by the font's
ascender/descender ratio, and set leading to 0. A paragraph's height is
then n * advance exactly as in Word, and the block's above/below sit where
Word measures its gaps from, so the grid-only boundary top-up added for
#394 is no longer needed.

That top-up's counterpart went the other way: the parser injected an 8pt
default w:spacing w:after whenever neither the paragraph nor its style
chain set one, which masked the missing leading. ECMA-376 leaves that gap
at zero, and a Word export of a document whose styles.xml defines no
Normal spacing confirms it, so the default is now 0pt. It stays an
explicit Some(0.0) rather than None so the paragraph block still pins its
`below` and Typst's own 1.2em default block spacing cannot leak in.

With both, per-line pitch matches the Word golden PDFs within 0.15pt and
mean absolute vertical drift over the business corpus drops from 14.61pt
to 13.52pt - 04_resume_en from 4.90pt to 0.69pt, 09_offer_letter_en from
2.57pt to 1.41pt, 07_product_spec_en from 4.56pt to 1.20pt.

The list path's metric_leading_pt is now always zero, so it becomes a
wrapper_spans_full_line flag.

Fixes #452

Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
@developer0hye
developer0hye merged commit 4a75741 into main Jul 25, 2026
32 of 33 checks passed
@developer0hye
developer0hye deleted the fix/docx-paragraph-boundary-leading branch July 25, 2026 17:11
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.

DOCX: consecutive Courier New paragraphs advance 28% tighter than Word

1 participant