Skip to content

fix(docx): lay text beside a floating table instead of over it - #2349

Merged
developer0hye merged 1 commit into
mainfrom
fix/docx-floating-table-wrap
Oct 19, 2026
Merged

developer0hye merged 1 commit into
mainfrom
fix/docx-floating-table-wrap

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

A DOCX w:tblpPr table was drawn with a bare #place, which excludes nothing. The paragraphs after it were therefore laid out at full width over the table. Word treats the table as an exclusion region. A line inside the table's vertical extent lies in the room beside the table, and w:jc and indents resolve against that room. When neither side leaves room, the line moves below the table.

Typst has no text wrap, and which lines meet the table is only known after layout. So the placed table now carries -top/-bottom metadata labels. Each root paragraph after it is wrapped in a layout-time context helper (o2p-float-wrap). The helper asks whether the paragraph's first line overlaps the table's extent on the same page. If it does, the paragraph is padded off the table's side, or pushed below the table plus its own w:before. The side is chosen from the frame at codegen. It is the wider side, if at least 72pt is free there, as in docxide-pdf's FloatZone/min_wrap_w. Otherwise the paragraph moves below. 72pt is an empirical value, marked FIXME(unprobed minimum wrap width): the Word references only separate no room from 162pt and more. The wrap goes inside the paragraph's own block, so its spacing is untouched. w:leftFromText/w:rightFromText are now parsed.

This is the first milestone of #2229, scoped to tables whose drawn position is already right:

Related issue

Related: #2229. Also #2148, #2230, #2232, #2236 (see the corpus scan below).

