Skip to content

fix(docx): keep an empty top-margin frame's box above a full-width table - #2361

Merged
developer0hye merged 1 commit into
mainfrom
fix/docx-empty-frame-box
Oct 20, 2026
Merged

developer0hye merged 1 commit into
mainfrom
fix/docx-empty-frame-box

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

This is the fourth #2231 milestone. In compatibility mode 15, an empty frame's box at the top margin now pushes a full-width table below it.

Problem. Since #2256 and #2357, empty body paragraphs that share a w:framePr frame take no flow lines. Word 2013 and later still keep that frame's box at the top margin, though, when the frame states no w:vAnchor (or margin), and a full-width table on that page starts below the box. Word's exports of docxide-pdf's empty-margin-frame-* cases (fe7b8bea, pdfinfo Creator: Microsoft Word) show this:

  • In compatibility mode 15 the table starts one line lower per framed paragraph (12pt or 24pt). That holds even when visible text comes between the table and the frame (visible-body-before-frame).
  • In mode 14 the table stays at the top margin.
  • Hidden paragraph marks (hidden-marks), the paragraph that ends the section, and a box wholly beside the text area (off-page) add nothing.

office2pdf left the table at the top margin in every mode, 12 to 24pt above Word.

Change.

  • Parser: in mode 15, the visible lines of such a group become a TextFrame with a new vertical_anchor: FrameAnchor::Margin. Dropping a box beside the text area happens during section resolution. Mode 14, and documents that declare no mode, are unchanged.
  • Renderer: the box is drawn invisibly from the top of the text area, with metadata labels on its top and bottom edges. Each full-width table at the root of the same section asks at layout time whether it starts inside a box on its page. If it does, a block before the table moves it to the box's bottom edge. The gap above the table and any rows carried onto the next page are unchanged.
  • Body text keeps its place. Word lays it out on both sides of the box (empty-frame-control); that stays with DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355.

Results (baselines in pt from mutool draw -F stext):

Word case Line Before After Word
empty-margin-frame-15-two-empty (evidence) Public left 105.22 128.22 129.30
Public greeting 201.81 224.81 225.83
-15-one-empty Public left 105.22 116.72 117.28
Public greeting 201.81 213.31 213.83
-visible-body-before-frame Public left 105.22 128.22 129.30
Public greeting 217.91 240.91 242.08

Related issue

Related: #2231

Testing

  • cargo test --locked --workspace --profile ci: 4488 passed, 0 failed, rerun after the final code change (only doc comments changed after that run).
  • cargo clippy --locked --workspace --all-targets -- -D warnings and cargo fmt --all -- --check: clean.
  • New tests, each run first against the unchanged source, where all three failed for the behavioural reason (no box; the table 24pt high):
    • empty_paragraphs_in_a_top_margin_frame_keep_its_box_in_word_2013_layout: the parser rule. Mode 15 keeps one box with two lines and none for the section mark. Explicit margin also keeps it. Mode 14, an undeclared mode, hidden marks, a box beside the text area, page anchoring, a w:y, a w:yAlign, and no w:w keep none.
    • a_full_width_table_in_an_empty_frames_box_starts_below_it: compiled layout with exact 12pt frame lines. A table moves exactly 24pt with the frame after it and with the frame before it. Body text in the box does not move, nor does a table that starts below the box.
    • a_full_width_table_starts_below_an_empty_frames_box: the unmodified docxide case as a GT-backed regression.
  • Corpus scan, visual audit and performance pass as below.

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: Rendered PDF change; see the visual audit.

Visual audit

  • Issue: DOCX: a body w:framePr paragraph is laid out in the flow instead of as a positioned frame that text wraps around #2231
  • Fixture: tests/fixtures/docx/docxide/empty_frame_box.docx (docxide-pdf cases/empty-margin-frame-15-two-empty/input.docx at fe7b8bea, Apache-2.0); GT is that case's reference.pdf (pdfinfo Creator: Microsoft Word, one A4 page; check_gt_integrity.py --source: no structural corruption; it embeds Times New Roman and Tahoma, ours Liberation Serif)
  • Page(s): 1
  • Renderer and DPI: pdftoppm, 150 DPI (render-cluster audit at 300 DPI)
  • Evidence mode: fix
  • Baseline failure report: None
  • Layout audit report: assets/bugfixes/issue-2231/layout-audit.json
  • Render cluster reports: assets/bugfixes/issue-2231/render-clusters-page-1.json
  • Reference exporter differences: None
  • Fine-detail threshold: 0.5pt
  • Layout audit page count: Pass
  • Layout audit text flow: Pass
  • Layout audit visible fills: Pass
  • Layout audit rectangle geometry: Pass
  • Layout audit large shifts: Pass
  • Layout audit fine shifts: Tracker: sub-materiality visual deviations (revisit in bulk) #1874
  • New follow-up issues found in this audit: None
  • Model vision findings: I inspected the full Word, before and after pages, the 5% diff and every shifted-line crop at 150 and 300 DPI; the table and greeting now sit on Word's lines, about 1pt high.
    • Before: the table's first line was at the top margin (105.22), 24pt above Word's 129.30. The recipient column and the greeting were 24pt high with it.
    • After: the table starts below the two-line box, and all eight text lines are within 0.98–1.19pt of Word, every one of them higher. That uniform offset is the Tahoma substitute's shorter line (see Summary).
    • All 53 diff clusters are glyphs of those eight lines, doubled by that offset. They are the same glyphs at the same x, regular weight, Times New Roman against its metric-compatible Liberation Serif.
    • All text is regular, black and 14pt. There are no borders, rules, fills, hairlines, dashes, bold, italic or underline. The box itself paints nothing, in Word or here.
  • GT: assets/bugfixes/issue-2231/gt.jpg
  • Before: assets/bugfixes/issue-2231/before.jpg
  • After: assets/bugfixes/issue-2231/after.jpg
  • Native: None
  • Compare: None

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 Matches GT: one page
Element presence Matches GT: all eight lines present
Position/size Remaining: #1874 every line 0.98–1.19pt above Word (box lines in the host's Tahoma substitute; 0.06pt with Word's Tahoma subset)
Rotation/flip No deviation observed
Fill No deviation observed: white page, no fills
Stroke/border No deviation observed: no strokes or borders
Shape outline geometry No deviation observed: no shapes
Text content Matches GT: normalized text layer identical
Font family/weight/style Matches GT: Times New Roman via metric-compatible Liberation Serif, regular
Text color Matches GT: black
Alignment Matches GT: left and right column x within 0.12pt
Line/paragraph spacing Fixed: the table starts below the empty frame's box (Public left 128.22, Word 129.30; was 105.22) and the greeting follows it (224.81, Word 225.83; was 201.81)
Clipping/overflow No deviation observed

