Repository navigation
fix(docx): lay text beside a floating table instead of over it - #2349
Merged
Merged
Conversation
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>
This was referenced Oct 19, 2026
This was referenced Oct 19, 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.
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.
Summary
A DOCX
w:tblpPrtable 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, andw:jcand 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/-bottommetadata labels. Each root paragraph after it is wrapped in a layout-timecontexthelper (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 ownw: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'sFloatZone/min_wrap_w. Otherwise the paragraph moves below. 72pt is an empirical value, markedFIXME(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:rightFromTextare now parsed.This is the first milestone of #2229, scoped to tables whose drawn position is already right:
vertAnchor="page"/"margin"tables. It also applies around avertAnchor="text"table that opens a page-starting section, where its anchor paragraph's top is the text area's top. Elsewhere a text-anchored table is still drawn from the top margin (DOCX: a w:tblpPr floating table with w:vertAnchor="text" is positioned from the top margin instead of its anchor paragraph #2236). A first candidate wrapped around those mispositioned tables too. That reflowed 64 pages of a 69-page real document by about 370pt, and moved a form's title to the page foot. The gate removes both, and is a one-line removal once DOCX: a w:tblpPr floating table with w:vertAnchor="text" is positioned from the top margin instead of its anchor paragraph #2236 lands.topFromText/bottomFromText, which docx-rs does not parse. Per-line narrowing: a paragraph is judged at its first line, so one that starts beside the table stays narrowed below it. Multi-column sections. Paragraphs that emit no block. The compat-15 rule for indented empty paragraphs (theempty-float-*cases need DOCX: a w:tblpPr floating table with w:vertAnchor="text" is positioned from the top margin instead of its anchor paragraph #2236 first).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 warningsandcargo fmt --all -- --check: clean.crates/office2pdf/src/parser/docx_floating_table_wrap_tests.rsfail onmainand pass here:w:jc.tests/fixtures/docx/docxide/floating_table_wrap.docx. That is docxide-pdf's unmodified Apache-2.0leading-table-clear-leading-no-clear, SHA-256c3f94e718f0ab6793cfa7f3f36d6b6c058ac156081bf0848d8d7cd17605759f9.a9c2f86f) and candidate release binaries: tracked fixtures, docxide-pdfcases/andscraped/atadb8c2b8, and thefixtures-v1bulk corpus. Exit statuses are unchanged. Only files containingw:tblpPrchanged. With the gate, 21 PDFs change; every mover was measured against its Word reference (mutool draw -F stext):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.polish_municipal_letter: the letter body moves below its letterhead table, for example "OBWIESZCZENIE" 121.77 → 200.31, Word 201.89.w:clear="all"cases (leading-table-clear-{leading,empty,space-only,trailing}-clear,clear-break-lines-{one,two},repeated-clear-tab-*): the text after the clearing break now lies beside the table. Before, it lay at full width over the table; Word puts it below. For example, "Public body paragraph" moves from 72.00/132.96 to 322.00/132.96, while Word has it at 72.03/193.32. This is exactly DOCX: w:br w:clear="all" is treated as a plain line break, so text after it stays beside a floating table instead of resuming below it #2230's title, which depends on this float geometry.section-mark-after-table-float-compat14/15: "Public greeting" 96.74 → 128.99, Word 104.80. Word drops the empty section-mark paragraph that follows the table, but office2pdf keeps its 24.2pt line (DOCX: an empty section-mark paragraph's line is kept or dropped by whether its w:pPr is bare, not by its position in the section #2232). Before, that kept line happened to land the greeting near Word, because the table took no space. Without the kept line, the greeting would sit at the table's bottom, about 104.7pt.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.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 tomainunder the gate.w:tblpPrdocuments found no layout non-convergence. The only other warning was pre-existing onmain.Visual impact
Visual audit
tests/fixtures/docx/docxide/floating_table_wrap.docx(docxide-pdfcases/leading-table-clear-leading-no-clear/input.docxatadb8c2b8, Apache-2.0); GT is that case'sreference.pdf(pdfinfoCreator: Microsoft Word, one A4 page;check_gt_integrity.py: no structural corruption)fixassets/bugfixes/issue-2229/layout-audit.jsonassets/bugfixes/issue-2229/render-clusters-page-1.jsonassets/bugfixes/issue-2229/gt.jpgassets/bugfixes/issue-2229/before.jpgassets/bugfixes/issue-2229/after.jpgVisual comparison
Required inspection
Deviation audit
Final performance pass
/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--lockedin 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:
leading-table-clear-leading-no-clearpolish_municipal_letter(letterhead table)tests/fixtures/docx/10-pages.docxScoped measurements, not product SLAs.
w:beforeplus 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.w:tblpPrcorpus PDFs are byte-identical to the visually accepted candidate, so the evidence above stands. The full test suite was re-run and passes.Checklist
Signed-off-byline🤖 Generated with Claude Code