Testing

  • cargo test --locked --workspace --profile ci: passes.
  • cargo clippy --locked --workspace --all-targets -- -D warnings and cargo fmt --all -- --check: clean.
  • New compiled-layout tests in crates/office2pdf/src/parser/docx_floating_table_wrap_tests.rs fail on main and pass here:
    • Text beside a left table, at two widths: left, centred and right w:jc.
    • Text beside a right table.
    • Lines below the band return to full width, at two heights.
    • A margin-anchored table down the page, at two offsets.
    • Paragraph spacing unchanged against a no-table control.
    • Move-below for a full-width table and for one leaving a 22pt strip.
    • The issue's case, read from the tracked fixture tests/fixtures/docx/docxide/floating_table_wrap.docx. That is docxide-pdf's unmodified Apache-2.0 leading-table-clear-leading-no-clear, SHA-256 c3f94e718f0ab6793cfa7f3f36d6b6c058ac156081bf0848d8d7cd17605759f9.
  • Corpus scan. I converted 2,342 DOCX files with run-owned base (a9c2f86f) and candidate release binaries: tracked fixtures, docxide-pdf cases/ and scraped/ at adb8c2b8, and the fixtures-v1 bulk corpus. Exit statuses are unchanged. Only files containing w:tblpPr changed. With the gate, 21 PDFs change; every mover was measured against its Word reference (mutool draw -F stext):
    • Toward Word:
      • leading-table-clear-leading-no-clear (this PR's evidence).
      • fullwidth-float-empty-clear: "Following paragraph" 95.33 → 106.82, Word 108.30. The remaining 1.5pt is Tahoma line metrics; pdffonts: the GT embeds Tahoma, this host paints Liberation Sans.
      • case113/114: "Body marker" x 72.00 → 432.00, Word 432.93.
      • case115: y 72.51 → 132.51, Word 152.61.
      • case116/117: y 105.20 → 165.20, Word 181.86. The rest is the text-anchored second table drawn over the first (DOCX: a w:tblpPr floating table with w:vertAnchor="text" is positioned from the top margin instead of its anchor paragraph #2236).
      • polish_municipal_letter: the letter body moves below its letterhead table, for example "OBWIESZCZENIE" 121.77 → 200.31, Word 201.89.
    • Further from Word, tracked:
    • Without Word references (LibreOffice 24.2 export used as a reference, labelled):
      • tdf115883: body text below its page-anchored table at 160.60 (was 57.60; LibreOffice 162.73).
      • n775899: "This is some text" beside the table at 364.50 (LibreOffice 358.95). Its second line stays narrowed where LibreOffice returns it to full width: the per-line limitation above.
    • The nine empty-float-* cases and the first candidate's croatian, east_asia, case130, case152–169, case32/40/45/46 and case174/175 movers are byte-identical to main under the gate.
    • A diagnostic build printing Typst warnings over all 173 w:tblpPr documents found no layout non-convergence. The only other warning was pre-existing on main.

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: text after a w:tblpPr floating table is laid out at full width over the table instead of beside it #2229
  • Fixture: tests/fixtures/docx/docxide/floating_table_wrap.docx (docxide-pdf cases/leading-table-clear-leading-no-clear/input.docx at adb8c2b8, Apache-2.0); GT is that case's reference.pdf (pdfinfo Creator: Microsoft Word, one A4 page; check_gt_integrity.py: no structural corruption)
  • Page(s): 1
  • Renderer and DPI: pdftoppm, 150 DPI
  • Evidence mode: fix
  • Baseline failure report: None
  • Layout audit report: assets/bugfixes/issue-2229/layout-audit.json
  • Render cluster reports: assets/bugfixes/issue-2229/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: DOCX: explicit legacy-mode tables start one cell margin too far right #2148
  • Layout audit fine shifts: DOCX: explicit legacy-mode tables start one cell margin too far right #2148
  • 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 150 DPI line crops; the greeting and body text now lie beside the table as in Word.
    • Before: "Public greeting" was centred across the whole column (x 255.08) and "Public body paragraph" started at the margin. Both sat inside the table's box, beside the cell text "Public floating topic"/"Second topic line".
    • After: the greeting is centred in the room right of the table (x 380.08, Word 377.65) and the body starts at the room's edge (x 322.00, Word 317.38), on Word's lines (dy within 0.16pt).
    • The cell text is drawn 5.47pt right of Word. The table's left edge sits at the margin instead of one cell margin left of it (DOCX: explicit legacy-mode tables start one cell margin too far right #2148). The room beside the table starts from the table's right edge, so the body inherits that offset (+4.62pt) and the centred greeting half of it (+2.43pt). Every diff cluster is one of these displaced lines.
    • Both sides paint the same regular serif at 14pt: Times New Roman in Word, the metric-compatible Liberation Serif here. All text is black. There are no borders, rules, fills, hairlines, dashes, bold, italic or underline.
  • GT: assets/bugfixes/issue-2229/gt.jpg
  • Before: assets/bugfixes/issue-2229/before.jpg
  • After: assets/bugfixes/issue-2229/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 four lines present
Position/size Remaining: #2148 cell text +5.47pt, body +4.62pt and greeting +2.43pt right of Word; all lines now sit on Word's side of the table and on Word's lines (dy within 0.16pt)
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 Remaining: #2148 the greeting is centred in the room beside the table, whose edge inherits the table's 5.4pt left-edge offset
Line/paragraph spacing Matches GT: 20pt line pitch, dy within 0.16pt
Clipping/overflow No deviation observed

Final performance pass

  • Status: Complete
  • Measurements: 7 interleaved runs per build with /usr/bin/time. Medians: the reported file 0.27s/41.8 MiB (base) vs 0.27s/43.4 MiB; a real letter with a letterhead table 0.41s/48.8 MiB vs 0.42s/52.1 MiB; the 10-page control 0.45s/49.7 MiB vs 0.45s/51.8 MiB; a 108-page stress document 0.60s/86.8 MiB vs 0.96s/142.4 MiB after the optimization (1.39s/291.8 MiB before it). The full table is below.
    • Setup: /usr/bin/time -f "%e %M", release CLIs built with --locked in separate run-owned targets, 7 runs each, base and candidate interleaved, warm page cache (one discarded warm-up conversion per build). 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 leading-table-clear-leading-no-clear 0.27s (0.26–0.27), 41.8 MiB 0.27s (0.26–0.27), 42.6 MiB 0.27s (0.26–0.27), 43.4 MiB
      docxide polish_municipal_letter (letterhead table) 0.41s (0.41–0.42), 48.8 MiB 0.42s (0.42–0.43), 50.8 MiB 0.42s (0.42–0.42), 52.1 MiB
      Stress: a page-top table, then 1,500 multi-line paragraphs (108 pages) 0.60s (0.60–0.61), 86.8 MiB 1.39s (1.38–1.40), 291.8 MiB 0.96s (0.95–0.98), 142.4 MiB
      Stress with a page break after the first paragraph (one wrapped paragraph) 0.61s (0.61–0.62), 88.3 MiB — 0.61s (0.60–0.62), 90.3 MiB
      Control tests/fixtures/docx/10-pages.docx 0.45s (0.45–0.45), 49.7 MiB 0.46s (0.45–0.46), 50.7 MiB 0.45s (0.45–0.47), 51.8 MiB
    • Scoped measurements, not product SLAs.

  • Outcome and revalidation: one optimization kept. Each wrapped paragraph is a layout-time query, and the first candidate wrapped every root paragraph after a table until the next break, 1,500 in the stress document. A paragraph can only meet the table on the table's own page. So a table is now dropped once the wrapped paragraphs' least advance (w:before plus one line, shortened by a sub-single multiple) exceeds the page's text height. That cut the stress case from 1.39s/292 MiB to 0.96s/142 MiB.
    • The rest is Typst's iterative layout: consecutive wrapped paragraphs each depend on the position of the one before, which takes extra passes. With a single wrapped paragraph the cost disappears (0.61s vs 0.61s). Avoiding it would need a different wrap design, not a simple change.
    • Revalidation after the optimization: the stress PDF and all 173 w:tblpPr corpus PDFs are byte-identical to the visually accepted candidate, so the evidence above stands. The full test suite was re-run and passes.

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

A w:tblpPr table was drawn with a bare #place, so the paragraphs after
it ran at full width over the table. Word lays a line that falls within
the table's vertical extent in the room beside it, resolving w:jc and
indents against that room, and moves it below the table when neither
side leaves room.

Typst has no text wrap, so the placed table carries top and bottom
labels, and each root paragraph after it checks at layout time whether
its first line meets the table on the same page. It is then padded off
the wider side (at least 72pt, docxide-pdf's empirical min_wrap_w) or
pushed below the table plus its own before-gap. w:leftFromText and
w:rightFromText are parsed.

Exclusion is applied only where the table is drawn where Word puts it:
page- and margin-anchored tables, and text-anchored ones that open a
page-starting section. Elsewhere a text-anchored table is still drawn
from the top margin (#2236), and wrapping around it reflowed whole
documents. A table stops being checked once the paragraphs after it
have advanced a page, which bounds the layout-time cost.

Related: #2229

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