Repository navigation
fix(docx): draw a text-anchored framed paragraph in its frame - #2356
Merged
Merged
Conversation
A body paragraph in a w:framePr frame hung w:y below its place in the flow (w:vAnchor="text"), with a stated w:w and w:h, was laid out as an ordinary paragraph. It took a line in the flow and was drawn at the flow position. Word draws it in its frame, out of the flow, and lays the text after it beside the frame. Such a paragraph, alone in its frame, now becomes a Block::TextFrame, following docxide-pdf's Word-probed paragraph_offset_frame. Other frames stay in the flow as before. - It is placed with a #place that has no vertical alignment, w:y below the end of the previous block's after-gap, and takes no flow space. Typst would otherwise merge the next block's before-gap above it, so that gap is laid again by a zero-height block after the frame. Frame content is top-aligned. - The root paragraphs after it wrap around it with the floating-table wrap from #2349, which judges a paragraph by its first line. A paragraph that starts above the frame but would reach it moves below the frame rather than run over it. Per-line narrowing is #2355. - A frame stays in force in a continuous section laid out in the same text area. An exact frame stops being checked once the paragraphs after it have passed its bottom edge. Related: #2231 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 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 body paragraph in a
w:framePrframe hungw:ybelow its place in the flow (w:vAnchor="text"), with a statedw:wandw:h, was laid out as an ordinary paragraph. It took a line in the flow, pushing the rest of the page down by that line, and was drawn at the flow position. Word draws it in its frame, out of the flow, and lays the text after it beside the frame.Such a paragraph, alone in its frame, is now lifted into a new IR block,
Block::TextFrame. The predicate follows docxide-pdf's Word-probedparagraph_offset_frame(src/pdf/mod.rsatfe7b8bea): text-anchored, a nonzerow:ywith now:yAlign, a stated width and height, and a wrap other thannone/notBeside. Every other frame stays in the flow as before, including #2256's empty frames.w:x/w:xAlignresolve againstw:hAnchor(page, margin, column) in section resolution. The frame is drawn with a#placethat has no vertical alignment, so Typst puts it at the flow position, and it takes no flow space. Typst merges the gaps on both sides of a placed item and lands it below the merged gap. Word hangs the frame from the end of the previous block's after-gap: docxide-pdf'sparagraph-offset-frame-exactputs its frame where the immediate case does, although the paragraph after it states a 12ptw:before. So the frame is placed with that gap collapsed above it and added to its offset, and a zero-height block after it lays the gap again, where it still merges with the next block's gap. Frame content is top-aligned, also in a vertically centred section.w:hRule="exact"fixes the height. Any other rule is taken as a least height (FIXME(unprobed height rule)).w:hSpace;w:vSpacewidens the band. A paragraph whose first line ends above the frame but which would reach it is moved below the frame. Word instead narrows only the lines beside the frame. This is a stopgap: without it, the first-line-only wrap overprinted the frame text in docxide-pdf'simmediate-longand in two of the nine real bulk documents below. Per-line narrowing and the both-sides fill are filed as DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355. The rule is scoped to text frames, so floating-table output is unchanged.immediate-longcase needs this: its frame ends the first section. An exact frame stops being checked once the paragraphs after it have passed its bottom edge.Still open in #2231: the both-sides wrap around
empty-frame-control's empty frame, frames anchored to the page or margin (includingnotBeside/none, style-inheritedw:framePrand framed tables, such as docxidemassachusetts_community_sanitation's letterhead), groups of adjacent identical frames (theempty-margin-frame-*cases), and frames withoutw:w.Related issue
Related: #2231. Also #2355 (filed from this work), #2233, #2232, #2229, #2256.
Testing
cargo test --locked --workspace --profile ci: passes, 4,483 tests.cargo clippy --locked --workspace --all-targets -- -D warningsandcargo fmt --all -- --check: clean.crates/office2pdf/src/parser/docx_text_frame_tests.rs. I confirmed that each compiled-layout test fails with its rule disabled and passes with it:w:ybelow its anchor andw:xfrompage/margin/text, at two offsets, and the next paragraph keeps its no-frame position.w:hSpaceedge, at two heights, and lines below it return to the full column.yAlign,notBeside,none, margin or page anchoring, and a frame shared by two paragraphs all stay in the flow.tests/fixtures/docx/docxide/offset_text_frame.docx: docxide-pdf's unmodified Apache-2.0paragraph-offset-frame-immediate, SHA-2564db89768a90c73bec8fc389fe7a354688cb99caa26820226f246d5520faa39d1.80aba9ca) and candidate release binaries.w:framePr(fe7b8bea, Word references): only the threeparagraph-offset-frame-*cases change. Positions aremutool draw -F stextbaselines:immediate(the evidence below): "Dear recipient" 101.97 → 85.87 (Word 85.78). "About providing information" 85.87 → 102.67 (Word 102.53). Its x stays 71pt right (DOCX: a negative w:ind w:left is clamped to zero, so a paragraph that hangs into the margin starts 71pt right of Word #2233).exact: the frame line 85.87 → 102.67 (Word 102.53). "Dear recipient" 182.36 → 166.26 (Word 146.05); the remaining 20.2pt is the empty section-mark 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).immediate-long: the frame is drawn in place, and the long paragraph now starts at the frame's bottom edge: line 1 at 132.72, where Word has it at 85.78, with lines 2–3 at x 321.13 beside the frame. This is DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355; before, the frame took a flow line and lines 2–3 ran at full width.fixtures-v1) has nine documents the rule lifts, all LibreOffice bug-tracker files without Word references. I judged them against a LibreOffice 24.2 export, labelled as a non-native reference:tdf103544(a negative-w:yframe now at the top right),tdf133702, andtdf154703_framePrWrapSpacing(text wraps left of a right-aligned frame with a 127.6ptw:hSpace; two pages, as in LibreOffice).tdf107119: the frame sits mid-column with text on its right only, where LibreOffice fills both sides (DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355).tdf133701andtdf133924: the frame is in place, and the paragraph it hangs into moves below it, where LibreOffice wraps it on both sides (DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355). Before, these frames sat at the top in the flow.tdf125894,tdf131420andtdf154703_framePr.Visual impact
Visual audit
tests/fixtures/docx/docxide/offset_text_frame.docx(docxide-pdfcases/paragraph-offset-frame-immediate/input.docxatfe7b8bea, Apache-2.0); GT is that case'sreference.pdf(pdfinfoCreator: Microsoft Word, one A4 page;check_gt_integrity.py --source: no structural corruption)fixassets/bugfixes/issue-2231/layout-audit.jsonassets/bugfixes/issue-2231/render-clusters-page-1.jsonw:ind w:left="-1418"(−70.9pt); here that is clamped to zero (DOCX: a negative w:ind w:left is clamped to zero, so a paragraph that hangs into the margin starts 71pt right of Word #2233). Fourteen of the sixteen diff clusters are this displaced line.assets/bugfixes/issue-2231/gt.jpgassets/bugfixes/issue-2231/before.jpgassets/bugfixes/issue-2231/after.jpgVisual comparison
Required inspection
Deviation audit
Final performance pass
Setup:
/usr/bin/time -f "%e %M". Release CLIs were built with--lockedin separate run-owned targets. Each input got 7 runs per build, base and candidate interleaved, with a 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:
offset_text_frame.docxparagraph-offset-frame-immediate-long(move-below path)tdf133924(real frame document)tests/fixtures/docx/10-pages.docx(no frame)The no-frame control moves by the same 2–3 MiB as the small inputs, which is variance between builds.
Scoped measurements, not product SLAs.
Checklist
Signed-off-byline🤖 Generated with Claude Code