Skip to content

test(docx): hold every drawing of a block that writes text to a paragraph of that text - #979

Merged
DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-flow-owner-drawing-guard
Oct 30, 2026
Merged

DemchaAV merged 4 commits into
2.5-devfrom
fix/docx-flow-owner-drawing-guard

Conversation

@DemchaAV

@DemchaAV DemchaAV commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Why

A drawing placed from its paragraph moves with that paragraph when a reader edits the text above
it. One placed from the page keeps its place on the sheet while the text it belongs to is edited
away from under it — the card walks down the page and the icon over its corner stays behind.

Four changes in a row taught the export to carry such drawings, bringing the template corpus from
465 drawings placed from the page down to 32. Nothing held what they won: no test read where a
drawing is placed from at all, so a change putting one back on the sheet would have left every
suite green.

What changed

Test code only — no engine change, so the corpus is byte-identical and no baseline moves.

DocxDrawingPlacements (new) reads every drawing of a DOCX from every XML part — the body, its
headers and footers, and the tables and text boxes nested in them — with what each is placed from
and across, whether it floats, and the wp:docPr name the export writes it under, which is what
tells a drawing a node paints (Drawing N) from a page's own fill (Page background N). A drawing
standing in another's text box is read in its own right, each anchor in its own frame.

It also reads simplePos, which the rest of this would be blind without: at simplePos="1" Word
and LibreOffice take the place from wp:simplePos and read neither positionH nor positionV, so
a drawing nailed to the sheet that way still says relativeFrom="paragraph" in the file. A truthy
value is reported as a problem; left out, the attribute means what "0" means, as the schema has
it.

An anchor the reader cannot classify — nothing to be placed down from, no name, a name that is
neither a drawing nor a page fill, an offset that is no number, a place it was not written to judge
— is reported as a problem rather than passed over.

FlowOwnerDrawingDocuments (new) holds six small documents, one for each paragraph that carries
a drawing today: a paragraph of the body, a panel's, a column of a row, an item of a list, the one
line two halves of a band share, and a table cell a drawing is all of. Between them they cover all
three writers of text that offer a paragraph to the drawings beside it — a paragraph node, a list's
item, a band's shared line — and the exception for a drawing that is all a cell holds, which offers
no paragraph at all; the first three share the paragraph node as their writer and differ in the
holder whose paragraph the walk must find. Each is small enough that a failure names what stopped
carrying the drawing. They are deliberately not in the fidelity corpus — they hold the rule, where
the corpus fixes what the export does today, and adding documents to the corpus moves its
baselines.

DocxDrawingAnchorGuardTest (new) holds both. Over the corpus, the drawings still placed from
the page must equal, document for document and drawing for drawing, a ledger that names the reason
each is there:

  • 25 of a table's composed cell, where the walk from the cell whose box holds the drawing finds
    no paragraph of that cell it can be placed from and the fall back to the nearest text does not
    place it either — invoice-payments 13, invoice-workspace 8, invoice-merchant 4, each with
    92 or more paragraphs on its page, so it is not for want of one. Which step gives up is not
    pinned down, and the entry states the outcome rather than guessing at a mechanism.
  • 6 whose owner's own text is written as the drawing itself — a brand lockup's four badges and
    a closing banner's two, laid over the flow and set in a text box where the page sets them — so
    that no paragraph of the blocks holding them stands on their page at all.
  • 1 with four paragraphs of its own block on its page that none of them was placed from. How
    far it rises is not what refuses it: risesNoFurther is reached only from Seat.place, which a
    composed cell's drawing takes by way of placeBeside, while a drawing a node paints is placed by
    Seat.placeHeld, which never asks. Of what placeHeld does refuse on, one is measured out — the
    nearest paragraph's top is known, 52.80pt — and the rest are named in the entry, unmeasured.

