Skip to content

fix(docx): pass a dropped section mark's w:after on to the next continuous section - #2381

Merged
developer0hye merged 2 commits into
mainfrom
fix/docx-section-mark-spacing
Oct 20, 2026
Merged

developer0hye merged 2 commits into
mainfrom
fix/docx-section-mark-spacing

Conversation

@developer0hye

Copy link
Copy Markdown
Owner

File submission policy

Before attaching or committing files, read the submission policy.
Complete a security review and obtain your organization's internal approval for
public sharing where applicable. You are responsible for lawful disclosure;
the project and maintainers disclaim related liability to the extent permitted
by applicable law.

  • Any submitted sample files or attachments satisfy the submission policy, or none are submitted.

Summary

Second milestone of #2232: the spacing around an empty section mark that takes no line, before a continuous section.

Word lays the next section's first paragraph out after the previous paragraph's whole w:after, plus only the part of its own w:before beyond the mark's w:after: gap = previous after + max(0, next before − mark after). This is docxide-pdf's section_break_spacing (fe7b8bea, src/pdf/mod.rs ~5666), from Word probes. Typst collapsed the two neighbouring gaps to their maximum instead. Where the next section opened a #columns or #move frame, the first paragraph's w:before was lost entirely, because it fell at the start of the frame's region.

Related issue

Related: #2232, #2372, #1874. Also #2371 (first milestone), #2256.

Testing

  • cargo test --locked --workspace --profile ci: 4,508 passed, 0 failed.
  • cargo fmt --all -- --check and cargo clippy --locked --workspace --all-targets -- -D warnings: clean.
  • New failing-first tests in docx_section_mark_tests.rs, both seen against main's code before the fix:
    • a_dropped_section_mark_absorbs_only_its_own_after_of_the_next_before compiles the layout and measures the "Before"→"Next" baseline gap against a zero-spacing control. It uses eight (after, mark after, next before) triples: (12, 8, 10) → 14 (docxide's Word probe), (12, 0, 10) → 22 (covid's shape), (4, 0, 10) → 14, (6, 2, 4) → 8, (0, 0, 10) → 10, (10, 20, 15) → 10, (10, 10, 0) → 10 and (0, 4, 0) → 0. Each runs in one column, in two columns and in a section that later breaks the page, as a plain paragraph and as a list item. Main gives 12 for the first.
    • a_dropped_section_mark_after_a_page_break_leaves_the_next_page_top_alone checks that a 10pt w:before after a page break and a dropped mark still adds nothing at the new page's top. That is the shape of LibreOffice's tdf170119_bottomSpacing test. It passes on main and fails with the page-break guard removed.
  • Reported file against its Word reference: docxide-pdf scraped/covid_insomnia_mental_health (fe7b8bea, Creator: Microsoft Word), page 1, mutool draw -F stext baselines, main → this PR (Word):
    • "1. Introduction" 642.68 → 652.68 (650.45). The 12pt after, 0pt mark and 10pt before give 12 + 10 = 22pt, where main gave 12.
    • Column 2's first line is unchanged at 641.54 (638.93). Both columns now start the same 2.2–2.6pt below Word's place; see Remaining.
  • Corpus scan. Run-owned release binaries: main 19436ae1 (two byte-identical builds) and this branch. Inputs: 2,370 DOCX files (tracked fixtures, docxide-pdf fe7b8bea cases/ and scraped/, the fixtures-v1 bulk corpus). No exit status changed and only 2 PDFs differ:
    • covid_insomnia_mental_health, as above. A survey of every dropped mark before a continuous section in the corpus finds it is the only Word-referenced file where the rule differs from Typst's maximum.
    • LibreOffice's tdf90697_complexBreaksHeaders (no Word export). Three continuous starts open a #move or #columns frame for changed margins. At two of them (after 0, mark 0, before 3.1 / 7.4pt) main lost the w:before at the frame's top, and the paragraph now moves down by exactly 3.10 / 7.40pt. At the third (13.2, 13.2, 2.9pt) main added the 2.9pt on top of the 13.2pt, and the paragraph now moves up 2.90pt. All three now equal the rule's answer. The page count is unchanged at 7.

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: A continuous section's first paragraph after a dropped section mark now sits where Word puts it.

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
  • Used Codex/Claude vision to inspect the full GT/output pages, diff, and matched crops
  • Inspected matched region crops at full resolution
  • Ran compare_layout.py --audit --fine-shift PT and dispositioned every fine/large text-instance shift, rectangle geometry deviation, painted-text visibility mismatch, and visible-fill occlusion
  • Ran compare_render.py --cluster-report PATH --strict-clusters and dispositioned every material 5% fuzz diff cluster by explicit ID
  • Inventoried hairlines and border dash styles
  • Inventoried font weight, italic, and underline emphasis

