Skip to content

fix(docx): anchor a drawing in a paragraph of the block it belongs to - #974

Merged
DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-anchor-drawings-in-their-owner
Oct 27, 2026
Merged

DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-anchor-drawings-in-their-owner

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

Why

A drawing in a DOCX export stood in the paragraph whose text was nearest it: within 48pt, rising above that paragraph's top by 24pt at most, and in a table cell by a stroke's width. Anything else was placed from the page's edges, and stayed there when a reader edited the text above it while the text moved away. That covered an icon laid over a card's corner, a sidebar's portrait, a skill's meter beside its label in a row, and a timeline's dots in a column of their own.

The fidelity corpus placed 465 drawings on the page:

  • 270 drawn by a node a block holds;
  • 5 timelines' rails;
  • 190 composed in table cells.

In Word's editing protocol (drawings-follow-text), 132 of the 231 drawings beside text that moved followed it, and 17 documents failed.

What changed

  • DocxDrawingAnchors anchors a drawing a node paints by the blocks holding it. The holders are the node itself, a layer stack, a panel, a section, a row, an alignment.
    • The drawing is anchored in the first paragraph able to carry it that the nearest of them writes on its page, and placed from that paragraph's top.
    • Where it stands gives only the offset, however far from the paragraph's text.
    • Holders. DocxLayoutMetrics.holdersOf gives the node's path and each enclosing block's path. It leaves out a container the tree starts at, taken to be the page's flow: every corpus document starts at one, and a painted container added at the root is left out with it.
    • Matching. seat now takes the path of the node a paragraph is written for, matched against those holders.
  • In a table cell, placement (Seat.placeHeld) follows the measurements below:
    • the cell of the paragraph's row that holds the drawing across takes it, the drawing's own where it stands in a column of its own;
    • otherwise the paragraph's cell keeps it, placed out of its column;
    • a drawing holding text (a text box, a badge's letters) is kept only by a cell holding it across;
    • a cell set to the middle or the bottom, or sitting in one, carries none, and the drawing goes to the block's next paragraph that can carry it, or else up to the next block;
    • a repeated header row and a line not set from the cell's left carry none either.
  • A drawing no holder carries goes to the paragraph nearest it. That covers a drawing no node paints (a timeline's rail) and one whose holders write no paragraph on its page that can carry it. It is anchored in the paragraph nearest it on its page that can, however far off. Only where no paragraph on its page can carry it does it stay on the page, as before.
  • What a table's composed cell draws has no node of its own and keeps the nearest-text rule within reach (Owner.composed). Seat.place and its limits remain for it alone, and go with the change that anchors it.
  • A paragraph's top is worked out once per section for the drawings nodes draw, as a drawing's holders and its nearest paragraphs try the same paragraphs again.
  • Report notes say how each drawing is anchored, a text box laid over the flow's included. 532 notes are reworded, and the count stays at 1499.
  • Javadoc. The old limits (REACH, FURTHEST_RISE, the cell's stroke-width rise) now say they hold only what a composed cell draws. The in-cell limit's LibreOffice case was a cell set to the middle.
  • Linux LibreOffice baseline. libreoffice-linux takes cv-teal_pulse's rows, and no other document's, from this PR's CI run 40503780286. A comment in each file's header says why:
    • its email line stands in a cell set to the middle; it stood at +0.08pt only while the contact separator was anchored in that cell, which made LibreOffice 24.2 set the cell from its top;
    • under the rule above, the separator goes elsewhere, and LibreOffice centres the cell as it does the rest of the row: +0.93pt, where Word sets it at +1.14pt and the row's LinkedIn line stands at +0.83pt;
    • ten other lines stand 0.1 to 0.3pt further. That is runner drift: the same run moves documents this change leaves byte for byte by as much.
  • Example preview. assets/readme/examples/word-export-companion.docx is re-rendered. Its "GC" badge is anchored in the paragraph above it instead of on the page.
  • Docs:
    • the DOCX recipe's "drawn as shapes" passage, the composed-cell passage, the line-drawing exception, the band's icon-over-card sentence and the links-and-anchors row;
    • the capability matrix's drawing rows;
    • the CHANGELOG.

Measured

Anchoring in a table cell's paragraph. 50 hand-made documents in Word 16.0.20527 and LibreOffice 24.2 (CI's build), with LibreOffice 26.8 where noted.

  • A shape anchored with layoutInCell="1", placed from the cell's column and the paragraph's top, stands where both editors say, to 0.06pt down, in all of these cases:
    • rising 36pt above its row or above the table;
    • hanging below the table;
    • standing left of its cell or in the next cell;
    • taller than its row;
    • in a row of exact height, or in a nested table;
    • in front of the text or behind it.
  • In all of them, neither editor clips the shape or moves a line against a control without it. Three lines added above the table move every shape 42pt, as they move the text.
  • Across the corpus, the offsets this change writes run from 155pt above the paragraph to 620pt below it. Both editors draw those drawings where they stood before, except where they now move with their text (below).
  • layoutInCell="0" in a cell is not usable: Word measures the paragraph offset from the cell's top and LibreOffice from the paragraph, which differ by the cell's top margin (10.5pt).
  • LibreOffice 24.2 sets the text of a cell carrying a drawing from the cell's top, whatever its vertical alignment says and wherever the drawing stands. In a row three lines taller than the cell's text, that is 16pt high when the cell is set to the middle and 32pt when set to the bottom. A drawing rising into such a row from the table under it changes nothing.
  • LibreOffice 26.8 pulls a text box placed out of its cell's column back into the column (120pt, LumaStudio's sidebar). A plain shape it places where the file says, as Word and LibreOffice 24.2 do.

The corpus, 71 documents.

  • Changed documents. 32 change, all in document.xml. Paragraphs are unchanged, except that TerracottaRail no longer needs the 0.1pt hairline paragraph that carried its seven drawings before the table opening its page.

  • Page anchors. 204 drawings are now on the page, against 465:

    • 190 are composed in table cells (the next change);
    • 12 are LumaStudio's text boxes in its sidebar and closing note, whose column holds no paragraph wide enough;
    • 2 are on pages whose only text is a pair of lines (LumaStudio_long page 2, Obsidian_long page 5).
    • All 261 others are anchored in a paragraph.
  • Lines, base export against new, in the same editor.

    • Word. Lines move under 0.1pt, apart from:
      • TerracottaRail: the removed hairline raises the table, 0.12pt further from the page;
      • ObsidianInvoice's closing badge "K" (a single letter, which the gate does not hold): +1.2 → +1.8pt, as the badge follows the closing band Word sets 0.6pt low.
    • LibreOffice 24.2. The same, plus TealPulse's email line (see the Linux LibreOffice baseline above).
  • Drawings in the editors. They follow their paragraphs now, so a drawing stands where its paragraph's text stands:

    • PaymentsInvoice's icons in LibreOffice 24.2 sit 4.85pt higher with their text, which LibreOffice already set 4.8pt high;
    • icon_over_card's icon now stands on the card's corner in both editors, as each sets the card, 14pt low.
  • Word's editing protocol (drawings-follow-text, Word 16.0.20527). "Base" is 2.5-dev over 70 documents; "this change" is all 71.

    Paragraph lengthened Base: drawings following Base: documents failing This change: drawings following This change: documents failing
    the longest 132 of 231 17 209 of 234 11
    the first 182 of 335 20 300 of 334 9
    • icon_over_card passes both.
    • The failures that remain are:
      • composed cells (metered, metered_long, workspace, subscription);
      • LumaStudio's text boxes;
      • drawings that follow their block where the protocol pairs them with the nearest paragraph inside it.
  • LibreOffice 24.2 turned lines. It draws some zero-width lines turned by 90°: 14 of 105 on the base, 9 now. Which ones changes with where they are anchored, and Word draws all of them right.

Tests

  • DocxDrawingOwnerAnchorsTest (18 tests):
    • a holder's first paragraph is taken over a nearer paragraph, its own or another block's;
    • a holder's next paragraph takes the drawing where its first cannot;
    • the drawing's own cell, merged from the row above, leaves it to the paragraph's cell, placed out of its column;
    • the offset is how far the drawing stands, above or below;
    • a drawing in a cell rises as far as it stands;
    • a cell keeps a drawing out of its column, but not one holding text;
    • the drawing's own cell in the row takes it;
    • a holder with no paragraph on the page passes the drawing up, past a nearer paragraph of another block;
    • a cell set to the middle or the bottom, or held in one, carries none;
    • the nearest paragraph takes a rail, and a badge whose holders write no paragraph;
    • a paragraph that cannot place the drawing is passed over;
    • a repeated header row carries none;
    • path matching stops at a segment's end;
    • a composed cell's drawing still keeps to within reach.
  • DocxDrawingHolderTest (4 tests): through the backend, the icon over a card goes into the card's paragraph and a meter into its own cell of its row; holdersOf leaves out the page's flow but keeps a block at the root and a container further in.
  • DocxDrawingsTest: a ring alone in its column beside a sidebar's paragraph goes into its own cell, and no hairline paragraph opens the page.
  • Rewritten tests. These four pinned the old placement:
    • DocxCellDrawingTest's line alone in a row's cell is now anchored in its own cell, not held to its height;
    • DocxDrawingsTest's dot 80pt below a paragraph is now anchored in it;
    • the two table-only pages keep their hairline paragraph with a centred paragraph, which can place nothing;
    • DocxDrawingAnchorsTest now queues its shapes as a composed cell's, the rule it pins.
  • Sabotages. Each rule broken once, then the tests that should catch it; all 14 were caught, and the files were byte-identical afterwards. The rules broken:
    • the owner rule skipped;
    • the search ended at a holder whose paragraphs cannot place the drawing;
    • a holder's nearest paragraph taken instead of its first;
    • a cell set to the middle allowed;
    • a cell inside such a cell allowed;
    • the page's flow counted as a holder;
    • the out-of-column fallback removed;
    • the nearest paragraph removed;
    • a composed drawing sent by its holders;
    • seats offered without their path;
    • a repeated header row allowed;
    • the drawing's own cell not looked for;
    • a drawing holding text kept out of its column;
    • the old rise limits put back.
  • Word fidelity gate (word-fidelity.ps1, Word 16.0.20527): passes on all 71 documents.
  • CI, LibreOffice 24.2 (Linux): run 40503780286 failed DOCX Fidelity (LibreOffice) on cv-teal_pulse's email line alone (+0.08 → +0.93pt). In the same run, documents this change leaves byte for byte moved within the gate's 0.5pt: 328 lines further and 756 nearer. Their rows are left for a retake of their own. With teal_pulse's rows taken from that run, every check passes on da00105b9, DOCX Fidelity (LibreOffice) included. CodeQL's note on the new test's offset parsing is answered there: an offset that is not a number fails the test as an assertion.
  • LibreOffice Windows gate (26.8): fails invoice-obsidian and proposal-indigo, with the same lines as on 2.5-dev, which fails them the same way. invoice-luma_studio fails without the text-box rule and passes with it.
  • clean verify of the reactor as CI runs it → BUILD SUCCESS: core 823, render-pdf 339, render-docx 2334, testing 5, render-pptx 142, templates 127, qa 1846. The four JDK 17 knowledge checks pass, and core javadoc has 0 warnings. examples passes 93 tests. CommittedAssetDriftTest failed before the preview was re-rendered.

Named, not fixed

  • A drawing anchored by its block follows the whole block, not the line it stands beside inside it. A timeline's dots in a column of their own follow their row, not the entry each stands by, when an entry above them grows.
  • Some text offers no place yet. Paragraphs written for a list's items, a pair of lines or a table's own cell text offer none, so a drawing whose block writes only such text goes to the nearest paragraph.

Lane: shared engine (render-docx). The DOCX export's output changes; no Java API changes.

A drawing stood in the paragraph whose text was nearest it, within 48pt and
rising above it by 24pt at most, by a stroke's width in a table cell, and
otherwise was placed from the page's edges, where it stayed while the text
above it was edited: an icon over a card's corner, a sidebar's portrait, a
skill's meter in a row, a timeline's dots in a column of their own.

A drawing a node paints is now anchored in the first paragraph able to
carry it that the nearest block holding it writes on its page, the page's
flow left out (DocxLayoutMetrics.holdersOf), and placed from that
paragraph's top however far off it stands. In a table cell the cell of
that paragraph's row holding it across takes it, else the paragraph's own
cell keeps it out of its column, unless it holds text, which LibreOffice
26.8 pulls back into the column. A cell set to the middle or the bottom
carries none: LibreOffice 24.2 sets such a cell's text from its top once it
carries a drawing. A drawing no holder carries goes to the paragraph
nearest it on its page. What a table's composed cell draws keeps the
nearest-text rule.

The report's notes say how each drawing is anchored; the recipe, the
capability matrix and the CHANGELOG describe the rule; the Word export
companion preview is re-rendered, its badge now in the paragraph above it.
…re a held drawing falls back

A text box laid over the flow is anchored as any drawing a node paints, and
its note now says so. The nearest-text limits (REACH, FURTHEST_RISE, the
stroke's rise in a cell) say they hold only what a composed cell draws;
holdersOf says a container at the tree's root is taken to be the page's
flow; the CHANGELOG and the capability matrix name the paragraph's own
cell keeping a drawing out of its column, and a cell held in one set to the
middle.

Tests pin a block's next paragraph taking a drawing its first cannot, a
drawing's own cell merged from above leaving it to the paragraph's cell,
a ring alone in its column taking its own cell with no hairline before the
table, and a meter taking its own cell of its row.
Its email line stands in a cell set to the middle. It stood at +0.08pt
only while the contact separator was anchored in that cell, which made
LibreOffice 24.2 set the cell from its top. A cell set to the middle now
carries no drawing, and LibreOffice centres the cell as it does the rest
of its row: +0.93pt, where Word sets the line at +1.14pt.

The document's rows come from CI run 40503780286. Ten other lines stand
0.1 to 0.3pt further, as the same run sets lines of documents the export
does not change; no other document's rows are taken.
The owner-anchor test reads drawing offsets from the written XML; one that
is not a whole number of EMU now fails the test with what was found rather
than throwing, as the drawing-anchors test already does.
@DemchaAV
DemchaAV marked this pull request as ready for review October 27, 2026 13:35
@DemchaAV
DemchaAV merged commit ffbc08f into 2.5-dev Oct 27, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-anchor-drawings-in-their-owner branch October 27, 2026 13:41
DemchaAV added a commit that referenced this pull request Oct 28, 2026
The note added with the baseline rewrite said a Word build sets lines a
little differently on its own, and that a gate failure on another build is
re-shot in a change of its own. Measurement says otherwise: the corpus
exported from 6683be9, the commit whose run last took all 62 rows on Word
16.0.20430, and converted by Word 16.0.20527 measures byte-identical to that
commit's baseline - 62 document rows and 4282 line rows, 0 changes. The two
builds read this corpus the same, so the note pointed a contributor at the
wrong cause.

What actually leaves a baseline stale is the gate's own shape: it fails a
line only once it stands more than 0.5pt further from the page, so a change
that moves documents nearer, or less than that further, passes and the rows
stay as they were. The note now says that, with what it cost between #899
and #974, and keeps the build in one clause as the thing it rarely is.
DemchaAV added a commit that referenced this pull request Oct 28, 2026
A cold pass over the note's numbers against the measurements found three
wrong:

- 75 commits between #899 and #974; `git rev-list --count` says 74, which is
  also what the per-commit attribution sums to.
- "229 of them standing a point nearer the page": 229 lines moved across, but
  one of them stands further (`proposal-editorial`'s "Signature:", -0.01 ->
  +0.11pt), and only the 220 of #944 moved by about a point - the other nine
  moved 0.02 to 0.76pt.
- "Word 16.0.20430 and 16.0.20527 measure this corpus identically": the A/B
  that shows it was run on the corpus as 6683be9 exported it, 62 documents.
  Today's corpus is 71 and carries least line heights, per-page margin
  sections and panel cuts that did not exist then, none of which the older
  build was ever measured on. The sentence now says which corpus it is about.
DemchaAV added a commit that referenced this pull request Oct 28, 2026
…975)

* test(qa): rewrite the Word fidelity baseline on Word 16.0.20527

The baseline was shot on Word 16.0.20430, and the Word the gate is run with
is 16.0.20527. It sets 1388 of the corpus's 5165 lines at another height and
229 at another place across, 1609 rows in all, reaching 0.48pt further from
the page against the 0.51pt at which the gate fails a line: the Word gate
passed with 0.03pt to spare on cv-serif_headline, cv-panel and
invoice-obsidian. Nine documents already carried rows shot on 16.0.20527, so
the file held two builds at once.

word-windows.tsv and word-windows-lines.tsv now hold what Word 16.0.20527
measures across all 71 documents, under one header. CONTRIBUTING.md says,
where it tells a contributor to run the Word gate, that the baseline's header
records the build it was shot on and that a failure on another build is
re-shot in a change of its own. Nothing else changes: no Java, and no
resource the export reads, so the corpus exports byte for byte as before
(71 documents, 1499 report notes).

The file holds its own control group: the nine documents whose rows were
already shot on 16.0.20527 move 0 lines of 883, while the other 62 move 1609
of 4282. The move is Word's 1/600in grid - 1385 of the 1388 lines whose
drift down changes move by an exact multiple of 0.12pt, and 227 of the 229
that move across do too. The other five move a whole number of steps plus
0.02 to 0.03pt. All 5165 lines are found in both measurements, none lost and
none new; past 0.05pt, 632 stand nearer the page and 553 further, and across
all but one of the 229 move nearer, four invoices' figures by about a point.
Of the 47 rows that change, 21 stand nearer, 21 further and 5 the same, no
median by more than 0.13pt. Acceptance stays at 63 of 71, the same eight
documents short of it.

Three full runs - export, a fresh Word conversion of all 71 documents,
measure - wrote byte-identical measurements, the baseline is that
measurement byte for byte, and the third holds a fresh Word run to the
rewritten files.

* docs(contributing): say why a stale fidelity baseline goes unnoticed

The note added with the baseline rewrite said a Word build sets lines a
little differently on its own, and that a gate failure on another build is
re-shot in a change of its own. Measurement says otherwise: the corpus
exported from 6683be9, the commit whose run last took all 62 rows on Word
16.0.20430, and converted by Word 16.0.20527 measures byte-identical to that
commit's baseline - 62 document rows and 4282 line rows, 0 changes. The two
builds read this corpus the same, so the note pointed a contributor at the
wrong cause.

What actually leaves a baseline stale is the gate's own shape: it fails a
line only once it stands more than 0.5pt further from the page, so a change
that moves documents nearer, or less than that further, passes and the rows
stay as they were. The note now says that, with what it cost between #899
and #974, and keeps the build in one clause as the thing it rarely is.

* docs(contributing): correct three figures in the fidelity gate note

A cold pass over the note's numbers against the measurements found three
wrong:

- 75 commits between #899 and #974; `git rev-list --count` says 74, which is
  also what the per-commit attribution sums to.
- "229 of them standing a point nearer the page": 229 lines moved across, but
  one of them stands further (`proposal-editorial`'s "Signature:", -0.01 ->
  +0.11pt), and only the 220 of #944 moved by about a point - the other nine
  moved 0.02 to 0.76pt.
- "Word 16.0.20430 and 16.0.20527 measure this corpus identically": the A/B
  that shows it was run on the corpus as 6683be9 exported it, 62 documents.
  Today's corpus is 71 and carries least line heights, per-page margin
  sections and panel cuts that did not exist then, none of which the older
  build was ever measured on. The sentence now says which corpus it is about.
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.

2 participants