Skip to content

DOCX: a body w:framePr paragraph is laid out in the flow instead of as a positioned frame that text wraps around #2231

Open

Description

@developer0hye

Problem

A body paragraph with w:pPr/w:framePr is laid out as an ordinary in-flow paragraph. Word lifts it out of the flow into a frame box at the w:x/w:y offset from its w:hAnchor/w:vAnchor, w:w wide (and w:h tall when w:hRule="exact"), and the following body lines wrap around that box. office2pdf 0.9.1 instead (1) gives the framed paragraph a line in the flow, pushing the rest of the page down by one line, (2) draws its text at the flow position instead of the frame position, and (3) wraps nothing around it. This holds even for an empty framed paragraph.

Lead only: frame_property is read solely by extract_hf_frame (crates/office2pdf/src/parser/docx_sections.rs:1404), i.e. the header/footer path; the body paragraph converter never reads it.

Measurements

Public synthetic inputs (Apache-2.0) with Microsoft Word reference PDFs (pdfinfo Creator: Microsoft Word) from docxide-pdf fe7b8bea. Coordinates from mutool draw -F stext, points from the page top; office2pdf 0.9.1 (current main 3e946f31 differs only by #2219, which these cases do not exercise).

empty-frame-control

Empty paragraph, <w:framePr w:w="4659" w:wrap="auto" w:hAnchor="text" w:x="1701"/> (14pt TNR mark), then "Following paragraph". Frame box x = 72 + 85.05 = 157.05 to 390.0.

Anchor Word docxide office2pdf
"Following" baseline y 85.03 85.13 101.17 (+16.14)
"paragraph" x (wraps past the frame) 390.18 72 + same line, no wrap same line, no wrap

Word puts "Following" left of the empty frame and "paragraph" right of it at x=390.18 = frame right edge.

paragraph-offset-frame-immediate / -exact / -immediate-long

One-cell table ("Recipient"), then <w:framePr w:w="5116" w:h="601" w:hRule="exact" w:wrap="auto" w:vAnchor="text" w:hAnchor="page" w:x="1306" w:y="336"/> with <w:ind w:left="-1418" w:firstLine="1560"/> ("About providing information"), then "Dear recipient". Frame box: x 65.3 to 321.1, top = table bottom 73.3 + 16.8 = 90.1, height 30.05.

Case / anchor Word docxide office2pdf
immediate: "About" first glyph x / baseline y 72.28 / 102.53 72.40 / 102.73 163.05 / 85.87
immediate: "Dear" baseline y 85.78 85.93 101.97 (+16.19)
exact: "About" x / baseline y 72.28 / 102.53 72.40 / 102.73 163.05 / 85.87
immediate-long: wrapped lines 2-3 x 321.13 321.10 85.05 (no wrap; line breaks change)
immediate-long: line 4 (below the frame) x 85.03 85.05 85.05

In Word the frame text x is 65.3 (page x) + (-70.9 + 78.0) first-line indent = 72.4, so the frame text also depends on the negative left indent (#2233). The exact case's "Dear" is additionally off from the empty section-mark paragraph rule (#2232).

page-header-frame-marker

Section 1 is a single empty paragraph carrying <w:framePr w:w="4659" w:wrap="auto" w:hAnchor="text" w:x="1701"/> and the section's w:sectPr; section 2 is continuous. Header is one line ending ~40pt, far above the 72pt top margin, so this is not #2056.

Anchor Word docxide office2pdf
"Following section" line top 72.03 72.33 79.67
baseline y 82.03 81.38 91.94

Factoring out the face (ascent taken from the header line, whose top is 28.35pt in both: Word 9.90, office2pdf 7.94), the body line top is Word 72.1, office2pdf 84.0: Word starts the body at the top margin (frame out of flow), office2pdf lays the empty framed marker as a ~12pt line. (office2pdf's 11pt C059 face instead of 10pt Tahoma is #2234 / #2235.) Compare page-header-ordinary-marker, where the unframed marker does take a line in Word.

Scores (docxide scorer, host fonts)

Case office2pdf SSIM docxide SSIM
empty-frame-control 1.7 28.6
page-header-frame-marker 13.4 37.6
paragraph-offset-frame-exact 22.7 36.3
paragraph-offset-frame-immediate 27.3 38.3
paragraph-offset-frame-immediate-long 60.5 30.4

(immediate-long scores higher for office2pdf only because docxide substitutes a sans face for TNR; the coordinates above are the defect.)

Expected

A body paragraph (or run of adjacent paragraphs with identical w:framePr) with w:framePr is removed from the body flow and laid out in a frame: horizontal position from w:hAnchor (text/margin/page) + w:x (or w:xAlign), vertical from w:vAnchor (text = the paragraph's flow position, margin, page) + w:y (or w:yAlign), width w:w (auto = content), height w:h per w:hRule (auto = content, which for an empty paragraph is its mark's line). With w:wrap auto/around, body lines whose vertical extent intersects the frame box are laid out in the space beside it (both sides when room remains), and resume full width below it. An empty frame still occupies its box for wrapping but adds no flow height.

The Word reference PDFs supply gt.jpg for fix-mode evidence. Reference implementation lead: docxide-pdf src/pdf/mod.rs (paragraph_offset_frame, is_empty_wrapping_frame), which positions and wraps the immediate cases but does not wrap around the empty frame.

Related: #2161 (its marker paragraph also carries w:framePr).

Activity

  1. added
    docx-corpusDOCX defect found on the docxide-pdf Word-reference corpus
    on Oct 18, 2026
  2. developer0hye commented on Oct 18, 2026

    @developer0hye
    OwnerAuthor

    #2256 landed the flow-height part of this issue for empty frames. A body paragraph that is alone in a w:framePr frame Word lifts out of the flow, and has no content, now leaves no line. The rule follows docxide-pdf's is_empty_wrapping_frame: the frame states more than a bare w:wrap, is not yAlign="inline", is not none/notBeside, and has no w:h. Paragraphs, or a table, sharing an identical w:framePr keep their empty lines.

    Results with Word references:

    Still open here:

    • Positioning frame content at w:x/w:y.
    • Wrapping body lines around the frame box. empty-frame-control's "paragraph" is still not at x=390.18.
    • Frames with a stated height, notBeside or none.
    • Groups of adjacent identical frames. The empty-margin-frame-* cases are unchanged; for example, empty-margin-frame-15-one-empty puts "Public greeting" at 224.81pt against Word's 213.83pt.
  3. developer0hye commented on Oct 19, 2026

    @developer0hye
    OwnerAuthor

    #2356 (merge 3503c178) landed the second milestone of this issue: positioning frame content at w:x/w:y, for one kind of frame.

    Lifted now. A body paragraph alone in a w:framePr frame hung w:y below its place in the flow. The predicate follows docxide-pdf's Word-probed paragraph_offset_frame: w:vAnchor="text", a nonzero w:y, no w:yAlign, a stated w:w and w:h, and a w:wrap other than none/notBeside. Such a paragraph becomes a Block::TextFrame:

    • It is drawn at its frame and leaves no line in the flow. w:x/w:xAlign resolve against w:hAnchor, and the frame hangs from the end of the previous block's after-gap.
    • The root paragraphs after it wrap beside it with the float wrap from fix(docx): lay text beside a floating table instead of over it #2349 (its wider side, w:hSpace/w:vSpace).
    • A paragraph whose first line ends above the frame but which reaches it starts below the frame instead of running over it.
    • The frame keeps excluding text in a following continuous section with the same text area.

    Results against the Word references (mutool draw -F stext baselines):

    Still open here:

    #2231 stays open for these.

  4. developer0hye commented on Oct 20, 2026

    @developer0hye
    OwnerAuthor

    #2357 (merge 8d25e2d7) landed a third milestone of this issue: a group of empty framed paragraphs leaves no flow lines. CI passed on the PR and on the merge commit.

    What changed. #2256 dropped the line of an empty framed paragraph only when the paragraph was alone in its frame. Now the rule applies to the whole run of adjacent body children that share an identical w:framePr:

    • An empty paragraph leaves no line when nothing in its frame shows anything. Paragraphs holding only spaces, a hidden mark, or bookmark/comment markers count as showing nothing.
    • A frame that holds text or a table keeps its empty paragraphs' lines, as before.

    Results against Word's references ("Public greeting" baseline, pt):

    Still open here:

  5. developer0hye commented on Oct 20, 2026

    @developer0hye
    OwnerAuthor

    #2361 (merge ff37c41b) landed a fourth milestone of this issue: in compatibility mode 15, an empty frame's box at the top margin pushes a full-width table below it. CI passed on the PR and on the merge commit.

    What changed.

    • In a document that declares compatibilityMode 15 or later, a group of empty framed paragraphs still takes no flow lines. Its frame keeps a box at the top margin when the frame:
      • states no w:vAnchor (or margin);
      • has no w:y and no w:yAlign;
      • has a w:w.
    • The box has one line per visible paragraph. A hidden mark or the paragraph that ends the section adds no line, and a box wholly beside the text area is dropped.
    • A full-width table at the root of the same section that starts inside the box on its page moves to the box's bottom edge. This is checked at layout time, so it works whether the frame comes before or after the table.
    • Mode 14 and documents with no declared mode are unchanged.

    Results against Word's references (baselines, pt):

    Case Line Before After Word
    empty-margin-frame-15-two-empty 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
    -visible-body-before-frame Public left 105.22 128.22 129.30

    The remaining ~1pt is this host's Tahoma substitute, which gives the box's 10pt lines 11.50pt each against Tahoma's 12.07pt. With the Tahoma subset from Word's PDF on --font-path, "Public left" lands at 129.36. It is recorded under #1874. A scan of all 76 docxide and fixtures-v1 documents with a body w:framePr changed only these three.

    Still open here:

    • Both-sides fill beside a frame, and per-line narrowing: DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355. This covers empty-frame-control's "paragraph" at x 390.18 and the compat-14 empty-margin-frame-14-* greeting, which Word splits around the box at x 403.17.
    • Rows a table carries onto the box's page. Word's empty-margin-frame-long-prefix starts them below the box (page 2, 24pt); here they still start at the top margin.
    • empty-margin-frame-15-two-space-only: its space-only paragraphs collapse (DOCX: space-only paragraphs collapse instead of reserving their line box #2288), so its box has no height.
    • Unprobed box rules. Only full-width tables and frames at the top margin itself (no w:y/w:yAlign) have Word references. Narrower tables and offset or aligned margin frames are left alone.
    • Page- and margin-anchored frames with content, including notBeside/none; w:framePr inherited from a paragraph style; framed tables; groups that hold text. Example: docxide massachusetts_community_sanitation, page 1 about 167pt low.
    • Frames without w:w (auto width), and text-anchored frames without w:h (croatian_grant_guidelines).
    • Unverified against Word: LibreOffice draws a bordered framed paragraph's w:pBdr around the whole frame box (bulk tdf133702, tdf103544).
  6. developer0hye commented on Oct 20, 2026

    @developer0hye
    OwnerAuthor

    #2362 (merge 6d02d1e8) landed a fifth milestone of this issue: rows a table carries onto the page of an empty frame's top-margin box now start below the box. CI passed on the PR and on the merge commit.

    What changed.

    • In compatibility mode 15, a full-width root table in a box's section, directly followed by the box's empty frame group, now carries labels on its first and last pages.
    • On a page the table reaches from an earlier one, the box floats at the top of the page. The carried rows, and everything after them, start below it. Elsewhere the box takes no flow space, as before.
    • The float is laid only when the carried rows still end on that page below the box. Otherwise the float would move them, the group and the box on to the next page, and Typst's layout would not settle. In that case the rows keep their place (a TODO; no Word reference shows it).

    Results against Word's reference (empty-margin-frame-long-prefix, baselines, pt):

    Line Before After Word
    Page 1 "Public left" 105.22 105.22 105.28
    Page 2 first "Public recipient" 105.22 128.22 129.30
    Page 2 "Public greeting" 620.38 643.37 644.70

    The remaining ~1pt is the host's Tahoma substitute again: with Word's embedded Tahoma subset on --font-path, page 2 starts at 129.36 and the greeting sits at 644.52. It is recorded under #1874. A scan of every docxide, fixtures-v1 and tracked document with a w:framePr changed only this case and its tracked copy.

    Still open here:

    • Both-sides fill beside a frame, and per-line narrowing: DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355. This covers empty-frame-control's "paragraph" at x 390.18 and the compat-14 empty-margin-frame-14-* greeting, which Word splits around the box at x 403.17.
    • empty-margin-frame-15-two-space-only: its space-only paragraphs collapse (DOCX: space-only paragraphs collapse instead of reserving their line box #2288), so its box has no height.
    • Unprobed box rules. Only full-width tables, and boxes at the top margin itself (no w:y/w:yAlign), have Word references. Narrower tables, offset or aligned margin frames, content between the table and the group, and carried rows the box would push onto the next page are left alone.
    • Page- and margin-anchored frames with content, including notBeside/none; w:framePr inherited from a paragraph style; framed tables; groups that hold text. Example: docxide massachusetts_community_sanitation, page 1 about 167pt low.
    • Frames without w:w (auto width), and text-anchored frames without w:h (croatian_grant_guidelines).
    • Unverified against Word: LibreOffice draws a bordered framed paragraph's w:pBdr around the whole frame box (bulk tdf133702, tdf103544).
  7. developer0hye commented on Oct 20, 2026

    @developer0hye
    OwnerAuthor

    #2365 (merge d041a66e) landed a sixth milestone of this issue: a frame Word keeps at a fixed place on its page and keeps text off the sides of is drawn at that place, and the body text it meets starts below it. CI passed on the PR and on the merge commit.

    What changed.

    • Effective frame. A body child's w:framePr is now its paragraph style's (nearest w:basedOn ancestor stating one), with each attribute the paragraph states over it. A table's is its first cell paragraph's. A frame stating only w:wrap is no frame. The existing empty-frame, offset-frame and top-margin-box rules use the same effective frame.
    • Fixed frames. Adjacent children sharing a frame with w:vAnchor page or margin/none, w:wrap notBeside/none and no w:yAlign become one TextFrame (text_below), drawn at its page position.
    • Band. Root paragraphs after it on its page whose first line meets its band start below it. The float wrap now re-checks until a pass moves the line no further, so overlapping bands chain.
    • Kept in the flow: a frame that shows only spaces (an existing test pins it), and a frame holding lists, breaks or drawings.

    Results against Word's reference of docxide-pdf's scraped massachusetts_community_sanitation (page 1, x and baseline, pt):

    Line Before After Word
    "The Commonwealth of Massachusetts" 168.28, 153.75 194.08, 31.25 193.83, 31.00
    Seal picture in the flow 45.30, 13.10 45.25, 13.05
    "CHARLES D. BAKER" 61.41, 297.98 37.86, 135.89 35.52, 134.30
    "May 28, 2021" 394.48 232.39 227.08

    The date's 5.3pt is two residuals nearly cancelling, each now tracked:

    A corpus scan of 2,366 DOCX moved only that document and six bulk LibreOffice frame documents, all toward a LibreOffice 24.2 reference (labelled non-Word; details in #2365).

    Struck from the list: croatian_grant_guidelines' text-anchored frame without w:h. Word keeps it in the flow, as office2pdf does; only its w:x="-39" (1.95pt) shift differs, which is sub-material and recorded in #1874.

    Still open here:

    • Both-sides fill beside a frame, and per-line narrowing: DOCX: a paragraph that reaches a lifted w:framePr text frame starts below it instead of narrowing only the lines beside it #2355.
    • empty-margin-frame-15-two-space-only: DOCX: space-only paragraphs collapse instead of reserving their line box #2288.
    • Unprobed top-margin box rules (narrower tables, offset or aligned margin frames, content between the table and the group).
    • Fixed frames, remaining parts:
      • Auto width. A frame without w:w lays its paragraphs across the text width from its left edge (FIXME). Word sizes it to the widest line, which matters for centred or right-aligned lines.
      • Frames that stay in the flow: those placed by w:yAlign or by w:xAlign without w:w; those inside block-level w:sdt (bulk tdf148671, three of six); and those holding lists or drawings (bulk Tdf147485).
      • Band gaps. Body text before the frame and tables after it do not clear its band. A band reaching the page's end does not move the next paragraph to the next page (bulk tdf104394_lostTextbox).
    • Page- or margin-anchored frames with content that wrap around (auto/around), which still flow.
    • Unverified against Word: LibreOffice draws a bordered framed paragraph's w:pBdr around the whole frame box (bulk tdf133702, tdf103544, tdf100075).
  8. developer0hye commented on Oct 20, 2026

    @developer0hye
    OwnerAuthor

    Draft #2368: frames inside block-level content controls. Not merged: its only reference is LibreOffice, and this Linux host cannot export from Word. CI passes on the draft, the Visual PR Contract included.

    What #2368 does. A paragraph or table inside a block w:sdt now has its w:framePr frame laid out as it would be without the control. Body children are flattened into flow children, each control expanding into its paragraphs and tables. Every existing frame rule groups and places frames by flow index: fixed page frames, offset frames, empty frames and the compat-15 top-margin box. A test pins it for five frame kinds, in single and nested controls. In bulk tdf148671, the three controlled page frames move from the flow at the top left (x 35.45) to their frame places (x 434.30), within 1.1pt of a LibreOffice 24.2 export (LibreOffice reference, not Word). The remaining 1.1pt is the two sides' different substitutes for Microsoft Sans Serif. A scan of all 2,366 DOCX packages (docxide-pdf, fixtures-v1, tracked) changed only that file.

    To merge #2368, a Mac must: export tests/fixtures/docx/libreoffice/tdf148671.docx from Word to PDF and confirm the three controlled frames sit at x 434.3pt with their tops at y 183.05, 198.05 and 212.3pt. Then regenerate the PR's gt.jpg, layout-audit.json and render-clusters-page-1.json from that PDF. The steps are in the PR body.

    Re-audit, so the next session need not repeat it. All 22 framed docxide-pdf cases and the scraped massachusetts letter were measured again on main d041a66e against their Word references. Every remaining deviation already has another open issue: #2355 (both-sides and per-line wrap), #2288 (-15-two-space-only), #2233 (negative indent of the offset frame text), #2232 (paragraph-offset-frame-exact section mark), #2236/#2230 (case116/128/130), #2253–#2255 (cont-first-*-overflow), #2363, #2354, #2364 and #2314 (massachusetts).

    Struck from the list: auto width. With Word's embedded Arial Rounded MT Bold subset on --font-path, massachusetts' auto-width, centred governor frame matches Word within 0.2pt on three of its four lines. The fourth, "Lieutenant Governor", is 2.17pt left because its trailing spaces are centred (sub-material, recorded in #1874). No Word reference shows an auto-width defect, so the FIXME(auto frame width) stays unverified rather than open work.

    Filed while auditing: the vertical rule in tdf148671 gets arrowheads from its type-less a:headEnd/a:tailEnd (#2366), and its schemeClr stroke paints black instead of grey (#2367).

    Still open here. Every item needs a Word export or a one-factor probe, so this issue is labelled autofix-blocked:

    The candidate and base binaries, the evidence PDFs and the timing runs for #2368 are kept on the host that ran this (/tmp/i2231). The corpus checkouts and build targets were removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    autofix-blockedissue-loop: unattended attempts failed, needs human reviewdocx-corpusDOCX defect found on the docxide-pdf Word-reference corpus

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions