Skip to content

fix(docx): draw a text-anchored framed paragraph in its frame - #2356

Merged
developer0hye merged 1 commit into
mainfrom
fix/docx-body-frame-position
Oct 19, 2026
Merged

developer0hye merged 1 commit into
mainfrom
fix/docx-body-frame-position

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 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, 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-probed paragraph_offset_frame (src/pdf/mod.rs at fe7b8bea): text-anchored, a nonzero w:y with no w:yAlign, a stated width and height, and a wrap other than none/notBeside. Every other frame stays in the flow as before, including #2256's empty frames.

  • Placement. w:x/w:xAlign resolve against w:hAnchor (page, margin, column) in section resolution. The frame is drawn with a #place that 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's paragraph-offset-frame-exact puts its frame where the immediate case does, although the paragraph after it states a 12pt w: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)).
  • Text beside the frame. The root paragraphs after it wrap with fix(docx): lay text beside a floating table instead of over it #2349's layout-time float wrap. A paragraph whose first line meets the frame is padded off its wider side, using w:hSpace; w:vSpace widens 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's immediate-long and 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.
  • Reach. A text frame stays in force in a following continuous section laid out in the same text area. The issue's immediate-long case 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 (including notBeside/none, style-inherited w:framePr and framed tables, such as docxide massachusetts_community_sanitation's letterhead), groups of adjacent identical frames (the empty-margin-frame-* cases), and frames without w: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 warnings and cargo fmt --all -- --check: clean.
  • New tests in 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:
    • The frame is drawn w:y below its anchor and w:x from page/margin/text, at two offsets, and the next paragraph keeps its no-frame position.
    • The frame hangs from the previous paragraph's after-gap, not the next one's before-gap. This holds for three gap pairs, with the next paragraph in the same section and in a following continuous section.
    • The frame's content is top-aligned in a vertically centred section, at two frame heights.
    • Lines meeting a left or right frame lie beside it at the w:hSpace edge, at two heights, and lines below it return to the full column.
    • An exact frame keeps text off its stated height, and an at-least frame off its taller content.
    • A paragraph reaching the frame from above starts below it, while a one-line paragraph above it stays put, at two offsets.
    • The frame excludes text in a following continuous section with the same margins, but not in one with different margins.
    • Parser controls: missing height, width or offset, yAlign, notBeside, none, margin or page anchoring, and a frame shared by two paragraphs all stay in the flow.
    • The issue's case, read from the tracked fixture tests/fixtures/docx/docxide/offset_text_frame.docx: docxide-pdf's unmodified Apache-2.0 paragraph-offset-frame-immediate, SHA-256 4db89768a90c73bec8fc389fe7a354688cb99caa26820226f246d5520faa39d1.
  • Corpus scan. I used run-owned base (80aba9ca) and candidate release binaries.

Visual impact

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

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 Matches GT: one page
Element presence Matches GT: all three lines present
Position/size Remaining: #2233 the framed line is on Word's baseline (dy +0.14pt) but 71.03pt right, its negative left indent clamped; the other lines match within 0.09pt
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: #2233 the framed line starts at the frame's left edge plus its 78pt first-line indent, without its -70.9pt left indent
Line/paragraph spacing Fixed: "Dear recipient" on the line after the table (85.87, Word 85.78; was 101.97), the framed line 16.8pt below it (102.67, Word 102.53; was 85.87)
Clipping/overflow No deviation observed

Final performance pass

  • Status: Complete
  • Measurements: 7 interleaved runs per build. Medians: the reported file 0.27s/40.0 MiB (base) vs 0.27s/42.6 MiB; a real frame document 0.40s/42.1 MiB vs 0.41s/45.3 MiB; the 10-page control 0.45s/48.8 MiB vs 0.45s/51.3 MiB; a 94-page stress document 0.59s/83.3 MiB vs 0.76s/112.4 MiB after the optimization (0.78s/116.4 MiB before it). 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, 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:

      Input Base Candidate before optimization Candidate
      Reported file offset_text_frame.docx 0.27s (0.26–0.27), 40.0 MiB 0.27s (0.26–0.27), 42.7 MiB 0.27s (0.26–0.28), 42.6 MiB
      docxide paragraph-offset-frame-immediate-long (move-below path) 0.27s (0.26–0.27), 40.0 MiB 0.26s (0.26–0.27), 41.2 MiB 0.27s (0.26–0.27), 42.7 MiB
      Bulk tdf133924 (real frame document) 0.40s (0.40–0.41), 42.1 MiB 0.40s (0.40–0.41), 45.1 MiB 0.41s (0.40–0.42), 45.3 MiB
      Stress: a text frame 100pt down, then 1,500 three-line paragraphs (94 pages) 0.59s (0.58–0.60), 83.3 MiB 0.78s (0.78–0.79), 116.4 MiB 0.76s (0.75–0.78), 112.4 MiB
      Control tests/fixtures/docx/10-pages.docx (no frame) 0.45s (0.45–0.46), 48.8 MiB 0.45s (0.45–0.46), 51.4 MiB 0.45s (0.45–0.47), 51.3 MiB
    • 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.

  • Outcome and revalidation: I kept one small optimization. A text frame stayed in force, as a floating table does, until the wrapped paragraphs had advanced a page. The paragraphs after an exact frame start at its anchor, so a frame is now dropped once they have passed its bottom edge. That cut the stress case from 0.78s/116.4 MiB to 0.76s/112.4 MiB.
    • The rest of the stress cost is Typst re-laying out everything after the first wrapped paragraphs once their layout-time wrap resolves. With the same frame at the document's end, both builds take 0.59s. This is the cost fix(docx): lay text beside a floating table instead of over it #2349 measured for tables, and avoiding it needs a different wrap design, not a simple change.
    • Revalidation after the optimization:
      • The evidence page renders pixel-identical at 150 and 300 DPI, and the committed evidence was regenerated from the final binary.
      • The strict render-cluster audit passes again (16/16).
      • The 24 docxide frame documents, the 9 bulk documents and the stress PDF have unchanged text layers.
      • 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 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>
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