The ledger names each drawing rather than counting them, so a change fixing one and breaking
another in the same document cannot pass on a tally that comes out the same. The equality holds
both ways: it is a record of work still to do, not a licence, and when the export learns to carry
those drawings the test fails until the ledger is brought down to match.

Over the six documents nothing may be placed from the page, and every drawing must be placed down
from a paragraph — not merely kept off the page, since an export that wrote these shapes into the
text instead would satisfy the weaker claim.

DocxFixtures (new) holds what this guard and DocxPageReferenceGuardTest both need to write a
DOCX by hand — the namespaces, a body, a header, a footer, a paragraph, the zip, and the check that
an export was made from its layout — which were about to exist twice. The page-reference guard now
uses it instead of its own copies.

docs/recipes/docx-export.md now says what being placed from the page costs a drawing, which the
rules around it described without naming the consequence.

Verification

Run on this branch, ./mvnw from the worktree:

  • DocxDrawingAnchorGuardTest 15 tests and DocxPageReferenceGuardTest 11, 26 of 26 passing.
  • test -pl :graph-compose-render-docx -am — 2363 tests, 0 failures, 1 skipped.
  • test -f qa/pom.xml — 1871 tests, 0 failures, 1 skipped.

Each rule the guard holds was broken once, alone, and the guard had to go red on a reported test
failure — an exit code was not accepted as proof, after a scripting fault turned a Maven that never
started into a false "caught":

broken guard
every drawing nailed to the sheet red
every drawing sent to an absolute point (simplePos="1") red
no holder ever places a drawing red
no holding cell ever places a drawing red
page fills no longer on the sheet red
the reader reads no XML part red
the reader reports every drawing as carried red
the reader ignores simplePos red
the reader takes a missing offset for nought red
the reader ignores a second wp:positionV red

Each was applied alone, to the final tree, and the set was re-run after every round of review
fixes, since both the reader and one document changed.

The corpus figures above were measured over the 71 exported documents: 760 floating drawings a node
paints, of which 728 are carried and 32 are placed from the page — the ledgered ones; 381 inline
drawings, which are placed from nothing and are not this guard's subject; and, separately, 32 page
fills, every one of them on the sheet, which is how the reader proves it can read a page-placed
drawing at all. The two 32s are different measurements that happen to agree.

…raph of that text

A drawing placed from its paragraph moves with that paragraph when a reader edits the text above
it; one placed from the page keeps its place on the sheet while the text it belongs to is edited
away from under it. Four changes taught the export to carry such drawings, bringing the template
corpus from 465 drawings placed from the page down to 32, and nothing held what they won: a
change putting one back on the sheet would have left every suite green, no test reading where a
drawing is placed from at all.

DocxDrawingPlacements reads every drawing of a DOCX from every XML part — the body, its headers
and footers, and the tables and text boxes nested in them, a drawing inside another's text box
included — with what each is placed from and across, whether it floats, and the name the export
writes it under, which tells a drawing a node paints from a page's own fill. It reads simplePos
as well, because at simplePos="1" the editors take the place from wp:simplePos and read neither
positionH nor positionV, so a drawing nailed to the sheet that way still says
relativeFrom="paragraph" in the file. An anchor it cannot classify is a problem it reports rather
than a silence.

DocxDrawingAnchorGuardTest holds the rule two ways. Over the template corpus, where 728 of the
760 floating drawings a node paints are carried by a paragraph, the 32 still placed from the page
must equal, document for document and drawing for drawing, a ledger naming the reason each is
there — work still to do, not a set of cases where the page is right. Over six documents of its
own, one for each writer of a paragraph that carries a drawing today — a paragraph of the body, a
panel's, a column of a row, an item of a list, the one line two halves of a band share, and a
table cell a drawing is all of — every drawing must be placed down from a paragraph, since one
written into the text instead would satisfy a rule that only kept it off the page.

DocxFixtures holds what this guard and DocxPageReferenceGuardTest both need to write a DOCX by
hand, which were about to exist twice; the page-reference guard now uses it.

