Repository navigation
fix(docx): keep an inline picture on its paragraph's text line - #2347
Merged
Merged
Conversation
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>
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 paragraph that mixes text runs with an inline picture (
w:r/w:drawing/wp:inline) lifted the picture out of its line.convert_paragraph_blocksqueued every inline picture in a separateinline_imageslist and flushed it as anImage/InlineImagesblock ahead of the paragraph's text. Three things followed:Word lays an inline picture out like a glyph. Changes, in
parser/docx.rs:wp:inlinepicture becomes a run whoseinline_boxholds 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#boxwriter 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'sleading-picture-image-no-spacescase, a line holding a picture and only a trailing space has no descent below it.split_content_at_inline_pictures), soLeft <picture> Endin onew:rkeeps its order.line_text_by_layout_fragmentdecides each stretch between page or column breaks separately. A picture before a page break with text only after it stays a picture block (the existingtest_inline_image_before_run_page_break_stays_before_break).w:pictimages 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):cell-inline-nested+11.57pt: DOCX: the empty paragraph that closes a cell after a nested table takes a full line where Word gives it none #2238.cell-inline-exact: DOCX: an inline picture taller than an exact-height line grows the line instead of being clipped to it #2228.cell-inline-tabbed, picture 3.6pt right of the 108pt stop: DOCX: a space before a tab is left out of the tab's position, so the tab overshoots its stop #2344, new. The cause is a space before a tab, and main shows it for text alone.leading-picture-image-fits/-overflowand the threepicture-padding-*cases: DOCX: whitespace on an inline picture's line is measured unlike Word: a spaces-only line collapses and leading spaces add descent #2343, new. These are whitespace line metrics: a spaces-only line collapses, and leading spaces add descent.Two positions move away from Word. The
fitsEnd baseline goes from 121.07 to 124.10 (Word 121.03), andpicture-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.jpgfrom this PR:wp:effectExtent; LibreOffice-only evidence.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):[72x36]picture box in source order, with no picture block.render::typst_gen::tests::image_tests::an_inline_picture_between_text_runs_stands_on_their_baselinecompiles authored DOCX through Typst and reads the frames, for 36pt and 4pt pictures, in the body and a cell:Endstarts at the picture's right edge;Updated test helpers.
tests/docx_fixtures.rsimage_block_count/image_flow_container_countnow 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 -- --checkandcargo 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 as80d4ac92.Build provenance. Baseline (main
8597ee78) and candidate CLIs were built with--locked --releasein 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 --sourcepasses):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.mutool; see the evidence README.Corpus scan, base against candidate, over 2,360 DOCX inputs:
cases/andscraped/atfe7b8bea…, the tracked fixtures, and the 1,608 DOCX files of thefixtures-v1bulk archive;case16, 9 scraped documents, and 15 bulk inputs (2 of them also tracked).Movers were scored with
compare_layout.pyand picture origins against Word for docxide inputs, and against LibreOffice 24.2 (not Word) for bulk inputs: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.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, soflow.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_studyp14 (a centred picture + trailing space underw: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.tdf165359_SdtWithDrawing/_SdtWithInlinego 2 pages → 1 (LibreOffice 1), with picture x exact and mean |dy| 6.5 → 0.02pt. Alsofdo78469(x 363.6 vs 354.2; main 221.7; text |dy| 11.5 → 0.1),tdf91122(text |dy| 18.5 → 0.01),test_msword_hang,fdo82123,fdo76098,VariousPictures(206.6 exact), and thedrawing.docxlogos, now side by side on one line.tdf104061_tableSectionColumns:/+ a 114.75pt logo in a cell withw:ind w:left="-240". The clamped negative indent (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) leaves the logo 0.4pt too wide, so it wraps under the/.imgshadow: shadowed pictures sit 12pt higher than LibreOffice, because inline pictures do not reservewp:effectExtent(DOCX: inline pictures do not reserve their wp:effectExtent, so shadowed pictures sit too close together #2345). The row gap itself moved toward LibreOffice (161.4 vs 163.5; main 171.4).tdf143475_rotatedWord2007imageInline: the picture now sits inline between its words, as in LibreOffice. Rotation is still not drawn, as on main.The bulk gate (
tests/bulk_conversion_baseline.json) asserts conversion success only; every mover still converts.Visual impact
Visual audit
tests/fixtures/cases/cell-inline-body/input.docxatfe7b8beafa7612cc89ad62a7d27b2c6141c4d6ed(Apache-2.0, not committed; SHA-256dc2b4699…)fixassets/bugfixes/issue-2227/layout-audit.jsonassets/bugfixes/issue-2227/render-clusters-page-1.jsonLeft Endis one line beneath it, andFollowing paragraphis a further line down, 13pt below Word.Left, the picture andEndshare one line with the picture's bottom on the text baseline, andFollowing paragraphfollows directly, as in Word. In the crops the picture starts 0.10pt right of Word's, and both lines are within 0.07pt.assets/bugfixes/issue-2227/gt.jpgassets/bugfixes/issue-2227/before.jpgassets/bugfixes/issue-2227/after.jpgVisual comparison
Required inspection
Deviation audit
Left/Endbaseline 108.00 against 108.03Endfollows the picture at 174.42 against Word's 174.33Following paragraphstarts at 111.62 against Word's 111.55Final performance pass
/usr/bin/time. Medians:cell-inline-body0.27s/43.7 MB (base) vs 0.27s/44.8 MB (candidate);russian_chess_pawn_lesson0.30s/53.7 MB vs 0.30s/54.3 MB;VariousPictures.docx0.42s/48.0 MB vs 0.42s/49.3 MB; text-only controlpoi_sample.docx0.40s/44.4 MB vs 0.41s/44.9 MB./usr/bin/time -f "%e %M", release CLIs built with--lockedin 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.Checklist
Signed-off-byline🤖 Generated with Claude Code