Skip to content

fix(docx): keep an inline picture on its paragraph's text line - #2347

Merged
developer0hye merged 3 commits into
mainfrom
fix/docx-inline-picture-in-line
Oct 19, 2026
Merged

developer0hye merged 3 commits into
mainfrom
fix/docx-inline-picture-in-line

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 paragraph that mixes text runs with an inline picture (w:r/w:drawing/wp:inline) lifted the picture out of its line. convert_paragraph_blocks queued every inline picture in a separate inline_images list and flushed it as an Image/InlineImages block ahead of the paragraph's text. Three things followed:

  • The picture sat at the paragraph's left edge, above the text.
  • All of the text went onto one line below it, so text written before the picture printed after it.
  • In a header, the pushed-down line overlapped the body's first line.

Word lays an inline picture out like a glyph. Changes, in parser/docx.rs:

  • Ride the runs. A sized wp:inline picture becomes a run whose inline_box holds the picture at its extent, the slot inline text boxes (DOCX: an inline DrawingML text box renders as a separate plain paragraph without its outline #1690) and diagrams already use. It applies when the picture shares its stretch of the paragraph (between page or column breaks) with any text: a character (spaces included), a tab, or a line break. The existing #box writer seats the box's bottom on the baseline. A taller picture grows the line upward, and the line's descent and leading stay the text's. The picture run carries no text style of its own: in Word's leading-picture-image-no-spaces case, a line holding a picture and only a trailing space has no descent below it.
  • Source order inside a run. A content run is split at each inline picture (split_content_at_inline_pictures), so Left <picture> End in one w:r keeps its order.
  • Per-fragment decision. line_text_by_layout_fragment decides each stretch between page or column breaks separately. A picture before a page break with text only after it stays a picture block (the existing test_inline_image_before_run_page_break_stays_before_break).
  • Unchanged paths. Pictures with no text beside them keep the picture-block path, which carries the paragraph's alignment, spacing and indents (DOCX: an inline picture paragraph's w:spacing w:after is discarded #499, DOCX: a paragraph holding only an inline picture ignores its first-line, hanging and left/right indents #2201). Floating pictures, drawing-canvas pictures, VML w:pict images and header/footer paragraph pictures (HFInline::Image) are unchanged.

docxide-pdf's inline_line_advance (picture plus text descent) was the reference for the line rule; Typst's own line box gives the same seat.

Related issue

Related: #2227

Every remaining deviation in the twelve reported cases has an open issue (table in assets/bugfixes/issue-2227/README.md):

Two positions move away from Word. The fits End baseline goes from 121.07 to 124.10 (Word 121.03), and picture-padding-* Following from 159.88 to 151.25 (Word 162.30). Main was closer only because it printed the spaces as an extra line below the lifted picture. #2343 records both.

New follow-up issues filed in this work, each carrying a compare.jpg from this PR:

Testing

  • TDD, written failing first; both new tests fail on main:

    • parser::docx::tests::image_tests::an_inline_picture_sharing_its_paragraph_with_text_rides_the_runs (IR):
      • Five paragraph shapes, each in the body and in a table cell: text on either side; leading spaces only; picture first; text and picture in one run; a tab before the picture. Each must give one paragraph whose runs are the text and a [72x36] picture box in source order, with no picture block.
      • Controls: a lone picture stays a picture block. A picture split from the text by a page break stays a block, while a picture beside text past the break rides that text's line.
    • render::typst_gen::tests::image_tests::an_inline_picture_between_text_runs_stands_on_their_baseline compiles authored DOCX through Typst and reads the frames, for 36pt and 4pt pictures, in the body and a cell:
      • the picture's bottom is on both runs' baseline;
      • End starts at the picture's right edge;
      • the picture stays below the previous line;
      • the gap below the baseline is equal for both picture heights;
      • in the body, the next line is one ordinary pitch below.
  • Updated test helpers. tests/docx_fixtures.rs image_block_count / image_flow_container_count now count pictures riding a paragraph's line. VariousPictures.docx's five pictures share a paragraph with a trailing space, so they are still five pictures in one flow container: the paragraph.

  • Full suite. cargo test --locked --workspace --profile ci --no-fail-fast: 4456 passed, 0 failed, 15 ignored. cargo fmt --all -- --check and cargo clippy --locked --workspace --all-targets --profile ci -- -D warnings: clean.

  • Main merged. Main 1eb1e8a6 (fix(issue-loop): count every pull request citing an issue, not the newest 30 #2340, issue-loop scripts and docs only) is merged in as 80d4ac92.

  • Build provenance. Baseline (main 8597ee78) and candidate CLIs were built with --locked --release in separate run-owned target directories.

  • Visual audit of the headline case (docxide-pdf cell-inline-body, one page, Creator: Microsoft Word; check_gt_integrity.py --source passes):

    • compare_layout.py --audit --fine-shift 0.5: 2/2 lines matched, no findings.
    • compare_text_layer.py: normalized content identical.
    • compare_render.py --dpi 150 --fine-shift 0.5 --strict-clusters: 11 clusters, all dispositioned by exact ID.
    • The other eleven reported cases were measured with mutool; see the evidence README.
  • Corpus scan, base against candidate, over 2,360 DOCX inputs:

    • sources: docxide-pdf cases/ and scraped/ at fe7b8bea…, the tracked fixtures, and the 1,608 DOCX files of the fixtures-v1 bulk archive;
    • 52 inputs fail on both builds alike; 2,268 PDFs are byte-identical; 40 change: the 12 reported cases, case16, 9 scraped documents, and 15 bulk inputs (2 of them also tracked).

    Movers were scored with compare_layout.py and picture origins against Word for docxide inputs, and against LibreOffice 24.2 (not Word) for bulk inputs:

    • Pictures reach Word's x: family_kinship_lesson_plan (219.2 exact; 5 pages → Word's 4), chiseldon_parish_planning_agenda (432.0 exact; 75.2 vs 75.5), usep_handbook (57.7 exact), scottish_fundraising_awards_campaign ×3 pages (112.8 vs 109.9; main 62.2), english_town_council_report (241.1 vs 240.7; main 223.1), italian_evaluation_minutes (184.2 vs 185.7; main 42.5).
    • russian_chess_pawn_lesson: pictures reach Word's x (142.05/150.72/153.05 exact; main 85.05), and the А label now sits beside its picture as in Word. Its mean |dy| rises from 5.7 to 12.7pt.
      • Page 2 already starts 29.87pt above Word, because Word carries an empty autospaced paragraph onto page 2.
      • It also has a 7.3pt shorter gap after the picture line, which equals main's gap after its spurious spaces line.
      • Main's extra spaces line below the lifted picture had partly offset both. Both offsets predate this PR.
    • case16: the picture line matches Word (baseline 158.85 vs 159.05; picture x 294.4 vs 301.8). Caladea, standing in for Cambria, is narrower, so flow. does not wrap as in Word. That shifts later pictures up 15–17pt, and the last picture fits on page 1. Main's lifted picture had masked this.
    • brazilian_logistics_study p14 (a centred picture + trailing space under w:ind w:hanging="567", left 0) moves 14.2pt right. The text path cannot hang a first line into the margin: 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.
    • french_sexual_health_youth_strategy: a picture moves to Word's x (105.5). Its pre-existing vertical offset is unchanged.
    • Bulk files against LibreOffice (labelled non-Word reference):
  • The bulk gate (tests/bulk_conversion_baseline.json) asserts conversion success only; every mover still converts.

Visual impact

  • No rendered PDF change
  • Rendered PDF change or visual evidence added
  • Reason: Not applicable

Visual audit

  • Issue: DOCX: an inline picture between text runs is lifted out of its line into a separate block above the text #2227
  • Fixture: docxide-pdf tests/fixtures/cases/cell-inline-body/input.docx at fe7b8beafa7612cc89ad62a7d27b2c6141c4d6ed (Apache-2.0, not committed; SHA-256 dc2b4699…)
  • Page(s): 1
  • Renderer and DPI: pdftoppm, 150 DPI
  • Evidence mode: fix
  • Baseline failure report: None
  • Layout audit report: assets/bugfixes/issue-2227/layout-audit.json
  • Render cluster reports: assets/bugfixes/issue-2227/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: Pass
  • Layout audit fine shifts: Pass
  • 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 crops of the picture line and both text lines.
    • Before: the picture sits alone at the left margin. Left End is one line beneath it, and Following paragraph is a further line down, 13pt below Word.
    • After: Left, the picture and End share one line with the picture's bottom on the text baseline, and Following paragraph follows directly, as in Word. In the crops the picture starts 0.10pt right of Word's, and both lines are within 0.07pt.
    • The picture's diff cluster is a one-pixel edge plus the red/blue band boundary. Word embeds an interpolated 191×95 JPEG; office2pdf embeds the 200×100 PNG.
    • Every other cluster is glyph outline only, from Times New Roman drawn as the metric-compatible Liberation Serif.
    • Black regular text on white. No rules, borders, hairlines, dashes, underline, bold or italic.
  • GT: assets/bugfixes/issue-2227/gt.jpg
  • Before: assets/bugfixes/issue-2227/before.jpg
  • After: assets/bugfixes/issue-2227/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: the picture and both GT lines are present and matched
Position/size Fixed: picture at (98.92, 72) against Word's (98.82, 72), same 72×36pt size; Left/End baseline 108.00 against 108.03
Rotation/flip No deviation observed
Fill Matches GT: the picture's red band over blue; only JPEG-versus-PNG resampling at the band boundary
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 Fixed: End follows the picture at 174.42 against Word's 174.33
Line/paragraph spacing Fixed: the picture line grows to the picture, and Following paragraph starts at 111.62 against Word's 111.55
Clipping/overflow No deviation observed

Final performance pass

  • Status: Complete
  • Measurements: 7 interleaved runs per build with /usr/bin/time. Medians: cell-inline-body 0.27s/43.7 MB (base) vs 0.27s/44.8 MB (candidate); russian_chess_pawn_lesson 0.30s/53.7 MB vs 0.30s/54.3 MB; VariousPictures.docx 0.42s/48.0 MB vs 0.42s/49.3 MB; text-only control poi_sample.docx 0.40s/44.4 MB vs 0.41s/44.9 MB.
    • Setup: /usr/bin/time -f "%e %M", release CLIs built with --locked in separate targets, 7 runs each, base and candidate interleaved, warm page cache, idle machine (a first pass under a concurrent test run gave the same medians). Intel i5-10400 (12 threads), Linux 7.0.0, host fonts: Liberation/Carlito/Caladea substitutes, no Microsoft core fonts.
    • Scoped measurements, not product SLAs.
  • Outcome and revalidation: no worthwhile simple optimization; none was kept or needed.
    • Elapsed medians are identical, within the 10ms timer resolution.
    • Peak RSS is 0.5–1.3 MB higher on every input, including the picture-free control, which takes none of the new code path. That is within the 2–2.6 MB variance seen between two clean builds of one source.
    • The added work is a linear pre-scan of each paragraph's run children and a clone of a run only when it holds both text and an inline picture.
    • No optimization code changed after the visual audit, so the tests and visual comparison above stand as the final revalidation.

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

developer0hye and others added 3 commits October 20, 2026 01:51
A paragraph mixing text runs and an inline picture lifted the picture out
as an image block at the paragraph's left edge and set all of its text on
one line below. Text written before the picture printed after it, and in
a header the pushed-down line overlapped the body.

Word lays an inline picture out like a glyph. A picture now rides its
paragraph's runs as an inline box of its extent, in source order, when it
shares the stretch of the paragraph between page or column breaks with
any text, spaces included. Its bottom edge sits on the baseline, a taller
picture grows the line upward, and the line's descent and leading stay the
text's. A run holding text and a picture is split so each keeps its place.
Pictures with no text beside them keep the picture-block path, which
carries the paragraph's alignment, spacing and indents.

docxide-pdf's cell-inline-body, -nested, -tabbed and -header Word cases
now set Left, the picture and End on one line, with the picture at x 98.92
(Word 98.82) and the next line 0.07pt from Word. The header no longer
overlaps the body. Across the docxide, scraped and bulk corpora, 40 of
2360 documents change. Most pictures land on Word's or LibreOffice's x, and
two tdf165359 files go from 2 pages to LibreOffice's 1. The movers that
end farther from a reference expose separate defects that the lifted
picture hid: whitespace line metrics (#2343), a space before a tab
(#2344), a negative indent (#2233), effect extents (#2345), and Caladea
standing in for Cambria.

Related: #2227, #2228, #2238

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
…re-in-line

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Yonghye Kwon <developer.0hye@gmail.com>
Defect evidence for #2346, filed from #2227: Word left, office2pdf right,
docxide-pdf case84 at 150 DPI.

Related: #2227, #2346

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