No engine code changes, so the corpus is unmoved and no baseline is retaken. The recipe now says
what being placed from the page costs a drawing.
… keyword where an offset belongs

The document for a drawing that is all a table cell holds put a bare EllipseNode in the cell,
which is not what the export's exception asks for: it fires only for a layer stack or a shape
container that only draws, so the disc was anchored by the ordinary walk instead and the
exception had no document at all. The disc is now a layer stack of one layer, which meets every
condition the exception states.

A wp:positionV may say wp:align — a keyword, "bottom" — where the export says a wp:posOffset.
Read as a missing offset that came out as nought, a drawing sitting exactly on its paragraph's
top: a reading, and a wrong one. The reader now tracks whether an offset was given at all and
reports an anchor that gave none, which leaves the missing-offset problem to anchors that do name
something to be placed down from, an anchor with no wp:positionV being that one problem and not
two.

The ledger's reasons reach the failure: they were recorded against each document and read
nowhere, so a red named the drawings and not what the ledger claims about them. The floor on the
corpus's floating drawings is held at 750 against the 760 measured rather than 700, since the
slack is what a drawing could go missing in.

DocxPageReferenceGuardTest drops the four imports left dead by moving its hand-written-file
helpers out.
…hor placed down twice

The document for a drawing that is all a table cell holds named the wrong gate. It said
drawingCellFor, and that a shape container would do as well as a layer stack. Neither is so:
drawingCellFor wants a placement of its own and content composed in a cell has none, so the gate
that fires is anchorComposedDrawing, and the shapes are collected by DocxCellDrawings.shapesOf,
whose parameter is a layer stack. A shape container in that cell would stop the exception firing
while the ordinary walk carried the drawing anyway — green, and covering nothing. The document
was already a layer stack of one layer; now the Javadoc says which gate that satisfies and that a
shape container is not one of them.

The reason recorded for the one drawing whose own block does write paragraphs on its page said
how far it rises, within what a drawing is allowed. The rise is not what refuses it, and not
because it is within the allowance: risesNoFurther is reached only from Seat.place, which a
composed cell's drawing takes by way of placeBeside, while a drawing a node paints is placed by
Seat.placeHeld, which never asks. The reason now names what placeHeld does refuse on, with the
one of those that is measured out.

An anchor carrying two wp:positionV left this reader saying what one of them names and how far
down the other says, silently; it is now a problem. An inline drawing's offset reads 0 as its
contract says, rather than the value a floating anchor gets when its offset cannot be read.

The ledger's record holds its drawings as a list, not an array, so its equality is its drawings.
The documents of the rule are all read before the test fails, since a document for each writer of
text is there to name the writer that stopped offering its paragraph.
…ng's places down

The documents were described as one for each writer of a paragraph that carries a drawing. They
are not: offerASeat is keyed on a paragraph node, so the body's paragraph, a panel's and a column
of a row are the same writer, and what those three vary is the holder whose paragraph the walk
must find. The six do cover all three writers that offer a paragraph — a paragraph node, a list's
item, the line two halves of a band share — and the exception for a drawing that is all a cell
holds, which offers none, and they are now described that way. The record's component is named
for what carries the drawing rather than for a writer.

Both ledger reasons named only the first leg of the walk. A drawing is left on the page only once
the fall back to the text nearest it has refused it as well, so both now say so.

A second wp:positionV is counted rather than inferred from whether the last one named anything to
be placed from: a first element without relativeFrom would otherwise pass unnoticed and leave its
offset read against the second's name.

The page-reference guard takes its header parts from the shared fixtures too, which is what moving
them out was for.
@DemchaAV
DemchaAV marked this pull request as ready for review October 30, 2026 02:59
@DemchaAV
DemchaAV merged commit b07dc14 into 2.5-dev Oct 30, 2026
11 checks passed
@DemchaAV
DemchaAV deleted the fix/docx-flow-owner-drawing-guard branch October 30, 2026 07:17
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