Deviation audit

Check Result
Page count/order Remaining: #2374, #2373, #2375, #2377, #2063. Page 1, the compared page, matches. The whole document runs to 6 pages against Word's 5: page 6 holds only the three trailing empty paragraphs, pushed over by long body lines (#2374), pages that hold less text (#2373), taller tables (#2375), an unbroken cell token (#2377) and wraps (#2063).
Element presence Remaining: #2379, #2373, #2376. All text is present. Page 1 draws an extra rule under "Keywords" (#2379). Pages 2–5 show section 1's two-line header and three-item footer instead of section 2's one-line header and page number (#2373). Tables 2 and 4 miss their banded-row rules (#2376).
Position/size Remaining: #2374, #2378, #2062, #2141, #2375, #2380, #1874. This PR fixes "1. Introduction": 652.68pt against Word's 650.45 (main 642.68). Column tops sit 2.2–2.8pt low (#1874). Column 2's lines drift with the line pitch (#2374). The footer sits 13.47pt low (#2062), with its left item 177pt right (#2378). The affiliation superscripts sit 1.3pt high (#2141). Table rows are taller (#2375). The bivariate list items start at the column edge (#2380).
Rotation/flip No deviation observed.
Fill No deviation observed: no page has fills.
Stroke/border Remaining: #2379, #2376, #2375, #1874. Page 1 has an extra Keywords rule, and the group's rule sits 1.04pt low (#2379, #1874). Banded table rules are missing (#2376). Table header text crosses the top rule (#2375). The header rule sits 0.17pt low (#1874). All rules are solid hairlines on both sides.
Shape outline geometry No deviation observed: no page has shapes.
Text content Matches GT: page 1's non-space characters are identical to Word's; one space differs, as on main.
Font family/weight/style Remaining: #2141, #2337. Calibri and Times New Roman are painted as metric-compatible Carlito and Liberation Serif, and bold and italic match, with two exceptions. The author superscripts use the face's superscript glyphs (#2141). Reference [18]'s label is upright where Word italicises it (#2337).
Text color Matches GT: black text on white on every page.
Alignment Remaining: #2378, #2380, #2063, #2297, #1874. The footer's left item is drawn 177pt right (#2378). The list items start at the column edge (#2380). Justified lines wrap differently (#2063). A reference URL breaks after "https://" (#2297). "Introduction" starts 2.43pt left of Word's (#1874).
Line/paragraph spacing Remaining: #2374, #2375. This PR fixes the gap after the dropped section mark: 12 + 10 = 22pt, as in Word. Body lines advance 11.5pt against 10.93 (#2374), and table-cell rows grow (#2375).
Clipping/overflow Remaining: #2377. "OR value" runs 11.6pt past the right margin, and "2.317" runs over the next cell.

