Skip to content

fix(docx): anchor a drawing in the paragraph whose text it stands beside, so it moves with that text - #852

Merged
DemchaAV merged 5 commits into
2.5-devfrom
fix/docx-drawings-follow-text
Oct 5, 2026
Merged

DemchaAV merged 5 commits into
2.5-devfrom
fix/docx-drawings-follow-text

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Why

A DOCX's drawings did not move when a reader edited the text above them. Most shapes the export draws for what the page paints were placed with wp:positionH/positionV relativeFrom="page": a timeline's dot, an icon by a heading, a skill's bar, a badge. The exception was a drawing that is all a table cell holds. Lengthen the summary of a CV in Word, and its entries and headings move down while their dots, icons and bars stay where they were.

The Word editing protocol (#847) had no scenario for this. A new one, drawings-follow-text, lengthens the longest paragraph. It then checks that every drawing beside text the edit moved moved with that text, by as much and onto the same page. On 2.5-dev, 12 of the 228 shapes beside moved text follow it.

What changed

Anchoring beside the text. DocxDrawingAnchors holds every page-placed shape until the section ends. Then it anchors each shape in the paragraph whose text the shape stands nearest, placed down from that paragraph's top (relativeFrom="paragraph").

  • Reach. The nearest paragraph is measured across and down together, within 48pt (REACH).
  • When the nearest can hold the shape.
    • The shape hangs from the paragraph's top, never rising above it. LibreOffice keeps a shape laid out in a cell from rising out of it, and set one 45pt above its paragraph at the head of the next page.
    • For a paragraph in a table cell, the conditions are below.
  • When the nearest cannot. The shape stays on the page; it is never given to a paragraph further off, which is not the text it stands by. An icon centred on a note under a totals table stands a little above the note's top. The table's last cell, 44pt up, took it in. The icon then hung below that cell's row, and LibreOffice on Linux set the row's "Total due" 2pt and 4pt up in two invoices.
  • The stroke clamp. A shape reaching up to a stroke's width past the paragraph's top is placed at the top. For a skill bar 0.13pt above its paragraph, Word's object model reported a Top of -999997, not an offset.
  • Shapes left on the page. A shape no paragraph takes is anchored in the page's first body paragraph, from the page's edges, as before. So are the opening hairline and closing paragraphs that carry shapes on a page with none.
  • Order. Shapes beside one paragraph are written in one run, in the order they are drawn.

Where Word places from (Seat.paragraphTop). Both editors measure relativeFrom="paragraph" from the paragraph's top, including its spacing/@before. A probe in Word 16.0.20430 and LibreOffice agreed to 0.1pt.

  • Exact lines. Word does not stand an exact line where the page draws it. The export already makes Word's baseline equal the page's. Word stands an exact line's baseline four fifths of the way down it (DocxTextBands.BASELINE_SHARE), raised by the first text run's w:position, so the line's top is baseline − (0.8·L − raise).
    • The run is found among a link's runs too, which POI leaves out.
    • The baseline is the one the page draws, after the TextVerticalAlign seat (seatShift).
    • Placing from the page's line top instead put TealPulse's section rules 4.6pt high and ObsidianInvoice's icon 4.8pt high. Leaving the seat out put LumaStudio's rule under a 36pt title 14.8pt high.
  • Paragraphs that offer no top. These are not used:
    • a paragraph with a top border;
    • one with contextual spacing switched on;
    • one with space reckoned in lines or automatically.

In a table cell (Seat.column). The shape is laid out in the cell (layoutInCell="1") and placed from the cell's text column (relativeFrom="column"). The column is the paragraph's text edge less its indent. The cell's left margin is taken off only to find the cell's edge, for whether the cell holds the shape across.

  • Measured in Word and LibreOffice:
    • the column starts at the cell's edge inside its margins, paragraph indent aside;
    • a nested cell is measured the same way;
    • with layoutInCell="0", Word measured the shape from the cell's top instead of the paragraph's.
  • A cell takes the shape only when all of these hold:
    • the cell holds the shape across: a dot in the gap between two columns belongs to neither;
    • its line is set from the left, because a centred or right-aligned line's indent is written 2pt short of the page's;
    • its row is not a repeated header, which Word repeats with what is anchored in it.

Which paragraphs offer a place (DocxSemanticBackend.offerASeat). Every paragraph written for a ParagraphNode is offered, with where the layout sets the node's first line (DocxLayoutMetrics.firstTextBox).

  • The first of them that holds text when the section ends takes the place. A paragraph the layout starts on a new page is written after an empty line that holds its top edge there, and that line is not where the text is.
  • A table's own cell text and a list's items offer no place yet.

Drawing XML. DocxDrawings.drawingInParagraph writes a body paragraph's shapes: placed across from the page, down from the paragraph. drawingInCell writes a cell's.

Report and docs.

  • The report says a drawing is anchored beside the text it stands by, or to the page.
  • These no longer say every shape stays on the page:
    • docs/recipes/docx-export.md (drawings, panels);
    • docs/recipes/timelines.md;
    • docs/recipes/shape-as-container.md;
    • docs/getting-started.md;
    • docs/architecture/canonical-legacy-parity.md;
    • the capability matrix.
  • CHANGELOG updated. CONTRIBUTING.md lists the new protocol scenario.

Verification

  • Editing protocol (Word 16.0.20430, the 35 corpus documents with drawings, -Scenario drawings-follow-text):

    Shapes beside moved text that follow it Documents passing
    2.5-dev 12 of 228 15
    this PR 104 of 228 19
    • How it decides. The scenario finds each drawing's owner by geometry: the paragraph nearest its top-left corner, up or down and across from its text. It does not know the export's reach or the hang rule.
    • What still counts against it:
      • shapes the export leaves on the page on purpose — TimelineMinimal's column-divider axis, a portrait far from its name;
      • footer icons whose text the edit pushes to the next page;
      • shapes beside a table's own cell text;
      • dots and bars standing a little above their paragraph's top.
    • What it proves. Where a shape is anchored, not where Word draws it. The renders below cover that.
  • Where shapes stand, unedited. Each shape was matched by kind and size in the Word and LibreOffice PDFs of 2.5-dev and this PR:

    • Word: no shape moves more than 1.5pt; the shapes now carry their text's drift.
    • LibreOffice: no shape moves more than 1.55pt, except OrangeOps's header separator. That separator now follows its line of text, which LibreOffice already sets 78pt low.
  • Text fidelity. The Word, LibreOffice Windows and LibreOffice Linux gates pass. Lines that move:

    • CompactMono. All of its lines move together: −0.12pt in Word (52 lines), −0.10pt in LibreOffice (58 lines, both platforms). Its drawings left its first paragraph, whose line had held them. Its worst line goes 1.01 → 0.91pt in Word and 0.93 → 0.83pt in LibreOffice.
    • TealPulse on Linux. Its contact line comes 0.93 → 0.08pt nearer.

    Baselines are committed; the Linux one is taken from this PR's CI artifact.

  • Tests:

    • DocxDrawingAnchorsTest (new, 17 tests) covers:
      • the nearest of several paragraphs in reach;
      • reach at 48pt across and down, and across alone;
      • the nearest paragraph that cannot hold a shape is not passed over;
      • never above the top, and a stroke's width placed at the top;
      • the paragraphs that offer no top, and contextual spacing off;
      • the exact-line top with a raise, and a link's raise;
      • the cell column, and Word's own cell margin;
      • a cell that does not hold the shape, a centred line, and a repeated header row;
      • the empty line before a page's first paragraph;
      • order.
    • DocxDrawingsTest:
      • the dot and a seated display title are placed from the paragraph Word sets at the top margin, to 0.5pt;
      • a badge's glyph and disc are placed from the title beside them, the glyph in the disc's middle;
      • a ring over a column's text is anchored in its cell;
      • a shape out of reach and a panel's drawing are covered.
    • DocxCellDrawingTest counts shapes left out of their cell.
  • Sabotage. Each rule fails its test when broken:

    • space above ignored, raise ignored, and the seat shift;
    • the hang rule, the cell-holds rule and the repeated header;
    • the stroke clamp, the order, and the empty line;
    • the centred line, the link's runs, and contextual spacing;
    • reach at 49pt, the nearest-only rule, and the top border;
    • lines-reckoned space, and Word's cell margin.
  • render-docx: 940 tests green.

  • Full reactor gate: ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose-qa,:graph-compose-coverage -am → BUILD SUCCESS (render-docx 940, qa 1820). CommittedAssetDriftTest 3 / 3 green.

Notes

  • Shapes still on the page:
    • shapes beside a table's own cell text or a list's items;
    • shapes in the gap between columns;
    • portraits far from any text;
    • shapes that rise more than a stroke above their nearest paragraph's top: a dot centred on a date line, a bar above its label. Taking them needs a negative offset where the editors keep it, which is the next step.
  • Space above at the head of a page. If an edit pushes a seated paragraph with space above it to the top of a page, and Word drops that space there, the shape lands that much lower than its text.
  • A table split across pages. A table cell's paragraph offers its place on the page its text starts on only.
  • One node used twice. A ParagraphNode used in two places shares one layout path, so a shape beside its second use is anchored with its first.
  • Length. A shape's length does not change: a rail stays as long as it was when the entries under it grow.

Lane: shared-engine (render-docx).

…ide, so it moves with that text

A shape the page paints (a timeline's dot, an icon by a heading, a skill's
bar, a badge) was placed from the page's edges, so editing the text above
it moved the text and left the shape behind. DocxDrawingAnchors now anchors
each shape in the nearest paragraph within 48pt that it hangs from, placed
down from that paragraph's top as Word reckons it: the space above included,
an exact line's baseline four fifths of the way down, raised by the run's
position. In a table cell it is laid out in the cell and placed from the
cell's text column. A shape beside no such paragraph stays on the page.

The Word editing protocol gains a drawings-follow-text scenario.
…, not below it

A cell holding a shape that hangs below its row moved the row's text in the
Linux LibreOffice: an icon beside the note under a totals table, taken into
the table's last cell, set two invoices' 'Total due' 2pt and 4pt up. A shape
now goes into a cell only when it starts above the foot of the text written
there and reaches past it by half its height at most.

The tests' offset helpers fail an assertion on a value that is not a number.
…e, not to one further off

The previous commit kept a shape out of a cell below the cell's text, judged by
the text the export wrote there as paragraphs. That missed what a cell holds as
list items or rows, and turned 23 shapes beside such text back to the page. The
icon it set out to keep out stood just above the note it is beside: the note
refused it, and the totals table's last cell, further off, took it. The nearest
paragraph is now the only one considered; when it cannot hold the shape, the
shape stays on the page.

Tests pin reach at 48pt, the paragraphs that offer no top, Word's own cell
margin and the nearest paragraph among several in reach. The Linux LibreOffice
baseline is taken from CI: CompactMono's lines 0.1pt up, as on Windows.
@DemchaAV
DemchaAV merged commit 70ba3c7 into 2.5-dev Oct 5, 2026
13 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-drawings-follow-text branch October 5, 2026 12:34
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