Final performance pass

  • Status: Complete
  • Measurements: 7 interleaved runs per build after one discarded warm-up. Medians: the reported file 0.27s/42.2 MiB (base) vs 0.27s/43.0 MiB. A real frame document is 0.41s/45.1 MiB vs 0.41s/45.1 MiB, and the 10-page control 0.45s/51.0 MiB vs 0.45s/51.8 MiB. Two 48-page stress documents follow: 0.82s/123.2 MiB vs 0.90s/145.8 MiB (box pushes the first table) and 0.82s/123.1 MiB vs 0.85s/127.9 MiB (300 checked tables). The full table is below.
    • Setup: /usr/bin/time -f "%e %M". Release CLIs were built with --locked in separate run-owned targets. Each input got 7 runs per build, builds interleaved, with a warm page cache. Intel i5-10400 (12 threads), Linux 7.0.0. Host fonts: Liberation substitutes, no Microsoft core fonts.

    • Results, median time (min–max) and median peak RSS:

      Input Base Candidate before optimization Candidate
      Reported file empty_frame_box.docx 0.27s (0.26–0.27), 42.2 MiB 0.27s (0.27–0.28), 43.5 MiB 0.27s (0.27–0.28), 43.0 MiB
      Bulk tdf133924 (real frame document, unchanged) 0.41s (0.40–0.41), 45.1 MiB 0.41s (0.40–0.41), 45.2 MiB 0.41s (0.40–0.42), 45.1 MiB
      Control tests/fixtures/docx/10-pages.docx (no frame) 0.45s (0.45–0.46), 51.0 MiB 0.46s (0.45–0.46), 51.6 MiB 0.45s (0.45–0.46), 51.8 MiB
      Stress A: the reported file plus 300 full-width tables in its second section (48 pages) 0.82s (0.81–0.83), 123.2 MiB 0.93s (0.92–0.94), 152.3 MiB 0.90s (0.90–0.91), 145.8 MiB
      Stress B: 300 full-width tables before the frame group, in its section (48 pages) 0.82s (0.81–0.83), 123.1 MiB 0.85s (0.84–0.85), 128.1 MiB 0.85s (0.84–0.85), 127.9 MiB
    • Small inputs move by under 1 MiB, within the variance between identical builds. Scoped measurements, not product SLAs.

  • Outcome and revalidation: scoping the table check to the box's section cut stress A from 0.93s/152.3 MiB to 0.90s/145.8 MiB; the rest is one more Typst layout iteration that no simple change removes; tests, corpus scan and visual comparison were rerun after the change.
    • Optimization. The first candidate checked every full-width table in a document that had a box. The checks now run only for tables in the box's own section, which is also how docxide-pdf's empty_frame_table_bands scopes its bands. In stress A that drops 300 checks: 0.93→0.90s and 152.3→145.8 MiB.
    • What remains. Stress A's remaining +0.08s/+22.6 MiB is one more Typst layout iteration. When the box does push a table, the position the check reads changes once more before the layout settles. Stress B's 300 checks, with no push, cost +0.03s/+4.8 MiB. That iteration is inherent to a layout-time position check, the same one the existing float wrap pays, and no simple change removes it.
    • Revalidation after the optimization. The workspace test suite passed. The corpus scan over the 76 framed documents gives the same three changed documents. The evidence PDF renders pixel-identically at 150 and 300 DPI to the audited one, and the committed layout and strict render-cluster reports were regenerated from it.

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

Empty body paragraphs that share a w:framePr frame take no flow lines
since #2256 and #2357. Word 2013 and later still keep the frame's box
at the top margin when the frame states no w:vAnchor (or "margin"),
and start a full-width table on that page below the box.

Word's exports of docxide-pdf's empty-margin-frame-15-* cases show it:
the table starts one line lower per framed paragraph (12pt or 24pt),
whether visible text comes between the table and the frame or not.
The compatibility-mode-14 exports leave the table at the top margin. A
hidden paragraph mark, the paragraph ending the section, and a box
wholly beside the text area add nothing. office2pdf left the table at
the top margin in every mode, 12 to 24pt above Word.

In compatibility mode 15, such a group's visible lines now form a
TextFrame anchored to the top margin. The renderer draws it invisibly
from the top of the text area, with labels on its edges. Each
full-width table at the root of the same section asks at layout time
whether it starts inside the box on its page, and if so a block before
it moves it to the box's bottom edge. Body text keeps its place; Word
lays it on both sides of the box, which stays with #2355.

Related: #2231

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