Final performance pass

  • Status: Complete
  • Measurements: elapsed time and peak RSS, medians of 10 interleaved runs per input and binary (/usr/bin/time -f "%e %M").
    • Release CLI (cargo build --locked --release -p office2pdf-cli) on an Intel i5-10400, 12 threads, 32 GB, Linux 7.0.0, with Liberation, Carlito and Caladea and no Microsoft core fonts. Warm page cache.
    • Binaries: two separately built, byte-identical baselines from main 19436ae1 (base, base2), and the candidate. The candidate is byte-identical to a fresh release build of the PR head's sources.
    • Inputs: covid_insomnia_mental_health (evidence, 5 pages) and feminist_voice_dissertation (105 pages), both docxide scraped/; tdf90697_complexBreaksHeaders (bulk, 7 pages, the other mover); tracked spaced_section_mark.docx and section_alignment_continuous.docx.
    • Median elapsed / peak RSS for base, base2, cand:
      • covid: 0.520s / 67.8 MiB, 0.520s / 67.9 MiB, 0.520s / 65.9 MiB
      • tdf90697: 0.580s / 79.9 MiB, 0.580s / 79.8 MiB, 0.580s / 78.5 MiB
      • spaced mark: 0.270s / 42.7 MiB, 0.270s / 42.6 MiB, 0.270s / 40.4 MiB
      • continuous alignment: 0.420s / 43.6 MiB, 0.420s / 43.5 MiB, 0.420s / 41.9 MiB
      • 105-page thesis: 1.000s / 140.1 MiB, 1.000s / 137.5 MiB, 1.010s / 137.3 MiB
  • Outcome and revalidation: no measurable change, so no optimization is warranted.
    • Elapsed time is equal within 10ms.
    • The candidate's peak RSS is up to 2.3 MiB lower on the small inputs. The two identical baselines differ by up to 2.6 MiB, so that is within build-to-build variation, not a gain.
    • The change adds one Option<f64> per paragraph style and, per continuous section, a scan to the first paragraph block.
    • No optimization code was changed, so the visual comparison and test suite above stand as the final revalidation.

Checklist

  • Commits include a Signed-off-by line
  • Final memory/speed pass completed after visual acceptance, or no conversion runtime impact is explained
  • PR scope contains one root cause
  • Remaining converter or harness deviations each reference an open issue

🤖 Generated with Claude Code

developer0hye and others added 2 commits October 20, 2026 18:05
…nuous section

An empty section mark that takes no line before a continuous section still
shapes the gap. Word lays the next section's first paragraph out after the
previous paragraph's whole w:after, plus only the part of its own w:before
beyond the mark's w:after: after + max(0, before - mark after), as
docxide-pdf's Word-probed section_break_spacing has it. Typst collapsed the
two gaps to their maximum instead. Where the next section opened a #columns
or #move frame, the w:before fell at the frame's region start and was lost.

The parser now hands the dropped mark's resolved w:after to the next
section's first paragraph (ParagraphStyle::section_mark_after). A mark right
after a page or column break hands nothing on, because the section then
opens at a page or column top. The renderer writes the residual before-gap
as fixed space in front of that paragraph, in the first column when the
section has columns, and zeroes the paragraph's own before-gap. Typst's weak
block spacing looks past fixed space, so the previous block's below-gap
survives whole.

docxide-pdf scraped/covid_insomnia_mental_health (Word reference, fe7b8bea):
"1. Introduction" 642.68 -> 652.68pt (Word 650.45). Of 2,370 corpus DOCX
files, only that one and LibreOffice's tdf90697_complexBreaksHeaders change;
the latter's three moved starts now equal the rule's gap.

Related: #2232

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
Word-referenced evidence for the dropped section mark's spacing on docxide-pdf
scraped/covid_insomnia_mental_health page 1: GT, before and after renders, the
page-1 layout audit and the strict render-cluster report. It replaces the
first milestone's evidence for #2232, as earlier multi-milestone issues did.

Also the compare images for the issues filed during the audit: #2372 (the
new-page counterpart of this rule, from docxide-pdf case25) and #2373-#2380
(pre-existing deviations on the same document).

Related: #2232, #2372, #2373, #2374, #2375, #2376, #2377, #2378, #2379, #2380

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
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