From 8a6265d45a89bfb2af7844604d6a1c3fd970e943 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 1 Nov 2026 20:12:47 +0000
Subject: [PATCH 1/7] fix(docx): draw a shape over a painted neighbour in
front, as the stack paints it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A panel is written as a shaded cell, and a cell's fill belongs to the text layer,
which Word and LibreOffice both paint over everything drawn behind that layer. A
shape standing beside such a panel — on a layer above it, not inside it — was
drawn behind the text, and so was covered wherever the two boxes met, the stack's
z-index lifting it clear of nothing.
Measured at 300 dpi on the one document of the fidelity corpus that holds the
case, an 18pt icon laid over a card's corner: the engine's PDF draws 4308 pixels
of the icon's own ink, and Word 16.0.20527 and LibreOffice 24.2 drew none at all
— not one pixel anywhere on the page.
The order is now taken from the tree. A shape a node paints itself, standing on a
layer the page paints after a painted layer of an overlay it stands in, goes in
front of the text, where no shading reaches it; by z-index, the layers sharing one
in the order they were given, which is the order the page paints them in. The
order is the tree's and not the geometry's, so such a shape stands in front
whether the two boxes meet or not — where they do not, the reader sees the same
page either way.
A neighbour counts as painted where it, or anything it holds, carries a fill in
the tree. That is wider than what the export actually shades: whether a node that
paints becomes a cell with shading is decided as it is written, and the tree
cannot be asked that. Wider is the safe way round here, a neighbour counted that
writes no shading only lifting a shape nothing would have covered. A node bearing
borders alone carries no fill and is not counted; a shape container's own fill is
not either, being a drawing rather than a cell, though a fill inside it is.
Three things leave such a drawing behind the text, and the export goes on naming
those as it named this case before. A painted layer the page lays over the drawing
in turn, whatever lies below: there the page covers the drawing, and in front it
would cover that layer's shading instead, which is the same loss the other way
round. A box holding a line of the flow or a picture, which in front it would
cover. And a drawing that is no shape of a node's own: a picture drawn where the
page puts it and a pass of the layout's own, a timeline's rail, are not asked the
question, and over a painted neighbour the naming pass names them where they
stand. The corpus holds no instance of either.
In Word the icon now draws 4314 pixels against the engine's 4308, in a box the
same to the pixel, and the page differs from the page before it only inside that
box. The anchor differs from the one written before by behindDoc alone, which the
two files' unzipped parts show.
Of the corpus's 71 documents two change: the icon's, and cv-sidebar_portrait,
where 37 of 39 anchors turn from behind the text to in front. Word and
LibreOffice draw that preset's page exactly as they drew it before — 0 of
3,868,706 pixels differ at 200 dpi in each — since nothing was painted over
those 37 to begin with. The other 69 documents are byte for byte the ones the
export wrote before, and the report's notes go 1498 to 1497.
Re-measured over the changed corpus in Word 16.0.20527: 309 of 325 drawings
follow their text, 95.08%, the same two counts as before.
The two tests of a shape inside a painted panel are given the sign of an
overlapping fill. As they stood they asserted the export's decision, for which
behindDoc is the sign; what they could not tell was whether the reader sees the
shape, the same fixture with its panel unpainted keeping behindDoc="1" and being
seen in full. Each now also asks for the note that says the panel's shading hides
the shape, and runs that unpainted control, which must not have it.
The corpus guard's ledger of drawings left under a neighbour's fill goes to
empty, and it gains the check the other way round: a drawing lifted in front must
not lie in the box of a painted panel the page paints after it, judged on the
page's own paint order. None of the 38 lifted does.
LibreOffice draws the icon whole but 14.16pt right of where Word and the page put
it. That is its own reading of a cell-anchored shape and not this change: with
the card bordered instead of filled, where the icon is behind the text and seen
anyway, LibreOffice puts it 14.40pt right and 13.92pt low while Word is within
0.24pt. It is recorded as a thing to measure, not fixed here.
---
CHANGELOG.md | 58 ++--
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 42 ++-
.../fidelity/DocxNeighbourFillGuardTest.java | 182 ++++++++++--
.../semantic/docx/DocxSemanticBackend.java | 137 ++++++++-
.../DocxDrawingUnderANeighboursFillTest.java | 264 +++++++++++++-----
.../semantic/docx/DocxDrawingsTest.java | 108 +++++--
7 files changed, 630 insertions(+), 163 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 765382ae0..50dfacaec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,39 +8,55 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
-- **A DOCX export names the drawings that a painted panel standing beside them covers, rather than
- losing them in silence.**
+- **A DOCX export carries a layer stack's order into the document, so a drawing laid over a painted
+ neighbour is drawn in front of the text instead of disappearing under that neighbour's shading —
+ and where the order keeps it behind, the export names what the shading takes.**
- A panel is written as a shaded cell, and a cell's fill belongs to the text layer, which both
editors paint over everything drawn behind that layer — a drawing on a layer *above* the panel
- included. A layer stack's z-index therefore does not lift a drawing clear of a painted
- neighbour, and the drawing is not seen wherever the two boxes meet.
+ included. A layer stack's z-index did not lift such a drawing clear of a painted neighbour, and
+ the drawing was not seen wherever the two boxes met.
- Measured at 300 dpi in Word 16.0.20527 and LibreOffice 24.2 on the one document of the
71-document fidelity corpus that holds the case, an 18pt icon laid over a card's corner: the
- engine's PDF draws 4308 pixels of the icon's own ink, Word's draws none and LibreOffice's
- draws none, the icon's whole box coming back the card's colour in both. With the card
- unpainted — the one difference — Word draws every pixel the engine does.
- - The report now carries an `APPROXIMATED` note naming such a drawing and one panel that covers
- it. The panels are taken where the export writes their fill rather than read off the node
- tree: whether a section, a container or a row that paints becomes a cell with shading is
- decided as it is written, so a panel bearing borders alone is not named and a row written as a
- panel is. A drawing and a panel are only ever judged against each other within one section,
- whose pages are counted from its own first and whose page height is its own, and a document
- of several sections names the section each loss is in.
+ engine's PDF draws 4308 pixels of the icon's own ink, Word's drew none and LibreOffice's drew
+ none, the icon's whole box coming back the card's colour in both. With the card unpainted — the
+ one difference — Word drew every pixel the engine does.
+ - A shape a node paints itself, standing on a layer the page paints after a painted layer of an
+ overlay it stands in, now stands in front of the text, where no shading reaches it. The order is
+ the tree's — by z-index, the layers sharing one in the order they were given — and not the
+ geometry's: such a shape stands in front whether the two boxes meet or not. A neighbour counts
+ as painted where it, or anything it holds, carries a fill in the tree. That is wider than what
+ the export actually shades, since whether a node that paints becomes a cell with shading is
+ decided as it is written and the tree cannot be asked, and wider is the safe way round: a
+ neighbour counted that writes no shading only lifts a shape nothing would have covered. A node
+ bearing borders alone carries no fill and is not counted, and a shape container's own fill is
+ not either, being a drawing rather than a cell — though a fill inside it is.
+ - Three things leave such a drawing behind the text, and the report carries an `APPROXIMATED` note
+ naming it and one panel that covers it. A painted layer the page lays over the drawing in turn:
+ there it belongs under that layer's shading, and in front it would cover the shading instead —
+ the same loss the other way round. A drawing whose box holds a line of the flow or a picture,
+ which in front it would cover, as inside a painted panel. And a drawing that is no shape of a
+ node's own: a picture drawn where the page puts it, and a pass of the layout's own such as a
+ timeline's rail, are not asked the question, and over a painted neighbour they stay behind and
+ are named there — the corpus holds no instance of either. The panels the note is taken from are
+ those the export writes a fill for, not the tree's, so a row written as a panel is named and a
+ panel bearing borders alone is not; a drawing and a panel are only ever judged against each
+ other within one section, whose pages are counted from its own first and whose page height is
+ its own, and a document of several sections names the section each loss is in.
- What it does not name is written out in the recipe and in the guard rather than left to be
discovered: a panel the layout breaks across pages, whose box is the one its first page gave
it — the guard holds those four by name, so a fifth is seen; a panel composed in an authored
table cell, which the layout gives no box of its own; a table cell's own shading, which
belongs to a cell rather than to a node; a cell's paint the table draws itself; and the
second panel over a drawing that lies over two, one note naming one panel.
- - No document of the corpus changes: the note states what the bytes already were.
- - A drawing standing *inside* a painted panel is a different case with a note of its own, and
- the export already puts such a drawing in front wherever it frames no text of the flow.
- Measured the same way, that case has no instance in the corpus at all — its branch is taken 55
- times over 14 documents and goes in front every one of them.
- - `DocxNeighbourFillGuardTest` holds the corpus to the one drawing that is known, judging by the
+ - A drawing standing *inside* a painted panel is a different case with a note of its own, decided
+ by the surface it stands on: the export already puts such a drawing in front wherever it frames
+ no text of the flow. Measured the same way, that case has no instance in the corpus at all —
+ its branch is taken 55 times over 14 documents and goes in front every one of them.
+ - `DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, judging by the
boxes the layout gave the nodes and not by `behindDoc`, which is set on drawings the reader
plainly sees as well: the same fixture with its panel unpainted keeps its anchor's
- `behindDoc="1"` while showing all 3237 pixels of its shape in Word.
+ `behindDoc="1"` while showing all 3237 pixels of its shape in Word. It fails closed on two
+ fixtures of its own, one that must come back named and one that must come back in front.
- **A DOCX export asks the same order of a composed cell's paragraphs as of a block's, so a drawing
a table's cell draws is anchored in the paragraph of that cell which places it nearest rather than
in the first one the cell writes.**
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 0f2e76af0..457625e87 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -132,7 +132,7 @@ honour an option ignores it (documented contract).
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row; in a table cell, `w:keepLines` alone, as Word reads `w:keepNext` on the paragraph a cell opens with as keeping the row with what follows its table. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list, such a line or a rule the layout moves to a new page in the flow holds its space above there as a paragraph does, and a table, a row or a panel on that one page holds the gap the layout carries there with its own top edge. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed; where the page after a cut has other top and bottom margins, and the page above has room for it, the paragraph between the parts ends a Word section with them (per-page margins, below) |
-| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included, so a stack's z-index does not lift a drawing clear of a painted neighbour. An icon laid over a card's corner draws none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF; the report names such a drawing and one panel that covers it, taking the panels where it writes their fill, so that a panel bearing borders alone — which writes no shading — is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to the one drawing that is known, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
+| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export actually shades (decided as it is written, so the tree cannot be asked) and wider the safe way round; not where it bears borders alone, and not for a shape container's own fill, which is a drawing rather than a cell. Three things leave such a drawing behind the text, and the report names it and one panel that covers it: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; and a drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — which is not asked the question. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
## Output surface and lifecycle
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index ac34e5e0a..83d67c5e2 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1262,16 +1262,38 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
covered by that card wherever the two boxes meet, in Word and in
LibreOffice alike, however the page stacks them: measured at 300 dpi, an
18pt icon over a card draws 4308 pixels of its ink in the engine's PDF and
- none at all in either editor's. Until a stack's order is carried into the
- document, the report names such a drawing and one panel that covers it, so
- the loss is stated rather than silent, and in a document of several
- sections it names the section the loss is in. Five it does not name: a
- drawing under a panel the layout breaks across pages, whose box is the one
- its first page gave it; one under a panel composed in an authored table
- cell, which the layout gives no box of its own; one under a table cell's
- own shading, which belongs to a cell rather than to a node; a cell's paint
- the table draws itself; and the second panel over a drawing lying over
- two, one note naming one panel.
+ none at all in either editor's. So the stack's order is carried into the
+ document: a shape a node paints itself, standing on a layer the page
+ paints after a painted layer of an overlay it stands in — by z-index, the
+ layers sharing one in the order they were given — is drawn in front of
+ the text, where no shading reaches it. The order is the tree's, not the
+ geometry's: such a shape stands in front whether the two boxes meet or
+ not. A neighbour counts as painted where it, or anything it holds,
+ carries a fill in the tree. That is wider than what the export actually
+ shades — whether a node that paints becomes a cell with shading is
+ decided as it is written, and the tree cannot be asked — and wider is
+ the safe way round, a neighbour that writes no shading only lifting a
+ shape nothing would have covered. A node bearing borders alone carries no
+ fill and is not counted; a shape container's own fill is not either,
+ being a drawing rather than a cell, though a fill inside it is.
+ Three things leave such a drawing behind the text, and the report names
+ what the neighbour's shading then takes of it. A painted layer the page
+ lays over the drawing in turn: there the drawing belongs under that
+ layer's shading, and in front it would cover the shading instead — the
+ same loss the other way round. A drawing whose box holds a line of the
+ flow or a picture, which in front it would cover, as inside a painted
+ panel. And a drawing that is not a shape a node paints itself: a picture
+ drawn where the page puts it and a pass of the layout's own, a timeline's
+ rail, are not asked the question, so over a painted neighbour they stay
+ behind and are named there. The note names the drawing and one panel that
+ covers it, and in a document of several sections the section the loss is
+ in. Five cases it names nothing for: a drawing under a panel the layout
+ breaks across pages, whose box is the one its first page gave it; one
+ under a panel composed in an authored table cell, which the layout gives
+ no box of its own; one under a table cell's own shading, which belongs to
+ a cell rather than to a node; a cell's paint the table draws itself; and
+ the second panel over a drawing lying over two, one note naming one
+ panel.
The report names the clip — a shape container's, and a layer
stack's that clips to its bounds — wherever it cuts what the layers paint:
a square tile's corners in a disc, a label run past its chip, an ornament
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 1d282266c..0a07f7053 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -59,7 +59,8 @@
*
* It fails closed, on positive signals rather than on the absence of a complaint. A fixture
* built for the case must come back named, so a detector that stops seeing its target fails here
- * rather than reporting a clean corpus ({@link #aDrawingOverAPaintedNeighbourIsAlwaysNamed}). The
+ * rather than reporting a clean corpus ({@link #aDrawingAPaintedNeighbourIsLaidOverInTurnIsAlwaysNamed},
+ * {@link #aDrawingOverAPaintedNeighbourIsCarriedInFrontOfIt}). The
* corpus must come back with its documents laid out and with the hundreds of drawings it paints.
* A note that says a shape was drawn and that this test cannot classify is a failure rather than a
* silence: the export has four notes whose wording begins that way and only two of them are a
@@ -77,31 +78,32 @@
* a rail drawn as a line, a picture drawn over the badge holding it — which the export judges and
* this test checks one by one instead ({@link #NAMED_BUT_UNPLACEABLE}).
*
- * The list is a ledger of work still to do, not a licence: the export has no drawing it is
- * content to leave under a neighbour's fill. The entry goes when the stack's order is carried into
- * the document, and this test then fails until the ledger is brought down to match.
+ * The ledger is empty. The stack's order is carried into the document — a drawing on a layer the
+ * page paints after a painted layer of the same stack stands in front of the text — and the one
+ * instance the corpus held, the icon over a card's corner, is drawn in front and covered by
+ * nothing. An entry coming back is a drawing the export has gone back to losing under a
+ * neighbour's fill, and this test then fails until either the export or the ledger is put right.
+ * What keeps a drawing behind the text on purpose — a painted layer the page lays over it in turn,
+ * a box holding a line of the flow — the export names, and the corpus holds no instance of either
+ * (so the fixtures below carry the positive signal that it still can).
*
* @author Artem Demchyshyn
*/
class DocxNeighbourFillGuardTest {
/**
- * The icon laid over a card's top right corner through a layer stack: the card is a painted
- * panel on the layer below, and the icon's 18pt box stands wholly inside it. Both editors
- * paint the card's fill over it, and neither draws one pixel of its ink.
+ * Empty: no drawing of the corpus is left under a neighbour's fill.
*
- * It is the corpus's only instance, and it is a synthetic document rather than a shipped
- * preset: of the seventy-one, sixty-four are presets — twenty-six CVs, seventeen invoices,
- * fifteen letters, four proposals, a receipt and a rota — and not one of them lays a drawing
- * over a painted neighbour. The synthetic document was not written for this case either. It
- * joined the corpus to hold a band's space above and below the panel that opens it, and the
- * commit that added it says in as many words that the fidelity gate reads lines of text, so
- * where the icon stands it does not see. The case is therefore measured and real, and no
- * preset is known to reach it.
+ * It held one entry — the icon laid over a card's top right corner in
+ * {@code synthetic-icon_over_card}, whose 18pt box stands wholly inside the card painted on the
+ * layer below, and of which neither editor drew one pixel. Carrying the stack's order into the
+ * document draws that icon in front of the text, where the card's shading does not reach it,
+ * and the export names nothing for it. That was the corpus's only instance: of the seventy-one
+ * documents, sixty-four are presets — twenty-six CVs, seventeen invoices, fifteen letters, four
+ * proposals, a receipt and a rota — and not one of them lays a drawing over a painted
+ * neighbour.
*/
- private static final Map> UNDER_A_NEIGHBOURS_FILL = Map.of(
- "synthetic-icon_over_card",
- List.of("ContainerNode[0]/LayerStackNode[1]/SvgIcon[1]/SvgLayer0[0]"));
+ private static final Map> UNDER_A_NEIGHBOURS_FILL = Map.of();
/**
* Drawings this test's own scan finds under a painted neighbour that the export does not name.
@@ -151,6 +153,32 @@ class DocxNeighbourFillGuardTest {
/** What a drawing's note says when the export put it in front of the text. */
private static final String IN_FRONT = "in front of the text";
+ /** What it says when the reason is a painted layer the page paints under the drawing. */
+ private static final String LIFTED_OVER_A_NEIGHBOUR =
+ "in front of the text, since the shading of a panel the page paints under it";
+
+ /**
+ * Drawings the export lifted in front of the text whose box lies in a painted panel the page
+ * paints after them. Empty: there being in front is the same loss the other way round
+ * — the drawing would be painted over that panel's shading — so the export keeps such a
+ * drawing behind, and this is where a change that stopped doing so would show.
+ */
+ private static final List LIFTED_UNDER_A_LATER_PANEL = List.of();
+
+ /**
+ * Drawings the export lifted in front whose place in the paint order this test cannot read,
+ * the page painting no fragment under their own path. Empty today.
+ */
+ private static final List LIFTED_UNPLACEABLE = List.of();
+
+ /**
+ * The drawings of the corpus the export lifts in front of the text over a painted layer under
+ * them — 38 today, in two documents. A floor, and well under it, so that an export which
+ * stopped lifting them is seen here rather than leaving a corpus with nothing to judge and
+ * every list above still empty.
+ */
+ private static final int AT_LEAST_THIS_MANY_LIFTED = 30;
+
@Test
void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
Map> found = new TreeMap<>();
@@ -160,8 +188,11 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
List unclassified = new ArrayList<>();
List unjudged = new ArrayList<>();
List acrossPages = new ArrayList<>();
+ List liftedUnderALater = new ArrayList<>();
+ List liftedUnplaceable = new ArrayList<>();
List stems = new ArrayList<>();
int drawings = 0;
+ int lifted = 0;
for (DocxCorpusDocument document : DocxFidelityCorpusTest.corpus()) {
AtomicReference report = new AtomicReference<>();
Scan scan;
@@ -173,6 +204,9 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
}
stems.add(document.stem());
drawings += scan.drawings();
+ lifted += scan.lifted();
+ scan.liftedUnderALater().forEach(one -> liftedUnderALater.add(document.stem() + ": " + one));
+ scan.liftedUnplaceable().forEach(one -> liftedUnplaceable.add(document.stem() + ": " + one));
scan.panelsAcrossPages().forEach(panel -> acrossPages.add(document.stem() + ": " + panel));
scan.unjudged().forEach(path -> unjudged.add(document.stem() + ": " + path));
scan.unclassified().forEach(note -> unclassified.add(document.stem() + ": " + note));
@@ -229,12 +263,27 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
.isSubsetOf(stems);
assertThat(found).as("every drawing under a neighbour's fill is the one the ledger names")
.isEqualTo(new TreeMap<>(UNDER_A_NEIGHBOURS_FILL));
+ assertThat(lifted).as("the drawings the export lifts in front over a painted layer under "
+ + "them, which the two lists below are judged over")
+ .isGreaterThanOrEqualTo(AT_LEAST_THIS_MANY_LIFTED);
+ assertThat(liftedUnplaceable)
+ .as("every lifted drawing has a place in the page's paint order to judge by")
+ .isEqualTo(LIFTED_UNPLACEABLE);
+ assertThat(liftedUnderALater.stream().sorted().toList())
+ .as("no drawing is lifted in front of a painted panel the page paints after it, "
+ + "where in front is the same loss the other way round")
+ .isEqualTo(LIFTED_UNDER_A_LATER_PANEL);
}
@Test
- void aDrawingOverAPaintedNeighbourIsAlwaysNamed() throws Exception {
- // The positive signal the corpus pass is held to: were the export to stop seeing this
- // case, the corpus would come back clean and the ledger would be the only thing failing.
+ void aDrawingAPaintedNeighbourIsLaidOverInTurnIsAlwaysNamed() throws Exception {
+ // The positive signal the corpus pass is held to. With the ledger empty, an export that
+ // stopped seeing this case altogether would leave the corpus clean and nothing failing,
+ // so a fixture that must come back named carries the fail-closed half. The pin lies
+ // between two painted bands: the page paints one under it and one over it, so it belongs
+ // behind the text — in front it would cover the upper band's shading — and the lower
+ // band's shading covers it there.
+ //
// Deliberately the same document as the unit test of the same case in render-docx, so
// that the two modules are held to one answer: it is a copy on purpose, not a duplicate
// to be tidied away.
@@ -242,25 +291,69 @@ void aDrawingOverAPaintedNeighbourIsAlwaysNamed() throws Exception {
try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
.margin(DocumentInsets.of(20)).create()) {
session.pageFlow(page -> page.add(new LayerStackBuilder().name("Stack")
- .layer(new SectionBuilder().name("Band").fillColor(DocumentColor.rgb(90, 120, 200))
+ .layer(new SectionBuilder().name("Lower").fillColor(DocumentColor.rgb(90, 120, 200))
.fixedWidth(200).addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
.position(new ShapeBuilder().name("Pin").size(30, 14)
.fillColor(DocumentColor.rgb(26, 86, 148)).build(),
150, 2, LayerAlign.TOP_LEFT, 1)
+ .layer(new SectionBuilder().name("Upper").fillColor(DocumentColor.rgb(10, 170, 90))
+ .fixedWidth(200).addParagraph(p -> p.name("Over").text("Over it")).build(),
+ LayerAlign.TOP_LEFT, 2)
.build()));
session.export(new DocxSemanticBackend(report::set));
}
- assertThat(report.get().notes()).as("the pin under the band's fill, named")
+ assertThat(report.get().notes()).as("the pin under the lower band's fill, named")
.anyMatch(note -> note.detail() != null && note.detail().contains(COVERED)
&& note.path() != null && note.path().contains("Pin"));
}
- /** What one document's scan found. */
+ @Test
+ void aDrawingOverAPaintedNeighbourIsCarriedInFrontOfIt() throws Exception {
+ // The other half of the positive signal: the empty ledger is a measurement, and this is
+ // the behaviour that made it empty. The same stack less the band the page paints over the
+ // pin — the one difference — and the pin is drawn in front, named as covered by nothing.
+ AtomicReference report = new AtomicReference<>();
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
+ .margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(DocumentColor.rgb(90, 120, 200))
+ .fixedWidth(200).addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14)
+ .fillColor(DocumentColor.rgb(26, 86, 148)).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build()));
+ session.export(new DocxSemanticBackend(report::set));
+ }
+
+ assertThat(report.get().notes())
+ .as("nothing covers the pin, so nothing is named for it")
+ .noneMatch(note -> note.detail() != null && note.detail().contains(COVERED));
+ assertThat(report.get().notes())
+ .as("and the export says it stands in front because of the panel under it")
+ .anyMatch(note -> note.path() != null && note.path().contains("Pin")
+ && note.detail() != null
+ && note.detail().contains("in front of the text, since the shading of "
+ + "a panel the page paints under it"));
+ }
+
+ /**
+ * What one document's scan found.
+ *
+ * @param lifted the drawings the export put in front of the text because a painted
+ * layer stands under them
+ * @param liftedUnderALater the ones of those whose box lies in a painted panel the page paints
+ * after them, where being in front is the loss the other way
+ * round
+ * @param liftedUnplaceable the ones the page paints no fragment of under their own path, so
+ * that their place in the paint order cannot be read
+ */
private record Scan(int drawings, List panelsAcrossPages, List covered,
List unjudged, List unclassified,
- Map placed, List panels) {
+ Map placed, List panels,
+ int lifted, List liftedUnderALater, List liftedUnplaceable) {
}
/** Whether a path's box lies in the box of a painted panel it does not stand inside. */
@@ -295,7 +388,18 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
indexPaths(roots.get(index), null, index, paths);
}
+ // Where the page paints each path, first fragment first: the engine's own order, not a
+ // reading of the tree, so that the order this test judges by is not the order the export
+ // judged by.
+ Map paintedAt = new LinkedHashMap<>();
+ List painted =
+ session.layoutGraph().fragments();
+ for (int place = 0; place < painted.size(); place++) {
+ paintedAt.putIfAbsent(painted.get(place).path(), place);
+ }
+
List behind = new ArrayList<>();
+ List lifted = new ArrayList<>();
Set asShapes = new HashSet<>();
List unjudged = new ArrayList<>();
List unclassified = new ArrayList<>();
@@ -321,6 +425,8 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
unjudged.add(note.path());
} else if (!detail.contains(IN_FRONT)) {
behind.add(note.path());
+ } else if (detail.contains(LIFTED_OVER_A_NEIGHBOUR)) {
+ lifted.add(note.path());
}
}
@@ -355,7 +461,31 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
}
}
}
- return new Scan(asShapes.size(), acrossPages, covered, unjudged, unclassified, placed, panels);
+ // The other way round. A drawing the export lifted in front is painted over everything of
+ // the text layer, a panel's shading included, so a painted panel the page paints AFTER it
+ // must not lie under it: there the page covers the drawing, and in front the drawing
+ // covers the panel. Judged on the page's own paint order and this test's own geometry.
+ List liftedUnderALater = new ArrayList<>();
+ List liftedUnplaceable = new ArrayList<>();
+ for (String drawing : lifted) {
+ Integer drawnAt = paintedAt.get(drawing);
+ if (drawnAt == null) {
+ liftedUnplaceable.add(drawing);
+ continue;
+ }
+ for (String panel : panels) {
+ Integer panelAt = paintedAt.get(panel);
+ if (panelAt == null || panelAt <= drawnAt || panel.startsWith(drawing + "/")) {
+ continue;
+ }
+ if (overlaps(placed.get(drawing), placed.get(panel))) {
+ liftedUnderALater.add(drawing + " under " + panel);
+ break;
+ }
+ }
+ }
+ return new Scan(asShapes.size(), acrossPages, covered, unjudged, unclassified, placed, panels,
+ lifted.size(), liftedUnderALater, liftedUnplaceable);
}
/** Whether two boxes stand on one page and share any area. */
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index de7f8a542..5f084235a 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4434,19 +4434,121 @@ private void writeOverTheFlow(XWPFDocument document, DocumentNode node) throws E
* a node laid over the flow writes its text boxes in this order too.
*/
private static List inPaintOrder(DocumentNode node) {
- List layers =
- node instanceof com.demcha.compose.document.node.LayerStackNode stack ? stack.layers()
- : node instanceof ShapeContainerNode container ? container.layers()
- : null;
+ List layers = layersOf(node);
if (layers == null) {
return node.children();
}
+ return layersInPaintOrder(layers).stream()
+ .map(com.demcha.compose.document.node.LayerStackNode.Layer::node)
+ .toList();
+ }
+
+ /** The layers a stack or a container lays over one another, or {@code null} for any other node. */
+ private static List layersOf(DocumentNode node) {
+ return node instanceof com.demcha.compose.document.node.LayerStackNode stack ? stack.layers()
+ : node instanceof ShapeContainerNode container ? container.layers()
+ : null;
+ }
+
+ /**
+ * Layers in the order the page paints them: by z-index, the ones sharing one in the order they
+ * were given, which {@link java.util.stream.Stream#sorted} keeps.
+ */
+ private static List layersInPaintOrder(
+ List layers) {
return layers.stream()
.sorted(java.util.Comparator.comparingInt(com.demcha.compose.document.node.LayerStackNode.Layer::zIndex))
- .map(com.demcha.compose.document.node.LayerStackNode.Layer::node)
.toList();
}
+ /**
+ * Whether the page lays a node over a painted neighbour, and over nothing painted above it:
+ * the node stands in a layer, of any overlay it stands in, that the page paints after a layer
+ * of that overlay carrying a fill in the tree, and before none.
+ *
+ * A panel is written as a shaded cell, and a cell's fill belongs to the text layer, which
+ * both editors paint over everything drawn behind that layer. A shape drawn behind the text
+ * under such a neighbour is therefore not seen at all, however the page stacks the two:
+ * measured at 300 dpi on the one document of the fidelity corpus that holds the case, an 18pt
+ * icon laid over a card's corner through a layer stack, the engine's PDF draws 4308 pixels of
+ * the icon's own ink and Word 16.0.20527 and LibreOffice 24.2 draw none — not one pixel
+ * anywhere on the page. With the card unpainted, the one difference, Word draws every pixel
+ * the engine does. So the order is taken from the tree: a layer the page paints after a
+ * painted one stands in front of the text.
+ *
+ * A painted layer the page paints after the node answers false, whatever lies
+ * below: there the page covers the node, and a shape in front would cover the panel instead,
+ * which is the same loss the other way round.
+ *
+ * A node standing inside a painted surface is another case, decided by the surface it is
+ * in ({@code surfaceBehind}) rather than by its neighbours. Only the shapes a node paints
+ * itself are asked this ({@code drawAndNameOwnFragments}): a picture drawn where the page puts
+ * it ({@code drawWhereThePagePutsIt}) and a pass of the layout's own, a timeline's rail, are
+ * not, and over a painted neighbour they stay behind the text and are named there
+ * ({@code nameTheDrawingsAPaintedPanelCovers}). The corpus holds no instance of either.
+ */
+ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
+ boolean over = false;
+ DocumentNode child = node;
+ for (DocumentNode parent = layout.parentOf(child); parent != null;
+ child = parent, parent = layout.parentOf(child)) {
+ List layers = layersOf(parent);
+ if (layers == null) {
+ continue;
+ }
+ List order = layersInPaintOrder(layers);
+ int itsPlace = -1;
+ for (int i = 0; i < order.size(); i++) {
+ // By identity: two layers holding equal nodes are two layers.
+ if (order.get(i).node() == child) {
+ itsPlace = i;
+ break;
+ }
+ }
+ if (itsPlace < 0) {
+ continue;
+ }
+ for (int i = 0; i < order.size(); i++) {
+ if (i == itsPlace || !paintsAFill(order.get(i).node())) {
+ continue;
+ }
+ if (i > itsPlace) {
+ return false;
+ }
+ over = true;
+ }
+ }
+ return over;
+ }
+
+ /**
+ * Whether a node, or anything it holds, carries a fill — the paint a cell written for it puts
+ * in the text layer, over what is drawn behind it.
+ *
+ * Read off the tree, as the neighbour's place in the stack is, and so wider than what the
+ * export actually shades: whether a section, a container or a row that paints becomes a cell
+ * with shading is decided as it is written — a row's paint goes unwritten where its panel
+ * cannot hold it, a container's where it is a band's layer or a stack's column — and the tree
+ * cannot be asked that ({@code rememberShadedPanel}, which the naming pass uses, is the side
+ * that knows). Wider is the safe way round here: a neighbour counted that writes no shading
+ * only lifts a drawing that nothing would have covered. A node bearing borders alone writes no
+ * shading and is not counted at all; a shape container's own fill is not, being a drawing
+ * rather than a cell, though a fill inside it is; and a table cell's own shading belongs to a
+ * cell and not to a node.
+ */
+ private static boolean paintsAFill(DocumentNode node) {
+ if ((node instanceof SectionNode || node instanceof ContainerNode || node instanceof RowNode)
+ && paintOf(node).fill() != null) {
+ return true;
+ }
+ for (DocumentNode child : node.children()) {
+ if (paintsAFill(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/** How far a line set in a text box over the flow may run past its box before Word breaks it. */
private static final double TEXT_BOX_SLACK_RATIO = 0.25;
@@ -5086,6 +5188,9 @@ private boolean drawOwnFragments(DocumentNode node) {
private boolean drawAndNameOwnFragments(DocumentNode node) {
boolean overlayFront = drawsInFront();
+ // Beside a panel the page paints under it, not inside one: its shading covers what is
+ // drawn behind the text just the same (laidOverAPaintedNeighbour).
+ boolean overAPaintedNeighbour = surfaceBehind == null && laidOverAPaintedNeighbour(node);
boolean front = false;
boolean drew = false;
// Whether a box it drew rounds its corners by radii that differ.
@@ -5102,14 +5207,14 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
unequalCorners |= !shapes.isEmpty() && roundsCornersUnequally(fragment);
continue;
}
- // On a painted surface — a panel, or a filled cell — whose shading both editors paint
- // over what lies behind the text, a shape stands in front unless it frames a line of
- // text or a picture on its page: a template sets an icon beside its heading through a
- // stack of one layer, which kept every payment panel's heading icon behind its
- // shading. Elsewhere the overlays it sits in decide (drawsInFront). A badge whose
- // glyph is drawn over it in front (drawnOverItsBadge) stands in front too: the
- // picture it frames is its own, not the flow's.
- boolean inFront = surfaceBehind != null && !Double.isNaN(canvasHeight)
+ // Under a fill both editors paint over what lies behind the text — the surface the
+ // shape stands on, or a neighbour the page paints under it — a shape stands in front
+ // unless it frames a line of text or a picture on its page: a template sets an icon
+ // beside its heading through a stack of one layer, which kept every payment panel's
+ // heading icon behind its shading. Elsewhere the overlays it sits in decide
+ // (drawsInFront). A badge whose glyph is drawn over it in front (drawnOverItsBadge)
+ // stands in front too: the picture it frames is its own, not the flow's.
+ boolean inFront = (surfaceBehind != null || overAPaintedNeighbour) && !Double.isNaN(canvasHeight)
? holdsItsDrawnGlyph(node) || !framesText(fragment, layout.textOnPage(fragment.pageIndex()))
: overlayFront;
if (queueDrawing(fragment, inFront)) {
@@ -5126,8 +5231,10 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
if (badgeText != null) {
message.append("; its text is held in the shape, which stands in front of the text");
} else if (front) {
- message.append("; in front of the text, since the panel's shading is painted over "
- + "shapes behind it");
+ message.append("; in front of the text, since the shading of ")
+ .append(surfaceBehind != null ? "the panel it stands in"
+ : "a panel the page paints under it")
+ .append(" is painted over shapes behind it");
} else if (surfaceBehind != null) {
message.append("; behind the text it frames, where the panel's shading hides it");
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index aedc7a763..f649c7bd2 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -11,29 +11,40 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentStroke;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayInputStream;
+import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
/**
- * A shaded cell's fill is part of the text layer in both Word and LibreOffice, and both paint it
- * over whatever is drawn behind that layer. So a drawing that stands beside a painted panel —
- * not inside it — and whose box reaches into that panel is covered by the panel's shading, and
- * the reader never sees that much of it.
+ * A panel is written as a shaded cell, and a cell's fill belongs to the text layer, which Word and
+ * LibreOffice both paint over everything drawn behind that layer. So a drawing that stands beside
+ * a painted panel — on a layer above it, not inside it — and is drawn behind the text is covered
+ * by that panel's shading, and the reader never sees it.
*
* Measured on the fidelity corpus in Word 16.0.20527 and LibreOffice 24.2, at 300 dpi: the one
- * document of the seventy-one that holds the case lays an 18pt icon over a card's top right
- * corner through a layer stack. The engine's PDF draws 4308 pixels of the icon's own ink; Word's
- * draws none, and LibreOffice's draws none — not one pixel anywhere on the page, the icon's whole
- * box coming back the card's colour. Until the stack's order is carried into the document, the
- * export says so rather than losing it in silence.
+ * document of the seventy-one that holds the case lays an 18pt icon over a card's top right corner
+ * through a layer stack. Drawn behind the text, the engine's PDF draws 4308 pixels of the icon's
+ * own ink, Word's draws none, and LibreOffice's draws none — not one pixel anywhere on the page,
+ * the icon's whole box coming back the card's colour. With the card unpainted, the one difference,
+ * Word draws every pixel the engine does.
*
- * A drawing standing inside a painted panel is a different case with its own note:
- * there the export already puts it in front wherever it frames no text of the flow. Measured the
+ *
So the stack's order is carried into the document: a layer the page paints after a painted one
+ * is drawn in front of the text ({@code laidOverAPaintedNeighbour}). What stays behind the text is
+ * what the order or the flow keeps there — a drawing a painted layer is laid over in turn, and one
+ * whose box holds a line of the flow, which in front it would cover — and that the export names,
+ * rather than losing it in silence.
+ *
+ * A drawing standing inside a painted panel is a different case with its own note and
+ * its own tests ({@code DocxDrawingsTest}): there the surface it stands on decides. Measured the
* same way, that case has no instance at all in the corpus — its branch is taken 55 times over 14
* documents and goes in front every one of them.
*
@@ -43,53 +54,138 @@ class DocxDrawingUnderANeighboursFillTest {
private static final DocumentColor BAND = DocumentColor.rgb(90, 120, 200);
private static final DocumentColor ACCENT = DocumentColor.rgb(26, 86, 148);
+ private static final DocumentColor OVER = DocumentColor.rgb(10, 170, 90);
+
+ /** The ink of the pin, as a shape's fill is written: what to pick its anchor out by. */
+ private static final String PIN_INK = "1A5694";
private static final String COVERED = "a panel painted beside it";
@Test
- void aShapeOverAPaintedNeighbourIsNamedAsCoveredByIt() throws Exception {
- DocxExportReport report = export(session -> session.pageFlow(page -> page
+ void aShapeOverAPaintedNeighbourStandsInFrontOfIt() throws Exception {
+ Export export = export(session -> session.pageFlow(page -> page.add(pinOverABand(BAND, 150, 2))));
+
+ assertThat(pinAnchor(export))
+ .as("the stack paints the pin over the band, so the document draws it in front")
+ .contains("behindDoc=\"0\"");
+ assertThat(covered(export.report()))
+ .as("nothing to name: the pin is not behind the text at all")
+ .isEmpty();
+ assertThat(detailsOf(export.report(), "/Pin"))
+ .anySatisfy(detail -> assertThat(detail).contains("in front of the text, since the shading of "
+ + "a panel the page paints under it"));
+ }
+
+ @Test
+ void aShapeOverAnUnpaintedNeighbourStaysBehindTheText() throws Exception {
+ // The one difference from the fixture above. With no fill beside it nothing is painted over
+ // the pin, and Word draws all 3237 pixels of it against the engine's 3237 with the anchor
+ // behind the text — which is why behindDoc alone says nothing about whether it is seen.
+ Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
- .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
- .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ .layer(new SectionBuilder().name("Band").fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
- .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(), 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
.build())));
- assertThat(covered(report)).singleElement().asString()
- .as("the pin, and the panel that covers it, both named")
- .contains("Pin").contains("Band[0]").contains(COVERED);
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report())).isEmpty();
}
@Test
- void aShapeOverAnUnpaintedNeighbourIsNotNamed() throws Exception {
- // The one difference from the fixture above. Without the fill there is nothing to paint
- // over the pin, and Word draws all 3237 pixels of it against the engine's 3237.
- DocxExportReport report = export(session -> session.pageFlow(page -> page
+ void aShapeOverANeighbourHoldingAPaintedPanelStandsInFrontOfIt() throws Exception {
+ // The fill is a card inside the neighbour rather than the layer itself: the cell written
+ // for that card paints over the pin just the same, so the neighbour counts as painted by
+ // what it holds and not only by what it is.
+ Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fixedWidth(200)
- .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ .addSection("Card", card -> card.fillColor(BAND)
+ .addParagraph(p -> p.name("Stage").text("Stage two")))
+ .build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aShapeOverANeighbourBearingBordersAloneStaysBehindTheText() throws Exception {
+ // A neighbour covers only where a fill is written for it. One bearing borders alone writes
+ // no shading, so there is nothing to paint over the pin: it keeps the place the overlays
+ // round it give it, and nothing is named.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band")
+ .stroke(DocumentStroke.of(ACCENT, 1)).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aShapeAPaintedNeighbourIsLaidOverInTurnStaysBehindAndIsNamed() throws Exception {
+ // The page paints a third layer, painted, over the pin. There the pin belongs behind that
+ // layer's shading, and in front it would cover the shading instead — the same loss the
+ // other way round. So it stays behind the text, where the layer under it covers it, and
+ // the export says so.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Lower").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
- .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(), 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .layer(new SectionBuilder().name("Upper").fillColor(OVER).fixedWidth(200)
+ .addParagraph(p -> p.name("Over").text("Over it")).build(),
+ LayerAlign.TOP_LEFT, 2)
.build())));
- assertThat(covered(report)).isEmpty();
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report())).singleElement().asString()
+ .as("the pin, and the panel that covers it, both named")
+ .contains("Pin").contains("Lower[0]").contains(COVERED);
}
@Test
- void aShapeBesideAPaintedNeighbourItDoesNotReachIsNotNamed() throws Exception {
- // Set past the band's right edge: the two share a line and no area, and nothing of the
- // pin is under the band's shading. This is the geometry doing the deciding — the pin is
- // drawn behind the text here exactly as it is in the fixture that is named.
- DocxExportReport report = export(session -> session.pageFlow(page -> page
+ void aShapeOverAPaintedNeighbourFramingALineOfTheFlowStaysBehindAndIsNamed() throws Exception {
+ // In front it would be painted over the band's own line of text. A drawing that frames a
+ // line of the flow stays behind it, as it does inside a painted panel, and what the
+ // neighbour's shading then takes of it is named.
+ Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
- .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
- .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(), 210, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(190, 30).fillColor(ACCENT).build(),
+ 2, 0, LayerAlign.TOP_LEFT, 1)
.build())));
- assertThat(covered(report)).isEmpty();
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report())).singleElement().asString()
+ .contains("Pin").contains("Band[0]").contains(COVERED);
+ }
+
+ @Test
+ void aShapeBesideAPaintedNeighbourItDoesNotReachStandsInFrontToo() throws Exception {
+ // Set past the band's right edge, where the band's shading covers nothing of it. The order
+ // is the tree's, not the geometry's: a layer the page paints over a painted one is drawn in
+ // front whether the two boxes meet or not, and where they do not the reader sees the same
+ // page either way.
+ Export export = export(session -> session.pageFlow(page -> page.add(pinOverABand(BAND, 210, 2))));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
}
@Test
@@ -99,7 +195,7 @@ void aShapeInsideAPaintedPanelAndBehindItsTextIsNotNamedAsCoveredByIt() throws E
// which has a note of its own — and the box it lies in is that same panel's. Only the
// test of which box holds which keeps it from being named here as well, so that test is
// what this fixture exercises: a shape the panel puts in front would never reach it.
- DocxExportReport report = export(session -> session.pageFlow(page -> page
+ Export export = export(session -> session.pageFlow(page -> page
.addSection("Card", card -> card.fillColor(BAND)
.addLayerStack(stack -> stack
.layer(new ShapeBuilder().name("Track").size(200, 20)
@@ -107,11 +203,10 @@ void aShapeInsideAPaintedPanelAndBehindItsTextIsNotNamedAsCoveredByIt() throws E
.layer(new ParagraphBuilder().name("Label").text("80%").build(),
LayerAlign.CENTER)))));
- assertThat(report.notes())
+ assertThat(detailsOf(export.report(), "/Track"))
.as("the track is behind the text, so it does reach the test of which box holds it")
- .anyMatch(note -> note.detail() != null
- && note.detail().contains("behind the text it frames"));
- assertThat(covered(report)).isEmpty();
+ .anySatisfy(detail -> assertThat(detail).contains("behind the text it frames"));
+ assertThat(covered(export.report())).isEmpty();
}
@Test
@@ -119,10 +214,10 @@ void aBadgeInFrontOverAPaintedNeighbourIsNotNamedAsCoveredByIt() throws Exceptio
// A badge holding its initials is handed to the queue already in front, with the flag for
// the call saying otherwise. Standing over a painted neighbour it is seen plainly, so it
// must not be named as hidden under it.
- DocxExportReport report = export(session -> session.pageFlow(page -> page
+ Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
- .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
.position(new ShapeContainerBuilder().name("Badge").circle(28)
.fillColor(ACCENT)
@@ -130,39 +225,19 @@ void aBadgeInFrontOverAPaintedNeighbourIsNotNamedAsCoveredByIt() throws Exceptio
150, 2, LayerAlign.TOP_LEFT, 1)
.build())));
- assertThat(covered(report)).isEmpty();
- }
-
- @Test
- void aShapeOverANeighbourBearingBordersAloneIsNotNamed() throws Exception {
- // A panel is named as covering only where a fill was actually written for it. One bearing
- // borders alone writes no shading, so there is nothing to paint over the pin, and saying
- // the pin is hidden under it would be false. This is why the panels are taken where the
- // export writes their fill rather than read off the tree, where a stroke and a fill look
- // alike.
- DocxExportReport report = export(session -> session.pageFlow(page -> page
- .add(new LayerStackBuilder().name("Stack")
- .layer(new SectionBuilder().name("Band")
- .stroke(DocumentStroke.of(ACCENT, 1)).fixedWidth(200)
- .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
- LayerAlign.TOP_LEFT, 0)
- .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
- 150, 2, LayerAlign.TOP_LEFT, 1)
- .build())));
-
- assertThat(covered(report)).isEmpty();
+ assertThat(covered(export.report())).isEmpty();
}
@Test
void aShapeOverAPaintedNeighbourInAnEarlierSectionIsNamedToo() throws Exception {
// A section's pages are counted from its own first, and a page's height is its section's,
// so a drawing and a panel are only ever judged against each other within one section. The
- // pin of each section stands over that section's band; neither may be missed, and neither
- // may be matched against the other section's band.
+ // pin of each section stands under that section's upper band; neither may be missed, and
+ // neither may be matched against the other section's.
List notes = exportSections(
- page -> page.add(stack("Band one", "Pin one")),
+ page -> page.add(pinnedBetweenTwoBands("Band one", "Pin one")),
page -> page.addParagraph(p -> p.text("A section of plain text, no panel in it.")),
- page -> page.add(stack("Band two", "Pin two")));
+ page -> page.add(pinnedBetweenTwoBands("Band two", "Pin two")));
assertThat(notes.stream()
.filter(note -> note.detail() != null && note.detail().contains(COVERED))
@@ -180,8 +255,8 @@ void twoSectionsOfOnePresetAreEachNamed() throws Exception {
// the second section's loss is as real as the first's, and a reader of the report is
// told which section each is in.
List notes = exportSections(
- page -> page.add(stack("Band", "Pin")),
- page -> page.add(stack("Band", "Pin")));
+ page -> page.add(pinnedBetweenTwoBands("Band", "Pin")),
+ page -> page.add(pinnedBetweenTwoBands("Band", "Pin")));
List covered = notes.stream()
.filter(note -> note.detail() != null && note.detail().contains(COVERED))
@@ -193,12 +268,28 @@ void twoSectionsOfOnePresetAreEachNamed() throws Exception {
}
/** A band painted on the lower layer with a pin set over it on the layer above. */
- private static com.demcha.compose.document.node.DocumentNode stack(String band, String pin) {
+ private static com.demcha.compose.document.node.DocumentNode pinOverABand(DocumentColor fill,
+ double x, double y) {
+ return new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(fill).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(), LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ x, y, LayerAlign.TOP_LEFT, 1)
+ .build();
+ }
+
+ /**
+ * A pin between two painted bands: the page paints one under it and one over it, so the pin
+ * stays behind the text and the one under it covers it — the case the report still names.
+ */
+ private static com.demcha.compose.document.node.DocumentNode pinnedBetweenTwoBands(String band, String pin) {
return new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name(band).fillColor(BAND).fixedWidth(200)
.addParagraph(p -> p.text("Stage")).build(), LayerAlign.TOP_LEFT, 0)
.position(new ShapeBuilder().name(pin).size(30, 14).fillColor(ACCENT).build(),
150, 2, LayerAlign.TOP_LEFT, 1)
+ .layer(new SectionBuilder().name("Over " + band).fillColor(OVER).fixedWidth(200)
+ .addParagraph(p -> p.text("Over")).build(), LayerAlign.TOP_LEFT, 2)
.build();
}
@@ -229,13 +320,42 @@ private static List covered(DocxExportReport report) {
.toList();
}
- private static DocxExportReport export(Consumer content) throws Exception {
+ /** The details of every note on a node whose path ends in {@code path}. */
+ private static List detailsOf(DocxExportReport report, String path) {
+ return report.notes().stream()
+ .filter(note -> note.path() != null && note.path().contains(path) && note.detail() != null)
+ .map(DocxExportReport.Note::detail)
+ .toList();
+ }
+
+ /** The one anchor holding the pin's ink. */
+ private static String pinAnchor(Export export) throws Exception {
+ try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(export.bytes()))) {
+ List pins = new ArrayList<>();
+ Matcher matcher = Pattern.compile("", Pattern.DOTALL)
+ .matcher(document.getDocument().xmlText());
+ while (matcher.find()) {
+ if (matcher.group().contains(PIN_INK)) {
+ pins.add(matcher.group());
+ }
+ }
+ assertThat(pins).as("one anchor drawn in the pin's ink").hasSize(1);
+ return pins.get(0);
+ }
+ }
+
+ /** What one export gives: the file, to read the anchors out of, and the report. */
+ private record Export(byte[] bytes, DocxExportReport report) {
+ }
+
+ private static Export export(Consumer content) throws Exception {
AtomicReference report = new AtomicReference<>();
+ byte[] bytes;
try (DocumentSession session = GraphCompose.document().pageSize(400, 400)
.margin(DocumentInsets.of(20)).create()) {
content.accept(session);
- session.export(new DocxSemanticBackend(report::set));
+ bytes = session.export(new DocxSemanticBackend(report::set));
}
- return report.get();
+ return new Export(bytes, report.get());
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
index f55536041..b152a3496 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
@@ -229,27 +229,72 @@ void aBadgeAloneInTheFlowKeepsItsPlaceWithItsGlyphDrawnOverIt() throws Exception
}
@Test
- void aFillUnderTextInAPaintedPanelStaysBehindIt() throws Exception {
+ void aFillUnderTextInAPaintedPanelStaysBehindItAndThePanelHidesIt() throws Exception {
// A track holding only drawing, with its label laid over it: in front, it would cover
// the label. Only a badge whose glyph is drawn over it frames nothing of the flow.
- try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page
+ //
+ // behindDoc on its own says nothing about whether the reader sees it: measured at 300 dpi,
+ // the same fixture with its panel unpainted keeps behindDoc="1" and Word draws every pixel
+ // of the shape. What tells the two apart is the fill the panel paints over it, and the
+ // control below is this fixture less that fill — so the pair is the sign of an overlapping
+ // fill, and neither half is green both ways.
+ AtomicReference report = new AtomicReference<>();
+ try (XWPFDocument document = export(report, session -> session.pageFlow(page -> page
.addSection("Card", s -> s.fillColor(DocumentColor.rgb(220, 230, 240))
.addLayerStack(stack -> stack
- .layer(new ShapeContainerBuilder().name("Track").rectangle(200, 20)
- .fillColor(DocumentColor.rgb(90, 120, 200))
- .center(new com.demcha.compose.document.dsl.ShapeBuilder().name("Fill")
- .size(100, 20).fillColor(ACCENT).build())
- .build(),
- LayerAlign.TOP_LEFT)
+ .layer(trackHoldingItsFill(), LayerAlign.TOP_LEFT)
.layer(new ParagraphBuilder().name("Label").text("80%").build(),
- LayerAlign.CENTER))))) ) {
- List tracks = anchors(document.getDocument().xmlText()).stream()
- .filter(anchor -> anchor.contains("5A78C8")).toList();
+ LayerAlign.CENTER)))))) {
+ assertThat(trackAnchors(document)).singleElement().asString().contains("behindDoc=\"1\"");
+ assertThat(hidingNotes(report.get(), "/Track"))
+ .as("the panel's shading over it is named, not lost in silence")
+ .isNotEmpty();
+ }
- assertThat(tracks).singleElement().asString().contains("behindDoc=\"1\"");
+ AtomicReference unpainted = new AtomicReference<>();
+ try (XWPFDocument document = export(unpainted, session -> session.pageFlow(page -> page
+ .addSection("Card", s -> s
+ .addLayerStack(stack -> stack
+ .layer(trackHoldingItsFill(), LayerAlign.TOP_LEFT)
+ .layer(new ParagraphBuilder().name("Label").text("80%").build(),
+ LayerAlign.CENTER)))))) {
+ assertThat(trackAnchors(document)).singleElement().asString()
+ .as("behind the text here too, and seen: the flag is not the difference")
+ .contains("behindDoc=\"1\"");
+ assertThat(hidingNotes(unpainted.get(), "/Track"))
+ .as("nothing painted over it, so nothing to name")
+ .isEmpty();
}
}
+ /** A progress track whose own fill is the drawing inside it. */
+ private static com.demcha.compose.document.node.DocumentNode trackHoldingItsFill() {
+ return new ShapeContainerBuilder().name("Track").rectangle(200, 20)
+ .fillColor(DocumentColor.rgb(90, 120, 200))
+ .center(new com.demcha.compose.document.dsl.ShapeBuilder().name("Fill")
+ .size(100, 20).fillColor(ACCENT).build())
+ .build();
+ }
+
+ /** The anchors drawn in the track's own ink. */
+ private static List trackAnchors(XWPFDocument document) {
+ return anchors(document.getDocument().xmlText()).stream()
+ .filter(anchor -> anchor.contains("5A78C8")).toList();
+ }
+
+ /**
+ * The notes saying a panel's shading hides a drawing on a node whose path ends in {@code path}
+ * — the export's own sign that a fill is painted over it, which {@code behindDoc} is not.
+ */
+ private static List hidingNotes(DocxExportReport report, String path) {
+ return report.notes().stream()
+ .filter(note -> note.path() != null && note.path().contains(path)
+ && note.detail() != null
+ && note.detail().contains("the panel's shading hides it"))
+ .map(DocxExportReport.Note::detail)
+ .toList();
+ }
+
@Test
void aBadgeAndItsGlyphInAPaintedPanelStandInFrontOfItsShading() throws Exception {
// Kept in the flow, the glyph was written white on the panel's shading and the disc,
@@ -687,16 +732,43 @@ void aShapeInAPanelInAColumnIsDrawnInFrontToo() throws Exception {
}
@Test
- void aRingRoundAPhotoInAPaintedCardStaysBehindThePhoto() throws Exception {
- try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page
+ void aRingRoundAPhotoInAPaintedCardStaysBehindThePhotoAndTheCardHidesIt() throws Exception {
+ // A disc exactly under its own photo: it frames that picture, so it stays behind it, and
+ // the card's shading then takes what the photo does not cover — measured at 300 dpi, 623
+ // pixels of the engine's ink down to 50, the disc being 97.8% under its photo in the
+ // engine's own PDF too.
+ //
+ // As above, behindDoc is not the sign of that: the control is this fixture less the card's
+ // fill, where the flag is the same and nothing is painted over the disc.
+ AtomicReference report = new AtomicReference<>();
+ try (XWPFDocument document = export(report, session -> session.pageFlow(page -> page
.addSection("Card", card -> card.fillColor(DocumentColor.rgb(220, 230, 240))
- .add(new ShapeContainerBuilder().name("Portrait").circle(60)
- .stroke(DocumentStroke.of(ACCENT, 1)).fillColor(ACCENT)
- .center(new ImageBuilder().name("Photo").source(pngBytes()).size(60, 60).build())
- .build()))))) {
+ .add(portraitRingingItsPhoto()))))) {
assertThat(anchors(document.getDocument().xmlText())).singleElement().asString()
.contains("behindDoc=\"1\"");
+ assertThat(hidingNotes(report.get(), "/Portrait"))
+ .as("the card's shading over the ring is named")
+ .isNotEmpty();
}
+
+ AtomicReference unpainted = new AtomicReference<>();
+ try (XWPFDocument document = export(unpainted, session -> session.pageFlow(page -> page
+ .addSection("Card", card -> card.add(portraitRingingItsPhoto()))))) {
+ assertThat(anchors(document.getDocument().xmlText())).singleElement().asString()
+ .as("behind the text here too, and seen: the flag is not the difference")
+ .contains("behindDoc=\"1\"");
+ assertThat(hidingNotes(unpainted.get(), "/Portrait"))
+ .as("nothing painted over it, so nothing to name")
+ .isEmpty();
+ }
+ }
+
+ /** A portrait disc with its photo drawn over it at the disc's own size. */
+ private static com.demcha.compose.document.node.DocumentNode portraitRingingItsPhoto() {
+ return new ShapeContainerBuilder().name("Portrait").circle(60)
+ .stroke(DocumentStroke.of(ACCENT, 1)).fillColor(ACCENT)
+ .center(new ImageBuilder().name("Photo").source(pngBytes()).size(60, 60).build())
+ .build();
}
@Test
From 752472323c240b47c7b4c5ccf7c7cd3aa19a617d Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 1 Nov 2026 21:02:20 +0000
Subject: [PATCH 2/7] fix(docx): take a stack's order whole, and say what the
corpus cannot judge
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Whether a shape goes in front was decided shape by shape, so over one painted
neighbour a shape clear of the text went in front while a shape above it that
framed a line stayed behind. Word draws every shape in front over every shape
behind, so the file then drew the lower of the two over the higher, which is the
loss this was fixing, the other way round.
The order is now taken whole, or not taken at all: where any drawing over that
painted layer has to stay behind, none of them is lifted. The test of whether a
shape frames a line of the flow moved with it, asked once of the stack instead
of again of each shape — the second asking became unreachable, and breaking it
reddened no test, so it is gone.
That has a price, and it falls on the one live preset of the corpus that could
have used the fix. cv-sidebar_portrait lays thirty-seven drawings over its
painted sidebar; one of them frames the photo it rings, so none is lifted and
the document is byte for byte what the export wrote before. Word and
LibreOffice draw that page identically either way — 0 of 3,868,706 pixels
differ at 200 dpi in each — but that is a measurement of this preset, not a
property of the rule.
So one document of the seventy-one changes now, by one anchor: the one the case
was measured on. Over the changed corpus drawings-follow-text re-measures at
309 of 325 drawings following their text in Word 16.0.20527, the same two
counts as before. Both documents whose bytes this change has touched at any
point were run in both exports and contribute the same either way.
The guard gains the check the other way round — a drawing lifted in front must
lie under neither a painted panel nor a drawing left behind that the page paints
after it, judged on the page's own paint order — and, with it, the numbers that
say what this corpus can be asked. It lifts one drawing and offers no pair at
all for those two lists, both asserted exactly. The first version of the check
reported "0 of 38" and that was vacuous; a count of the pairs says so at once.
A neighbour is counted as painted from the tree, which is wider than what the
export shades in one place and narrower in another. Narrower for a shape
container written as a shaded cell rather than drawn: a shape above one of
those is still covered, and the naming pass names it. Said as the known loss it
is, rather than as a safe side.
The note no longer asserts shading that may not have been written: a layer
counted from the tree carries a fill, and what a cell written for it would do
with that fill is what the note now says.
LibreOffice's placing of a cell-anchored shape, 14.16pt right of Word and the
page, goes into the CHANGELOG and the capability matrix: before this the icon
was drawn nowhere at all, and now it is drawn whole and that far across.
---
CHANGELOG.md | 56 ++++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 48 +++---
.../fidelity/DocxNeighbourFillGuardTest.java | 145 +++++++++++++-----
.../semantic/docx/DocxSemanticBackend.java | 114 +++++++++++---
.../DocxDrawingUnderANeighboursFillTest.java | 105 ++++++++++++-
6 files changed, 371 insertions(+), 99 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50dfacaec..0c7cdaba7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,24 +24,29 @@ follow semantic versioning; release dates are ISO 8601.
overlay it stands in, now stands in front of the text, where no shading reaches it. The order is
the tree's — by z-index, the layers sharing one in the order they were given — and not the
geometry's: such a shape stands in front whether the two boxes meet or not. A neighbour counts
- as painted where it, or anything it holds, carries a fill in the tree. That is wider than what
- the export actually shades, since whether a node that paints becomes a cell with shading is
- decided as it is written and the tree cannot be asked, and wider is the safe way round: a
- neighbour counted that writes no shading only lifts a shape nothing would have covered. A node
- bearing borders alone carries no fill and is not counted, and a shape container's own fill is
- not either, being a drawing rather than a cell — though a fill inside it is.
- - Three things leave such a drawing behind the text, and the report carries an `APPROXIMATED` note
- naming it and one panel that covers it. A painted layer the page lays over the drawing in turn:
- there it belongs under that layer's shading, and in front it would cover the shading instead —
- the same loss the other way round. A drawing whose box holds a line of the flow or a picture,
- which in front it would cover, as inside a painted panel. And a drawing that is no shape of a
- node's own: a picture drawn where the page puts it, and a pass of the layout's own such as a
- timeline's rail, are not asked the question, and over a painted neighbour they stay behind and
- are named there — the corpus holds no instance of either. The panels the note is taken from are
- those the export writes a fill for, not the tree's, so a row written as a panel is named and a
- panel bearing borders alone is not; a drawing and a panel are only ever judged against each
- other within one section, whose pages are counted from its own first and whose page height is
- its own, and a document of several sections names the section each loss is in.
+ as painted where it, or anything it holds, carries a fill in the tree — wider than what the
+ export shades in one direction and narrower in another, both deliberately. Wider, because
+ whether a node that paints becomes a cell with shading is decided as it is written and the tree
+ cannot be asked: a neighbour counted that writes no shading only lifts a shape nothing would
+ have covered. Narrower, because a shape container's own fill is not counted, being usually drawn
+ rather than written as a cell — where one is written as a cell, holding text, a shape above it
+ is still covered, and named. A node bearing borders alone carries no fill and is not counted at
+ all; a fill inside either of those is.
+ - Four things leave such a drawing behind the text. A painted layer the page lays over the drawing
+ in turn: there it belongs under that layer's shading, and in front it would cover the shading
+ instead — the same loss the other way round. A drawing whose box holds a line of the flow or a
+ picture, which in front it would cover, as inside a painted panel. Another drawing over the same
+ painted layer that has to stay behind for either of those reasons: the stack's order is kept
+ whole or not taken at all, since Word draws every shape in front over every shape behind, and
+ lifting one of them alone would put the lower of two over the higher. And a drawing that is no
+ shape of a node's own: a picture drawn where the page puts it, and a pass of the layout's own
+ such as a timeline's rail, are not asked the question at all.
+ - The report carries an `APPROXIMATED` note for a drawing left behind wherever a panel it wrote a
+ fill for lies under it, naming the drawing and one such panel. The panels are those the export
+ writes a fill for, not the tree's, so a row written as a panel is named and a panel bearing
+ borders alone is not; a drawing and a panel are only ever judged against each other within one
+ section, whose pages are counted from its own first and whose page height is its own, and a
+ document of several sections names the section each loss is in.
- What it does not name is written out in the recipe and in the guard rather than left to be
discovered: a panel the layout breaks across pages, whose box is the one its first page gave
it — the guard holds those four by name, so a fifth is seen; a panel composed in an authored
@@ -57,6 +62,21 @@ follow semantic versioning; release dates are ISO 8601.
plainly sees as well: the same fixture with its panel unpainted keeps its anchor's
`behindDoc="1"` while showing all 3237 pixels of its shape in Word. It fails closed on two
fixtures of its own, one that must come back named and one that must come back in front.
+ - Taking the order whole has a price, and it is worth knowing where it falls: a stack in which one
+ drawing has to stay behind keeps all of them there, so the order is not carried for it at all.
+ Of the 71-document fidelity corpus, one document changes — the one that holds the case — and the
+ other seventy are byte for byte what the export wrote before. `cv-sidebar_portrait` is among
+ them: thirty-seven of its drawings stand over its painted sidebar, one of them frames the photo
+ it rings, and so none is lifted. Word and LibreOffice draw that page identically either way, 0
+ of 3,868,706 pixels differing at 200 dpi in each, but that is a measurement of that preset and
+ not a promise about the rule. Over the changed corpus `drawings-follow-text` re-measures in Word
+ 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as before.
+ - In LibreOffice 24.2 a shape anchored in a table cell stands the cell's left padding to the right
+ of where Word and the page put it, so the icon that was drawn nowhere at all is now drawn whole
+ and that far across: measured 14.16pt on the lifted icon, and 14.40pt right with 13.92pt low on
+ the same icon left behind the text by a bordered rather than filled card, Word being within
+ 0.24pt of the page in both. The fidelity gate reads lines of text, so this is recorded here and
+ in the capability matrix rather than caught by it.
- **A DOCX export asks the same order of a composed cell's paragraphs as of a block's, so a drawing
a table's cell draws is anchored in the paragraph of that cell which places it nearest rather than
in the first one the cell writes.**
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 457625e87..f18fc1d35 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -132,7 +132,7 @@ honour an option ignores it (documented contract).
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row; in a table cell, `w:keepLines` alone, as Word reads `w:keepNext` on the paragraph a cell opens with as keeping the row with what follows its table. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list, such a line or a rule the layout moves to a new page in the flow holds its space above there as a paragraph does, and a table, a row or a panel on that one page holds the gap the layout carries there with its own top edge. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed; where the page after a cut has other top and bottom margins, and the page above has room for it, the paragraph between the parts ends a Word section with them (per-page margins, below) |
-| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export actually shades (decided as it is written, so the tree cannot be asked) and wider the safe way round; not where it bears borders alone, and not for a shape container's own fill, which is a drawing rather than a cell. Three things leave such a drawing behind the text, and the report names it and one panel that covers it: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; and a drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — which is not asked the question. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
+| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export shades in one direction (whether a node that paints becomes a shaded cell is decided as it is written, so the tree cannot be asked, and a neighbour counted that writes no shading only lifts a shape nothing would have covered) and narrower in another (a shape container's own fill is not counted, being usually drawn rather than written as a cell; where one is written as a cell, holding text, a shape above it is still covered, and named); a node bearing borders alone is not counted at all. Four things leave such a drawing behind the text: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; another drawing over the same painted layer that has to stay behind for either of those reasons, the stack's order being kept whole or not taken at all, since Word draws every shape in front over every shape behind; and a drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — which is not asked the question. The report names a drawing left behind wherever a panel it wrote a fill for lies under it, with one such panel. In LibreOffice 24.2 a shape anchored in a cell stands the cell's left padding right of where Word and the page put it — measured 14.16pt on the lifted icon and 14.40pt right with 13.92pt low on the same icon left behind, Word within 0.24pt of the page in both — so where that icon was drawn nowhere before, it is now drawn whole and that far across. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
## Output surface and lifecycle
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 83d67c5e2..70f1e8bb5 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1257,36 +1257,44 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
whatever their z-index.
A panel's shading is part of the text layer, so it is painted over every
drawing behind that layer — including one that belongs to a *neighbour* of
- the panel rather than to the panel itself. A drawing on a layer above a
- painted layer beside it, an icon laid over a card's corner among them, is
- covered by that card wherever the two boxes meet, in Word and in
- LibreOffice alike, however the page stacks them: measured at 300 dpi, an
- 18pt icon over a card draws 4308 pixels of its ink in the engine's PDF and
- none at all in either editor's. So the stack's order is carried into the
+ the panel rather than to the panel itself. A drawing left behind the text
+ on a layer above a painted layer beside it, an icon laid over a card's
+ corner among them, was covered by that card wherever the two boxes met,
+ in Word and in LibreOffice alike, however the page stacked them: measured
+ at 300 dpi, an 18pt icon over a card drew 4308 pixels of its ink in the
+ engine's PDF and none at all in either editor's. So the stack's order is
+ carried into the
document: a shape a node paints itself, standing on a layer the page
paints after a painted layer of an overlay it stands in — by z-index, the
layers sharing one in the order they were given — is drawn in front of
the text, where no shading reaches it. The order is the tree's, not the
geometry's: such a shape stands in front whether the two boxes meet or
not. A neighbour counts as painted where it, or anything it holds,
- carries a fill in the tree. That is wider than what the export actually
- shades — whether a node that paints becomes a cell with shading is
- decided as it is written, and the tree cannot be asked — and wider is
- the safe way round, a neighbour that writes no shading only lifting a
- shape nothing would have covered. A node bearing borders alone carries no
- fill and is not counted; a shape container's own fill is not either,
- being a drawing rather than a cell, though a fill inside it is.
- Three things leave such a drawing behind the text, and the report names
- what the neighbour's shading then takes of it. A painted layer the page
- lays over the drawing in turn: there the drawing belongs under that
+ carries a fill in the tree — wider than what the export shades in one
+ direction and narrower in another, both deliberately. Wider, because
+ whether a node that paints becomes a cell with shading is decided as it
+ is written and the tree cannot be asked: a neighbour counted that writes
+ no shading only lifts a shape nothing would have covered. Narrower,
+ because a shape container's own fill is not counted, being usually drawn
+ rather than written as a cell — where one is written as a cell, holding
+ text, a shape above it is still covered, and named. A node bearing
+ borders alone carries no fill and is not counted at all; a fill inside
+ either of those is.
+ Four things leave such a drawing behind the text. A painted layer the
+ page lays over the drawing in turn: there the drawing belongs under that
layer's shading, and in front it would cover the shading instead — the
same loss the other way round. A drawing whose box holds a line of the
flow or a picture, which in front it would cover, as inside a painted
- panel. And a drawing that is not a shape a node paints itself: a picture
+ panel. Another drawing over the same painted layer that has to stay
+ behind for either of those reasons: the stack's order is kept whole, or
+ not taken at all, since Word draws every shape in front over every shape
+ behind, and lifting one of them alone would put the lower of two over the
+ higher. And a drawing that is not a shape a node paints itself: a picture
drawn where the page puts it and a pass of the layout's own, a timeline's
- rail, are not asked the question, so over a painted neighbour they stay
- behind and are named there. The note names the drawing and one panel that
- covers it, and in a document of several sections the section the loss is
+ rail, are not asked the question at all.
+ The report names a drawing left behind wherever a panel it wrote a fill
+ for lies under it, naming the drawing and one such panel, and in a
+ document of several sections the section the loss is
in. Five cases it names nothing for: a drawing under a panel the layout
breaks across pages, whose box is the one its first page gave it; one
under a panel composed in an authored table cell, which the layout gives
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 0a07f7053..23cdc9c33 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -84,8 +84,15 @@
* nothing. An entry coming back is a drawing the export has gone back to losing under a
* neighbour's fill, and this test then fails until either the export or the ledger is put right.
* What keeps a drawing behind the text on purpose — a painted layer the page lays over it in turn,
- * a box holding a line of the flow — the export names, and the corpus holds no instance of either
- * (so the fixtures below carry the positive signal that it still can).
+ * a box holding a line of the flow, another drawing of the stack that one of those keeps there —
+ * the export names, and the corpus holds no instance of the first two (so the fixtures below carry
+ * the positive signal that it still can).
+ *
+ * What this corpus can and cannot say is written down in numbers rather than left to a reader of
+ * empty lists: it lifts one drawing ({@link #LIFTED_IN_THE_CORPUS}) and offers no pair at all to
+ * judge the two lists of {@link #PAIRS_THE_CORPUS_OFFERS} over. Those rules are held by the
+ * fixtures of {@code DocxDrawingUnderANeighboursFillTest}, and what is held here is that the
+ * corpus has not started asking something new.
*
* @author Artem Demchyshyn
*/
@@ -153,31 +160,57 @@ class DocxNeighbourFillGuardTest {
/** What a drawing's note says when the export put it in front of the text. */
private static final String IN_FRONT = "in front of the text";
- /** What it says when the reason is a painted layer the page paints under the drawing. */
+ /** What it says when the reason is a layer the page paints under the drawing carrying a fill. */
private static final String LIFTED_OVER_A_NEIGHBOUR =
- "in front of the text, since the shading of a panel the page paints under it";
+ "in front of the text, since a layer the page paints under it carries a fill";
/**
* Drawings the export lifted in front of the text whose box lies in a painted panel the page
- * paints after them. Empty: there being in front is the same loss the other way round
- * — the drawing would be painted over that panel's shading — so the export keeps such a
- * drawing behind, and this is where a change that stopped doing so would show.
+ * paints after them. Empty, and empty for want of a pair rather than for want of a
+ * fault: see {@link #PAIRS_THE_CORPUS_OFFERS}.
*/
private static final List LIFTED_UNDER_A_LATER_PANEL = List.of();
/**
- * Drawings the export lifted in front whose place in the paint order this test cannot read,
- * the page painting no fragment under their own path. Empty today.
+ * Drawings the export lifted in front, and panels, whose place in the paint order this test
+ * cannot read, the page painting no fragment under their own path. Empty today: a pair that
+ * cannot be judged would otherwise be a pair quietly not judged.
*/
- private static final List LIFTED_UNPLACEABLE = List.of();
+ private static final List UNPLACEABLE = List.of();
+
+ /**
+ * Drawings the export lifted in front of the text that lie over a drawing left behind which
+ * the page paints after them — where the file would invert the two, Word drawing every shape
+ * in front over every shape behind. Empty, and empty for want of a pair rather than for want
+ * of a fault: see {@link #PAIRS_THE_CORPUS_OFFERS}.
+ */
+ private static final List LIFTED_OVER_A_LATER_DRAWING = List.of();
+
+ /**
+ * How many (lifted drawing, later panel or drawing) pairs the two lists above are judged over.
+ *
+ * Zero. Measured, and said out loud rather than left for a reader to take
+ * the two empty lists for a clean corpus: of the drawings the export lifts here, not one has a
+ * painted panel or a drawing left behind that the page paints after it, so neither list can
+ * have anything in it whatever the export does. The rules those lists stand for are held by
+ * the unit fixtures written for them in {@code DocxDrawingUnderANeighboursFillTest}; the
+ * number is asserted exactly so that a corpus which starts offering a pair is seen here
+ * instead of quietly making the lists mean something new.
+ */
+ private static final int PAIRS_THE_CORPUS_OFFERS = 0;
/**
* The drawings of the corpus the export lifts in front of the text over a painted layer under
- * them — 38 today, in two documents. A floor, and well under it, so that an export which
- * stopped lifting them is seen here rather than leaving a corpus with nothing to judge and
- * every list above still empty.
+ * them.
+ *
+ * One: the icon over the card, the case this was measured on. Asserted
+ * exactly rather than as a floor, so that a corpus which starts lifting more — or stops
+ * lifting this one — is seen here. The seventy other documents lift nothing: thirty-seven
+ * drawings of {@code cv-sidebar_portrait} stand over its painted sidebar, but one of them
+ * frames the photo it rings, and the stack's order is taken whole or not at all, so none of
+ * them is lifted and that document is byte for byte what the export wrote before.
*/
- private static final int AT_LEAST_THIS_MANY_LIFTED = 30;
+ private static final int LIFTED_IN_THE_CORPUS = 1;
@Test
void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
@@ -189,10 +222,12 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
List unjudged = new ArrayList<>();
List acrossPages = new ArrayList<>();
List liftedUnderALater = new ArrayList<>();
- List liftedUnplaceable = new ArrayList<>();
+ List liftedOverALater = new ArrayList<>();
+ List unplaceable = new ArrayList<>();
List stems = new ArrayList<>();
int drawings = 0;
int lifted = 0;
+ int pairs = 0;
for (DocxCorpusDocument document : DocxFidelityCorpusTest.corpus()) {
AtomicReference report = new AtomicReference<>();
Scan scan;
@@ -205,8 +240,10 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
stems.add(document.stem());
drawings += scan.drawings();
lifted += scan.lifted();
+ pairs += scan.pairs();
scan.liftedUnderALater().forEach(one -> liftedUnderALater.add(document.stem() + ": " + one));
- scan.liftedUnplaceable().forEach(one -> liftedUnplaceable.add(document.stem() + ": " + one));
+ scan.liftedOverALater().forEach(one -> liftedOverALater.add(document.stem() + ": " + one));
+ scan.unplaceable().forEach(one -> unplaceable.add(document.stem() + ": " + one));
scan.panelsAcrossPages().forEach(panel -> acrossPages.add(document.stem() + ": " + panel));
scan.unjudged().forEach(path -> unjudged.add(document.stem() + ": " + path));
scan.unclassified().forEach(note -> unclassified.add(document.stem() + ": " + note));
@@ -265,14 +302,22 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
.isEqualTo(new TreeMap<>(UNDER_A_NEIGHBOURS_FILL));
assertThat(lifted).as("the drawings the export lifts in front over a painted layer under "
+ "them, which the two lists below are judged over")
- .isGreaterThanOrEqualTo(AT_LEAST_THIS_MANY_LIFTED);
- assertThat(liftedUnplaceable)
- .as("every lifted drawing has a place in the page's paint order to judge by")
- .isEqualTo(LIFTED_UNPLACEABLE);
+ .isEqualTo(LIFTED_IN_THE_CORPUS);
+ assertThat(unplaceable.stream().distinct().sorted().toList())
+ .as("every lifted drawing, and everything judged against one, has a place in the "
+ + "page's paint order to judge by")
+ .isEqualTo(UNPLACEABLE);
+ assertThat(pairs).as("the pairs the two lists below are judged over: nothing found means "
+ + "nothing only where something was looked at, and here nothing is")
+ .isEqualTo(PAIRS_THE_CORPUS_OFFERS);
assertThat(liftedUnderALater.stream().sorted().toList())
.as("no drawing is lifted in front of a painted panel the page paints after it, "
+ "where in front is the same loss the other way round")
.isEqualTo(LIFTED_UNDER_A_LATER_PANEL);
+ assertThat(liftedOverALater.stream().sorted().toList())
+ .as("no drawing is lifted over a drawing left behind that the page paints after "
+ + "it, which the file would invert")
+ .isEqualTo(LIFTED_OVER_A_LATER_DRAWING);
}
@Test
@@ -332,11 +377,10 @@ void aDrawingOverAPaintedNeighbourIsCarriedInFrontOfIt() throws Exception {
.as("nothing covers the pin, so nothing is named for it")
.noneMatch(note -> note.detail() != null && note.detail().contains(COVERED));
assertThat(report.get().notes())
- .as("and the export says it stands in front because of the panel under it")
+ .as("and the export says it stands in front because of the layer under it")
.anyMatch(note -> note.path() != null && note.path().contains("Pin")
&& note.detail() != null
- && note.detail().contains("in front of the text, since the shading of "
- + "a panel the page paints under it"));
+ && note.detail().contains(LIFTED_OVER_A_NEIGHBOUR));
}
/**
@@ -347,13 +391,19 @@ void aDrawingOverAPaintedNeighbourIsCarriedInFrontOfIt() throws Exception {
* @param liftedUnderALater the ones of those whose box lies in a painted panel the page paints
* after them, where being in front is the loss the other way
* round
- * @param liftedUnplaceable the ones the page paints no fragment of under their own path, so
- * that their place in the paint order cannot be read
+ * @param liftedOverALater the ones lying over a drawing left behind that the page paints
+ * after them, where the file inverts the two
+ * @param unplaceable the drawings and panels the page paints no fragment of under their
+ * own path, so that their place in the paint order cannot be read
+ * @param pairs how many (lifted, later thing) pairs the two lists above were
+ * judged over: nothing found means nothing only if something was
+ * looked at
*/
private record Scan(int drawings, List panelsAcrossPages, List covered,
List unjudged, List unclassified,
Map placed, List panels,
- int lifted, List liftedUnderALater, List liftedUnplaceable) {
+ int lifted, List liftedUnderALater, List liftedOverALater,
+ List unplaceable, int pairs) {
}
/** Whether a path's box lies in the box of a painted panel it does not stand inside. */
@@ -461,31 +511,56 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
}
}
}
- // The other way round. A drawing the export lifted in front is painted over everything of
- // the text layer, a panel's shading included, so a painted panel the page paints AFTER it
- // must not lie under it: there the page covers the drawing, and in front the drawing
- // covers the panel. Judged on the page's own paint order and this test's own geometry.
+ // The other way round, twice. A drawing the export lifted in front is painted over
+ // everything of the text layer and over every drawing left behind it, so two things must
+ // not lie under it: a painted panel the page paints AFTER it, whose shading the drawing
+ // would cover instead of being covered by, and a drawing left behind that the page paints
+ // after it, whose place over it the file would invert. Judged on the page's own paint
+ // order and this test's own geometry, pair by pair, with the pairs counted so that
+ // "nothing found" cannot mean "nothing looked at".
List liftedUnderALater = new ArrayList<>();
- List liftedUnplaceable = new ArrayList<>();
+ List liftedOverALater = new ArrayList<>();
+ List unplaceable = new ArrayList<>();
+ int pairs = 0;
for (String drawing : lifted) {
Integer drawnAt = paintedAt.get(drawing);
if (drawnAt == null) {
- liftedUnplaceable.add(drawing);
+ unplaceable.add("lifted " + drawing);
continue;
}
for (String panel : panels) {
Integer panelAt = paintedAt.get(panel);
- if (panelAt == null || panelAt <= drawnAt || panel.startsWith(drawing + "/")) {
+ if (panelAt == null) {
+ unplaceable.add("panel " + panel);
continue;
}
+ if (panelAt <= drawnAt || panel.startsWith(drawing + "/")
+ || drawing.startsWith(panel + "/")) {
+ continue;
+ }
+ pairs++;
if (overlaps(placed.get(drawing), placed.get(panel))) {
liftedUnderALater.add(drawing + " under " + panel);
- break;
+ }
+ }
+ for (String other : behind) {
+ Integer otherAt = paintedAt.get(other);
+ if (otherAt == null) {
+ unplaceable.add("behind " + other);
+ continue;
+ }
+ if (otherAt <= drawnAt || other.startsWith(drawing + "/")
+ || drawing.startsWith(other + "/")) {
+ continue;
+ }
+ pairs++;
+ if (overlaps(placed.get(drawing), placed.get(other))) {
+ liftedOverALater.add(drawing + " over " + other);
}
}
}
return new Scan(asShapes.size(), acrossPages, covered, unjudged, unclassified, placed, panels,
- lifted.size(), liftedUnderALater, liftedUnplaceable);
+ lifted.size(), liftedUnderALater, liftedOverALater, unplaceable, pairs);
}
/** Whether two boxes stand on one page and share any area. */
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 5f084235a..8b6cc268b 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4463,8 +4463,8 @@ private static List layer
/**
* Whether the page lays a node over a painted neighbour, and over nothing painted above it:
- * the node stands in a layer, of any overlay it stands in, that the page paints after a layer
- * of that overlay carrying a fill in the tree, and before none.
+ * the node stands in a layer, of any layer stack or shape container it stands in, that the
+ * page paints after a layer of that one carrying a fill in the tree, and before none.
*
* A panel is written as a shaded cell, and a cell's fill belongs to the text layer, which
* both editors paint over everything drawn behind that layer. A shape drawn behind the text
@@ -4480,12 +4480,27 @@ private static List layer
* below: there the page covers the node, and a shape in front would cover the panel instead,
* which is the same loss the other way round.
*
+ * So does a drawing over that painted layer which has to stay behind the text, the node's
+ * own shapes included ({@code staysBehindTheText}): the stack's order is kept whole, or not
+ * taken at all. Word draws every shape in front over every shape behind, so lifting one
+ * drawing of a stack while another stays there would put the lower of the two over the
+ * higher — the loss this fixes, in the one place it would be made worse. Nothing of the
+ * fidelity corpus asks this: no drawing lifted there has a panel or a drawing painted after
+ * it at all ({@code DocxNeighbourFillGuardTest} counts the pairs, and the count is zero), so
+ * the rule is held by the unit fixtures written for it.
+ *
* A node standing inside a painted surface is another case, decided by the surface it is
* in ({@code surfaceBehind}) rather than by its neighbours. Only the shapes a node paints
* itself are asked this ({@code drawAndNameOwnFragments}): a picture drawn where the page puts
* it ({@code drawWhereThePagePutsIt}) and a pass of the layout's own, a timeline's rail, are
* not, and over a painted neighbour they stay behind the text and are named there
* ({@code nameTheDrawingsAPaintedPanelCovers}). The corpus holds no instance of either.
+ *
+ * It answers false rather than guessing wherever the tree does not say: the climb is over
+ * the layout's parents, so it ends at a node the layout did not place — one composed in an
+ * authored table cell — and a layer it cannot find among its parent's, which is the answer
+ * that leaves the export as it was. A canvas lays its children over one another too but holds
+ * no layers, so it is climbed past rather than read.
*/
private boolean laidOverAPaintedNeighbour(DocumentNode node) {
boolean over = false;
@@ -4508,6 +4523,7 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
if (itsPlace < 0) {
continue;
}
+ int paintedBelow = -1;
for (int i = 0; i < order.size(); i++) {
if (i == itsPlace || !paintsAFill(order.get(i).node())) {
continue;
@@ -4515,12 +4531,48 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
if (i > itsPlace) {
return false;
}
- over = true;
+ paintedBelow = Math.max(paintedBelow, i);
+ }
+ if (paintedBelow < 0) {
+ continue;
+ }
+ // Whole, or not at all: a drawing over that painted layer which has to stay behind
+ // the text keeps every drawing over it there too, the node's own shapes included.
+ for (int i = paintedBelow + 1; i < order.size(); i++) {
+ if (staysBehindTheText(order.get(i).node())) {
+ return false;
+ }
}
+ over = true;
}
return over;
}
+ /**
+ * Whether a node, or anything it holds, paints a shape that has to stay behind the text: one
+ * whose box holds a line of the flow or a picture, which in front it would cover.
+ *
+ * The same test the shape itself is given when it is drawn
+ * ({@code drawAndNameOwnFragments}), asked here of a neighbour instead, so that the two
+ * answers cannot differ. A badge whose glyph is drawn over it stands in front wherever it is
+ * and is not one of these.
+ */
+ private boolean staysBehindTheText(DocumentNode node) {
+ if (!holdsItsDrawnGlyph(node)) {
+ for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
+ if (framesText(fragment, layout.textOnPage(fragment.pageIndex()))) {
+ return true;
+ }
+ }
+ }
+ for (DocumentNode child : node.children()) {
+ if (staysBehindTheText(child)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Whether a node, or anything it holds, carries a fill — the paint a cell written for it puts
* in the text layer, over what is drawn behind it.
@@ -4531,10 +4583,18 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
* cannot hold it, a container's where it is a band's layer or a stack's column — and the tree
* cannot be asked that ({@code rememberShadedPanel}, which the naming pass uses, is the side
* that knows). Wider is the safe way round here: a neighbour counted that writes no shading
- * only lifts a drawing that nothing would have covered. A node bearing borders alone writes no
- * shading and is not counted at all; a shape container's own fill is not, being a drawing
- * rather than a cell, though a fill inside it is; and a table cell's own shading belongs to a
- * cell and not to a node.
+ * only lifts a drawing that nothing would have covered.
+ *
+ * It is narrower in one place, and that one is a known loss rather than a safe side: a
+ * shape container's own fill is not counted, being usually drawn rather than written as a
+ * cell, so where one is written as a cell — holding text — a drawing above it is
+ * still covered, and the naming pass names it. A node bearing borders alone writes no shading
+ * and is not counted at all; a table cell's own shading belongs to a cell and not to a node.
+ * A fill inside any of those is counted, the walk being over everything the node holds.
+ *
+ * Not to be confused with {@code paintsAPanel}, which asks whether a node paints anything
+ * at all, borders included, or {@code paintsAPanelOfItsOwn}, which asks whether a row's paint
+ * can be written as a panel here.
*/
private static boolean paintsAFill(DocumentNode node) {
if ((node instanceof SectionNode || node instanceof ContainerNode || node instanceof RowNode)
@@ -5188,9 +5248,10 @@ private boolean drawOwnFragments(DocumentNode node) {
private boolean drawAndNameOwnFragments(DocumentNode node) {
boolean overlayFront = drawsInFront();
- // Beside a panel the page paints under it, not inside one: its shading covers what is
- // drawn behind the text just the same (laidOverAPaintedNeighbour).
- boolean overAPaintedNeighbour = surfaceBehind == null && laidOverAPaintedNeighbour(node);
+ // Beside a layer the page paints under it, not inside one: a cell written for that layer
+ // puts its fill in the text layer, over what is drawn behind it, just the same
+ // (laidOverAPaintedNeighbour). Asked once, and only of a node that draws something.
+ Boolean overAPaintedNeighbour = surfaceBehind == null ? null : Boolean.FALSE;
boolean front = false;
boolean drew = false;
// Whether a box it drew rounds its corners by radii that differ.
@@ -5207,16 +5268,21 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
unequalCorners |= !shapes.isEmpty() && roundsCornersUnequally(fragment);
continue;
}
- // Under a fill both editors paint over what lies behind the text — the surface the
- // shape stands on, or a neighbour the page paints under it — a shape stands in front
- // unless it frames a line of text or a picture on its page: a template sets an icon
- // beside its heading through a stack of one layer, which kept every payment panel's
- // heading icon behind its shading. Elsewhere the overlays it sits in decide
- // (drawsInFront). A badge whose glyph is drawn over it in front (drawnOverItsBadge)
- // stands in front too: the picture it frames is its own, not the flow's.
- boolean inFront = (surfaceBehind != null || overAPaintedNeighbour) && !Double.isNaN(canvasHeight)
+ // On a painted surface, whose shading both editors paint over what lies behind the
+ // text, a shape stands in front unless it frames a line of text or a picture on its
+ // page: a template sets an icon beside its heading through a stack of one layer, which
+ // kept every payment panel's heading icon behind its shading. A badge whose glyph is
+ // drawn over it in front (drawnOverItsBadge) stands in front too: the picture it frames
+ // is its own, not the flow's. Over a painted neighbour that test is already made, of
+ // every shape of the stack at once, by laidOverAPaintedNeighbour — so a shape it
+ // answers for frames nothing, and goes in front. Elsewhere the overlays it sits in
+ // decide (drawsInFront).
+ if (overAPaintedNeighbour == null) {
+ overAPaintedNeighbour = laidOverAPaintedNeighbour(node);
+ }
+ boolean inFront = surfaceBehind != null && !Double.isNaN(canvasHeight)
? holdsItsDrawnGlyph(node) || !framesText(fragment, layout.textOnPage(fragment.pageIndex()))
- : overlayFront;
+ : overAPaintedNeighbour || overlayFront;
if (queueDrawing(fragment, inFront)) {
drew = true;
front |= inFront;
@@ -5231,10 +5297,12 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
if (badgeText != null) {
message.append("; its text is held in the shape, which stands in front of the text");
} else if (front) {
- message.append("; in front of the text, since the shading of ")
- .append(surfaceBehind != null ? "the panel it stands in"
- : "a panel the page paints under it")
- .append(" is painted over shapes behind it");
+ message.append(surfaceBehind != null
+ ? "; in front of the text, since the shading of the panel it stands in is "
+ + "painted over shapes behind it"
+ : "; in front of the text, since a layer the page paints under it carries a "
+ + "fill, whose shading a cell written for it would paint over shapes "
+ + "behind the text");
} else if (surfaceBehind != null) {
message.append("; behind the text it frames, where the panel's shading hides it");
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index f649c7bd2..d97f03d9d 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -72,8 +72,8 @@ void aShapeOverAPaintedNeighbourStandsInFrontOfIt() throws Exception {
.as("nothing to name: the pin is not behind the text at all")
.isEmpty();
assertThat(detailsOf(export.report(), "/Pin"))
- .anySatisfy(detail -> assertThat(detail).contains("in front of the text, since the shading of "
- + "a panel the page paints under it"));
+ .anySatisfy(detail -> assertThat(detail).contains(
+ "in front of the text, since a layer the page paints under it carries a fill"));
}
@Test
@@ -176,6 +176,96 @@ void aShapeOverAPaintedNeighbourFramingALineOfTheFlowStaysBehindAndIsNamed() thr
.contains("Pin").contains("Band[0]").contains(COVERED);
}
+ @Test
+ void aStackWhoseUpperShapeMustStayBehindKeepsEveryShapeOfItThere() throws Exception {
+ // Two shapes over one painted band: the lower clear of the band's line of text, the upper
+ // holding it. Word draws every shape in front over every shape behind, so lifting the
+ // lower one alone would draw it over the upper one, which the page draws over it — the
+ // loss this rule fixes, made worse. The stack's order is kept whole: neither is lifted,
+ // and the panel beside them is named for both.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Over").size(190, 30).fillColor(OVER).build(),
+ 2, 0, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the lower shape stays behind with the upper one, not over it")
+ .contains("behindDoc=\"1\"");
+ assertThat(covered(export.report()))
+ .as("both named as covered by the band, since both are behind the text")
+ .hasSize(2);
+ }
+
+ @Test
+ void aShapeDeclaredBeforeAPaintedLayerOfTheSameZIndexStaysBehindIt() throws Exception {
+ // Layers sharing a z-index are painted in the order they were given, so the band declared
+ // after the pin is painted over it: the page covers the pin, and in front the pin would
+ // cover the band's shading instead.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 0)
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report())).singleElement().asString()
+ .contains("Pin").contains("Band").contains(COVERED);
+ }
+
+ @Test
+ void aShapeDeclaredAfterAPaintedLayerOfTheSameZIndexStandsInFrontOfIt() throws Exception {
+ // The same two layers the other way round, both still at z-index 0: the band is painted
+ // first, so the pin stands over it and goes in front. This and the test above are the pair
+ // that pins the tie — the sort is stable, and which way it falls is the order given.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 0)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aPictureOverAPaintedNeighbourStaysBehindItAndIsNamed() throws Exception {
+ // A picture drawn where the page puts it is not asked the question — only the shapes a
+ // node paints itself are — so over a painted neighbour it is still covered. Not silently:
+ // the naming pass names it where it stands. Written down as a test rather than as a
+ // sentence in the docs, which is all it was.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeContainerBuilder().name("Frame").circle(40)
+ .fillColor(ACCENT)
+ .center(new com.demcha.compose.document.dsl.ImageBuilder()
+ .name("Photo").source(pngBytes()).size(36, 36).build())
+ .build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(detailsOf(export.report(), "/Photo"))
+ .as("the picture is drawn where the page puts it, which is the path in question")
+ .isNotEmpty();
+ assertThat(covered(export.report()))
+ .as("the picture over the band, named as covered by it")
+ .anySatisfy(named -> assertThat(named).contains("Photo").contains("Band[0]"));
+ }
+
@Test
void aShapeBesideAPaintedNeighbourItDoesNotReachStandsInFrontToo() throws Exception {
// Set past the band's right edge, where the band's shading covers nothing of it. The order
@@ -348,6 +438,17 @@ private static String pinAnchor(Export export) throws Exception {
private record Export(byte[] bytes, DocxExportReport report) {
}
+ private static byte[] pngBytes() {
+ try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
+ javax.imageio.ImageIO.write(
+ new java.awt.image.BufferedImage(40, 40, java.awt.image.BufferedImage.TYPE_INT_RGB),
+ "png", out);
+ return out.toByteArray();
+ } catch (Exception failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
private static Export export(Consumer content) throws Exception {
AtomicReference report = new AtomicReference<>();
byte[] bytes;
From 2877723edc9161e148d0c504ca6c9e0b7220fe64 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 1 Nov 2026 21:41:23 +0000
Subject: [PATCH 3/7] fix(docx): ask the whole-stack test only of fragments a
shape is drawn from
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The test of whether a drawing has to stay behind the text was asked of every
fragment of a layer, and a paragraph's own fragment is in the list that test
reads from — so it found its own centre in its own box and answered yes. Every
layer bearing text or a picture above a painted one therefore refused the lift
for its whole stack, whatever the geometry, and [painted background, text,
icons] is the ordinary layer stack: the rule fired almost nowhere.
Only a fragment a shape is drawn from is asked now, which is the only kind a
lift could put over anything. A badge whose glyph is drawn over it keeps its
exception, and the glyph is reached by the walk and asked nothing, a picture
being drawn from no fragment of its own.
That moves the numbers back. Two documents of the seventy-one change, by 38
anchors: the one the case was measured on, and 37 of cv-sidebar_portrait's over
its painted sidebar. Word and LibreOffice draw that preset's page exactly as
they drew it before — 0 of 3,868,706 pixels differ at 200 dpi in each — nothing
having been painted over those 37 to begin with. The export after the
whole-or-nothing rule writes the same 71 documents as the export before it, byte
for byte, so what the rule costs is visible only on a built case, and the text
that had it costing cv-sidebar_portrait its lift is gone: that was this defect,
not a price.
Three more answers the tree does not give are now refused rather than guessed. A
node whose layer is not among its parent's answers false, as the javadoc already
said it did, instead of leaving a lift an inner stack had granted. A node one
instance of which stands at more than one place is refused outright: it has one
box and one path among those places, so an answer taken from whichever the index
kept would be given for all of them.
Four fixtures for what the corpus cannot ask: a layer of words over the painted
one, which must not keep the shape behind; two painted layers under one shape; a
shape in a stack nested over a painted layer; and a node standing twice. The
picture fixture now reads the frame's own anchor as well, so a badge quietly
gaining a covered-note cannot pass it.
---
CHANGELOG.md | 19 +--
.../fidelity/DocxNeighbourFillGuardTest.java | 14 +--
.../semantic/docx/DocxSemanticBackend.java | 40 +++++--
.../DocxDrawingUnderANeighboursFillTest.java | 111 ++++++++++++++++--
4 files changed, 151 insertions(+), 33 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c7cdaba7..cf27b5c75 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,15 +62,16 @@ follow semantic versioning; release dates are ISO 8601.
plainly sees as well: the same fixture with its panel unpainted keeps its anchor's
`behindDoc="1"` while showing all 3237 pixels of its shape in Word. It fails closed on two
fixtures of its own, one that must come back named and one that must come back in front.
- - Taking the order whole has a price, and it is worth knowing where it falls: a stack in which one
- drawing has to stay behind keeps all of them there, so the order is not carried for it at all.
- Of the 71-document fidelity corpus, one document changes — the one that holds the case — and the
- other seventy are byte for byte what the export wrote before. `cv-sidebar_portrait` is among
- them: thirty-seven of its drawings stand over its painted sidebar, one of them frames the photo
- it rings, and so none is lifted. Word and LibreOffice draw that page identically either way, 0
- of 3,868,706 pixels differing at 200 dpi in each, but that is a measurement of that preset and
- not a promise about the rule. Over the changed corpus `drawings-follow-text` re-measures in Word
- 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as before.
+ - Taking the order whole has a price — a stack in which one drawing has to stay behind keeps all
+ of them there, so the order is not carried for it at all — and it is worth saying where that
+ price falls: nowhere in the fidelity corpus. Two of its 71 documents change, by 38 anchors: the
+ one that holds the case, and `cv-sidebar_portrait`, 37 of whose drawings stand over its painted
+ sidebar. Word and LibreOffice draw that preset's page exactly as they drew it before, 0 of
+ 3,868,706 pixels differing at 200 dpi in each, nothing having been painted over those 37 to
+ begin with. No document of the corpus holds a stack the rule refuses, so what it costs is
+ measured only on a built case. Over the changed corpus `drawings-follow-text` re-measures in
+ Word 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as
+ before.
- In LibreOffice 24.2 a shape anchored in a table cell stands the cell's left padding to the right
of where Word and the page put it, so the icon that was drawn nowhere at all is now drawn whole
and that far across: measured 14.16pt on the lifted icon, and 14.40pt right with 13.92pt low on
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 23cdc9c33..9cc9be9f7 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -203,14 +203,14 @@ class DocxNeighbourFillGuardTest {
* The drawings of the corpus the export lifts in front of the text over a painted layer under
* them.
*
- * One: the icon over the card, the case this was measured on. Asserted
- * exactly rather than as a floor, so that a corpus which starts lifting more — or stops
- * lifting this one — is seen here. The seventy other documents lift nothing: thirty-seven
- * drawings of {@code cv-sidebar_portrait} stand over its painted sidebar, but one of them
- * frames the photo it rings, and the stack's order is taken whole or not at all, so none of
- * them is lifted and that document is byte for byte what the export wrote before.
+ * Thirty-eight, in two documents: the icon over the card, which is the
+ * case this was measured on, and thirty-seven of {@code cv-sidebar_portrait}'s over its
+ * painted sidebar. Word and LibreOffice draw that preset's page exactly as they drew it before
+ * — 0 of 3,868,706 pixels differing at 200 dpi in each — nothing having been painted over
+ * those thirty-seven to begin with. Asserted exactly rather than as a floor, so that a corpus
+ * which starts lifting more, or stops lifting these, is seen here.
*/
- private static final int LIFTED_IN_THE_CORPUS = 1;
+ private static final int LIFTED_IN_THE_CORPUS = 38;
@Test
void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 8b6cc268b..4ea0ac10f 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4496,13 +4496,20 @@ private static List layer
* not, and over a painted neighbour they stay behind the text and are named there
* ({@code nameTheDrawingsAPaintedPanelCovers}). The corpus holds no instance of either.
*
- * It answers false rather than guessing wherever the tree does not say: the climb is over
- * the layout's parents, so it ends at a node the layout did not place — one composed in an
- * authored table cell — and a layer it cannot find among its parent's, which is the answer
- * that leaves the export as it was. A canvas lays its children over one another too but holds
+ *
It answers false rather than guessing wherever the tree does not say: a node the layout
+ * did not place — one composed in an authored table cell — ends the climb, a layer it cannot
+ * find among its parent's ends it with a false whatever an inner stack had granted, and a node
+ * one instance of which stands at more than one place is refused outright, its box and its
+ * path being one among those places. A canvas lays its children over one another too but holds
* no layers, so it is climbed past rather than read.
*/
private boolean laidOverAPaintedNeighbour(DocumentNode node) {
+ if (layout.placedMoreThanOnce(node)) {
+ // One instance standing at more than one place has one path and one box among them,
+ // so what lies under it at one place is not what lies under it at another, and the
+ // answer would be given for all of them from whichever the index kept.
+ return false;
+ }
boolean over = false;
DocumentNode child = node;
for (DocumentNode parent = layout.parentOf(child); parent != null;
@@ -4521,7 +4528,11 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
}
}
if (itsPlace < 0) {
- continue;
+ // The layout's parent is an overlay whose layers do not hold this child: the tree
+ // does not say where in the stack it is, so neither does this — and a lift an
+ // inner stack had granted goes with it, that outer stack's own painted layers
+ // never having been read.
+ return false;
}
int paintedBelow = -1;
for (int i = 0; i < order.size(); i++) {
@@ -4552,14 +4563,23 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
* Whether a node, or anything it holds, paints a shape that has to stay behind the text: one
* whose box holds a line of the flow or a picture, which in front it would cover.
*
- * The same test the shape itself is given when it is drawn
- * ({@code drawAndNameOwnFragments}), asked here of a neighbour instead, so that the two
- * answers cannot differ. A badge whose glyph is drawn over it stands in front wherever it is
- * and is not one of these.
+ * Only a fragment a shape is drawn from is asked — the ones {@code queueDrawing} writes,
+ * and the only ones a lift could put over anything. A paragraph's own fragment, and a
+ * picture's, is in the list the test reads from, so asking the test of it would find its own
+ * centre in its own box: every layer bearing text or a picture would refuse the lift for its
+ * whole stack, whatever the geometry, which is a different rule from this one and one no
+ * measurement here supports.
+ *
+ * A badge whose glyph is drawn over it stands in front wherever it is, so its own outline
+ * is not asked either: the picture it frames is its own, not the flow's. The glyph itself is
+ * reached by the walk and asked nothing, a picture being drawn from no fragment of its own.
*/
private boolean staysBehindTheText(DocumentNode node) {
- if (!holdsItsDrawnGlyph(node)) {
+ if (!Double.isNaN(canvasHeight) && !holdsItsDrawnGlyph(node)) {
for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
+ if (DocxDrawings.of(fragment, canvasHeight).isEmpty()) {
+ continue;
+ }
if (framesText(fragment, layout.textOnPage(fragment.pageIndex()))) {
return true;
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index d97f03d9d..9503df4be 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -202,6 +202,95 @@ void aStackWhoseUpperShapeMustStayBehindKeepsEveryShapeOfItThere() throws Except
.hasSize(2);
}
+ @Test
+ void aLayerBearingTextOverThePaintedOneDoesNotKeepTheShapeBehind() throws Exception {
+ // What keeps a stack's shapes behind is a SHAPE of it that has to stay there, not any
+ // layer that happens to carry text. A layer of words over the band, nowhere near the pin,
+ // used to refuse the lift for the whole stack: the test of whether a box holds a line of
+ // the flow was asked of the words' own fragment, which holds its own line, so every
+ // text-bearing layer answered yes whatever the geometry. `[painted background, text,
+ // icons]` is the ordinary layer stack, so that answer was nearly every stack there is.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new SectionBuilder().name("Words").fixedWidth(90)
+ .addParagraph(p -> p.name("Note").text("A line of words")).build(),
+ 2, 40, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the words carry no shape, so they keep nothing behind")
+ .contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aShapeTheSameInstanceOfWhichStandsTwiceIsNotLifted() throws Exception {
+ // One node used as two layers stands at two places, and the layout keeps one path and one
+ // box for the instance. What lies under it at one place is not what lies under it at the
+ // other, so an answer taken from whichever place the index kept would be given for both:
+ // it is refused instead.
+ com.demcha.compose.document.node.DocumentNode pin =
+ new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build();
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(pin, 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(pin, 150, 30, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(anchorsHolding(export, PIN_INK))
+ .as("both places behind the text, neither answered from the other's")
+ .isNotEmpty()
+ .allSatisfy(anchor -> assertThat(anchor).contains("behindDoc=\"1\""));
+ }
+
+ @Test
+ void twoPaintedLayersUnderAShapeStillLiftIt() throws Exception {
+ // The topmost painted layer is the one the shapes above it are judged from; a second
+ // painted layer below it changes nothing, the page covering it with the first.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Under").fillColor(OVER).fixedWidth(200)
+ .addParagraph(p -> p.name("Deep").text("Under it all")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aShapeInAStackNestedOverAPaintedLayerStandsInFrontOfIt() throws Exception {
+ // The climb reads every stack the shape stands in, not only the nearest: the pin is a
+ // layer of an inner stack which is itself a layer over the painted band.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new LayerStackBuilder().name("Inner")
+ .position(new ShapeBuilder().name("Pin").size(30, 14)
+ .fillColor(ACCENT).build(), 0, 0, LayerAlign.TOP_LEFT, 0)
+ .build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
@Test
void aShapeDeclaredBeforeAPaintedLayerOfTheSameZIndexStaysBehindIt() throws Exception {
// Layers sharing a z-index are painted in the order they were given, so the band declared
@@ -262,8 +351,10 @@ void aPictureOverAPaintedNeighbourStaysBehindItAndIsNamed() throws Exception {
.as("the picture is drawn where the page puts it, which is the path in question")
.isNotEmpty();
assertThat(covered(export.report()))
- .as("the picture over the band, named as covered by it")
- .anySatisfy(named -> assertThat(named).contains("Photo").contains("Band[0]"));
+ .as("the picture over the band is named as covered by it, and it alone: the frame "
+ + "round it is a badge whose glyph is drawn over it, so the frame stands in "
+ + "front and nothing covers it")
+ .singleElement().asString().contains("Photo").contains("Band[0]");
}
@Test
@@ -420,17 +511,23 @@ private static List detailsOf(DocxExportReport report, String path) {
/** The one anchor holding the pin's ink. */
private static String pinAnchor(Export export) throws Exception {
+ List pins = anchorsHolding(export, PIN_INK);
+ assertThat(pins).as("one anchor drawn in the pin's ink").hasSize(1);
+ return pins.get(0);
+ }
+
+ /** Every anchor drawn in one ink. */
+ private static List anchorsHolding(Export export, String ink) throws Exception {
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(export.bytes()))) {
- List pins = new ArrayList<>();
+ List found = new ArrayList<>();
Matcher matcher = Pattern.compile("", Pattern.DOTALL)
.matcher(document.getDocument().xmlText());
while (matcher.find()) {
- if (matcher.group().contains(PIN_INK)) {
- pins.add(matcher.group());
+ if (matcher.group().contains(ink)) {
+ found.add(matcher.group());
}
}
- assertThat(pins).as("one anchor drawn in the pin's ink").hasSize(1);
- return pins.get(0);
+ return found;
}
}
From 63301a5bde77bd00129eed9bded31411bc7f97fb Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 1 Nov 2026 22:37:23 +0000
Subject: [PATCH 4/7] fix(docx): ask the whole-stack test of the nodes the
export draws as shapes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A badge whose glyph is drawn over it came back in front of the photo it frames,
its own fill painted over that picture: the badge's outline is a shape and was
lifted, while the picture was not — a picture is drawn where the page puts it and
asks the surface it stands on, not its neighbours. That is the inversion the
whole-stack rule exists to stop, done by the rule itself, and the fixture written
for the case asserted it as correct.
The test is now asked of the nodes the export draws as shapes — a drawing, or a
shape container that is not a badge holding its initials — and of the fragments
it draws them from. Two things were refusing the lift for stacks they have no
business in: the decoration fragment the layout gives a painted or bordered
wrapper, which is written as a cell and never as a drawing, and the box of a
badge holding its initials, which is handed over in front wherever it stands and
so cannot be put under anything. Both were measured; the badge left the stack's
pin behind a shape already in front of it, the inversion again.
The badge's outline frames its glyph, so this is what keeps that stack behind,
and the fixture now reads both anchors rather than the report alone — which the
last commit's message claimed it did and it did not.
A rule of its own for pictures was written for this and then taken out: sabotaged
it reddened no test, the badge's outline being what answers for the only shape a
picture takes above a painted layer. A picture beside its label in a layer of a
stack is written in the flow instead and covered by nothing, so no case drives
the rule, and the javadoc says that rather than leaving conservatism nothing
holds. Three more fixtures for what does hold: a badge with its initials, a
bordered neighbour bearing text, and a shape container that paints nothing.
The corpus is what it was: two of the seventy-one documents change, by 38
anchors, and the export writes the same bytes as before this commit.
---
.../fidelity/DocxNeighbourFillGuardTest.java | 18 ++++
.../semantic/docx/DocxSemanticBackend.java | 54 +++++++----
.../DocxDrawingUnderANeighboursFillTest.java | 96 +++++++++++++++++--
3 files changed, 142 insertions(+), 26 deletions(-)
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 9cc9be9f7..1274e23aa 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -157,6 +157,14 @@ class DocxNeighbourFillGuardTest {
private static final String DRAWN_IN_ITS_BLOCK = "drawn as a shape where the layout puts it, ";
private static final String DRAWN_IN_ITS_CELL = "drawn as a shape anchored in the table cell it fills";
+ /**
+ * What a picture drawn where the page puts it says instead. Such a picture is never lifted in
+ * front — the surface it stands on decides, not its neighbours — so it belongs among the
+ * drawings a lifted one is judged against, which it would not reach by the wordings above.
+ */
+ private static final List DRAWN_AS_A_PICTURE = List.of(
+ "drawn over the badge holding it", "drawn beside the text it labels");
+
/** What a drawing's note says when the export put it in front of the text. */
private static final String IN_FRONT = "in front of the text";
@@ -455,6 +463,16 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
List unclassified = new ArrayList<>();
for (DocxExportReport.Note note : report.notes()) {
String detail = note.detail() == null ? "" : note.detail();
+ if (DRAWN_AS_A_PICTURE.stream().anyMatch(detail::contains)) {
+ // Drawn where the page puts it and never lifted, so a lifted shape must not come
+ // to rest over it. It is no shape of a node's own, so it is not counted among the
+ // drawings read, and the panels are not asked about it: only the other way round.
+ if (note.path() != null && placed.containsKey(note.path())
+ && !detail.contains(IN_FRONT)) {
+ behind.add(note.path());
+ }
+ continue;
+ }
if (!detail.contains("drawn as a shape")) {
continue;
}
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 4ea0ac10f..137d533c4 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4496,12 +4496,21 @@ private static List layer
* not, and over a painted neighbour they stay behind the text and are named there
* ({@code nameTheDrawingsAPaintedPanelCovers}). The corpus holds no instance of either.
*
- * It answers false rather than guessing wherever the tree does not say: a node the layout
- * did not place — one composed in an authored table cell — ends the climb, a layer it cannot
- * find among its parent's ends it with a false whatever an inner stack had granted, and a node
- * one instance of which stands at more than one place is refused outright, its box and its
- * path being one among those places. A canvas lays its children over one another too but holds
- * no layers, so it is climbed past rather than read.
+ * So does a picture over that painted layer: a picture is drawn where the page puts it and
+ * is never lifted, so a shape lifted over one would cover it where the page draws it over the
+ * shape. Measured before it was counted: a badge's outline came back in front of the photo it
+ * frames, its own fill over the picture.
+ *
+ * Where the tree does not say, it does not guess. A layer it cannot find among its
+ * parent's ends the climb with a false, whatever an inner stack had granted, and a node one
+ * instance of which stands at more than one place is refused outright, its box and its path
+ * being one among those places. The climb itself ends at a node the layout gives no parent —
+ * the top of the tree, and equally a node composed in an authored table cell — with whatever
+ * the stacks below it answered; that is a defence against guessing at the places it did read,
+ * not a claim about the ones it did not. A canvas lays its children over one another too but
+ * holds no layers, so it is climbed past rather than read. The unfound-layer case is
+ * defence in depth: no shape of this repository's own templates reaches it, and no fixture
+ * drives it.
*/
private boolean laidOverAPaintedNeighbour(DocumentNode node) {
if (layout.placedMoreThanOnce(node)) {
@@ -4560,22 +4569,31 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
}
/**
- * Whether a node, or anything it holds, paints a shape that has to stay behind the text: one
- * whose box holds a line of the flow or a picture, which in front it would cover.
+ * Whether a node, or anything it holds, is drawn behind the text and stays there — so that
+ * nothing of its stack may be lifted over it.
*
- * Only a fragment a shape is drawn from is asked — the ones {@code queueDrawing} writes,
- * and the only ones a lift could put over anything. A paragraph's own fragment, and a
- * picture's, is in the list the test reads from, so asking the test of it would find its own
- * centre in its own box: every layer bearing text or a picture would refuse the lift for its
- * whole stack, whatever the geometry, which is a different rule from this one and one no
- * measurement here supports.
+ * A shape answers yes where its box holds a line of the flow or a picture, which in front
+ * it would cover. Only a node the export draws as shapes is asked, and only the fragments it
+ * draws them from: a paragraph's own fragment, and the decoration fragment a painted or
+ * bordered wrapper is given, are in the list the test reads from, so asking the test of those
+ * would find their own centre in their own box and refuse the lift for every stack bearing
+ * text or a border. A badge holding its initials is not asked either: its shape is handed over
+ * in front wherever it stands, so nothing is lifted past it.
*
- * A badge whose glyph is drawn over it stands in front wherever it is, so its own outline
- * is not asked either: the picture it frames is its own, not the flow's. The glyph itself is
- * reached by the walk and asked nothing, a picture being drawn from no fragment of its own.
+ * A picture is never lifted — {@code drawWhereThePagePutsIt} asks the surface it stands on,
+ * not its neighbours — so a shape lifted over one would cover a picture the page draws over
+ * the shape. Measured, that happened to a badge's glyph: the badge's outline came back in
+ * front of the photo it frames. It is this test that stops it, the outline framing that
+ * picture, and a picture needs no rule of its own here: of the shapes a picture can take above
+ * a painted layer, the badge's glyph is the one the export draws where the page puts it, and
+ * a picture beside its label in a layer of a stack is written in the flow instead and is
+ * covered by nothing. A rule for the picture itself was written, found to redden no test when
+ * sabotaged, and taken out rather than kept as conservatism nothing drives.
*/
private boolean staysBehindTheText(DocumentNode node) {
- if (!Double.isNaN(canvasHeight) && !holdsItsDrawnGlyph(node)) {
+ boolean drawn = isDrawing(node)
+ || node instanceof ShapeContainerNode badge && textBadgeParagraph(badge) == null;
+ if (drawn && !Double.isNaN(canvasHeight)) {
for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
if (DocxDrawings.of(fragment, canvasHeight).isEmpty()) {
continue;
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index 9503df4be..92eee3223 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -228,6 +228,75 @@ void aLayerBearingTextOverThePaintedOneDoesNotKeepTheShapeBehind() throws Except
assertThat(covered(export.report())).isEmpty();
}
+ @Test
+ void anUnpaintedShapeContainerOverThePaintedLayerKeepsNothingBehindIt() throws Exception {
+ // A shape container that paints nothing draws no shape, so it covers nothing and must not
+ // refuse the lift. Its box is asked of nothing: only the fragments a shape is actually
+ // drawn from are, which is what tells this container from the badge of the test above.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeContainerBuilder().name("Clear").rectangle(90, 20)
+ .center(new ParagraphBuilder().name("Inside").text("Clear").build())
+ .build(),
+ 2, 40, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aBadgeHoldingItsInitialsKeepsNothingBehindIt() throws Exception {
+ // A badge holding its initials is handed over in front wherever it stands, so it cannot be
+ // put under anything and must not keep the pin behind: measured, counting its own box
+ // among the things that stay behind left the badge in front and the pin behind it, which
+ // is the inversion the whole-stack rule exists to stop.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeContainerBuilder().name("Badge").circle(28).fillColor(OVER)
+ .center(new ParagraphBuilder().text("JR").build()).build(),
+ 40, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the pin is lifted with the badge, not left under it")
+ .contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
+ @Test
+ void aShapeOverABorderedNeighbourBearingTextIsStillLifted() throws Exception {
+ // A bordered wrapper is written as a cell, never as a drawing, though the layout gives it
+ // a decoration fragment of its own. Counting that fragment among the shapes that stay
+ // behind refused the lift for the whole stack — measured — so only the nodes the export
+ // draws as shapes are asked.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new SectionBuilder().name("Outlined").fixedWidth(90)
+ .stroke(DocumentStroke.of(ACCENT, 1))
+ .addParagraph(p -> p.name("Note").text("In a box")).build(),
+ 2, 40, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).isEmpty();
+ }
+
@Test
void aShapeTheSameInstanceOfWhichStandsTwiceIsNotLifted() throws Exception {
// One node used as two layers stands at two places, and the layout keeps one path and one
@@ -330,10 +399,15 @@ void aShapeDeclaredAfterAPaintedLayerOfTheSameZIndexStandsInFrontOfIt() throws E
@Test
void aPictureOverAPaintedNeighbourStaysBehindItAndIsNamed() throws Exception {
- // A picture drawn where the page puts it is not asked the question — only the shapes a
- // node paints itself are — so over a painted neighbour it is still covered. Not silently:
- // the naming pass names it where it stands. Written down as a test rather than as a
- // sentence in the docs, which is all it was.
+ // A picture drawn where the page puts it is never lifted — the surface it stands on
+ // decides, not its neighbours — so over a painted neighbour it is still covered. Not
+ // silently: the naming pass names it where it stands.
+ //
+ // And the badge round it stays behind WITH it. Lifting the badge alone would paint its
+ // own fill over the photo it frames, which the page draws over the badge: measured, that
+ // is what happened before the picture was counted among the things that keep a stack
+ // behind — the badge came back behindDoc="0" and the photo behindDoc="1". Both anchors
+ // are read here for that reason, not the note alone.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
@@ -350,11 +424,17 @@ void aPictureOverAPaintedNeighbourStaysBehindItAndIsNamed() throws Exception {
assertThat(detailsOf(export.report(), "/Photo"))
.as("the picture is drawn where the page puts it, which is the path in question")
.isNotEmpty();
+ assertThat(anchorsHolding(export, PIN_INK))
+ .as("the badge's own outline, behind the text with the photo it frames")
+ .singleElement().asString().contains("behindDoc=\"1\"");
+ assertThat(anchorsHolding(export, " assertThat(named).contains("Photo").contains("Band[0]"))
+ .anySatisfy(named -> assertThat(named).contains("Frame").contains("Band[0]"));
}
@Test
From 6e1b7123fdc74913e2fd693566bbd1698707089f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 1 Nov 2026 23:32:15 +0000
Subject: [PATCH 5/7] fix(docx): lift a shape over a painted neighbour only
when it is the only drawing there
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Whether a shape could be lifted was decided by asking why each of its
neighbours stays behind the text, and that question was answered wrongly three
times, each time by letting the lower of two shapes be drawn over the higher.
A picture drawn where the page puts it, in a nested layer: never lifted, and
nothing asked about it. A badge handed over already in front: counted as a
thing that stays behind, so the shape beside it was left under a shape standing
over it. A node one instance of which stands at more than one place: refused
for itself, and not for its neighbours.
The question is now the other one. A shape is lifted where it is the only
drawing over that painted layer, and otherwise none of the stack is. Being
alone needs nothing remembered about the kinds of drawing there are, and the
inversion inside a stack is closed by construction rather than by a list that
has been short three times.
Two predicates go with the enumeration. What counts as drawn is one test — the
layout gives the node a fragment a shape is drawn from — which covers a node
kind the export does not know as well as a shape of its own, and counts a
painted wrapper's decoration too, which can only refuse a lift. A picture
counts by being a picture, since it is drawn from no fragment of its own.
The price is real and it falls on real documents: a stack that draws twice over
a painted layer carries no order at all, even where lifting both would have
been safe. cv-sidebar_portrait draws thirty-seven times over its painted
sidebar, so none of those is lifted and that preset is byte for byte what the
export wrote before. One document of the seventy-one changes now, by one
anchor: the synthetic one the case was measured on. The recipe, the capability
matrix and the CHANGELOG say that, rather than leaving a reader to think a
sidebar CV gets its z-order carried.
Over the changed corpus drawings-follow-text re-measures at 309 of 325 drawings
following their text in Word 16.0.20527, the same two counts as before.
Three fixtures for what the rule now does and two for what it costs: a stack
drawing a picture beside its label, a lone shape beside a node standing twice,
a stack drawing a badge, a stack drawing a bordered neighbour, and a shape
container that paints nothing and so keeps nothing behind. The picture one was
twice found passing for another reason — first because the picture never
reached the path at all, then because the pin's box framed its centre — and is
built so that neither holds: the icon's centre stands past the pin's edge while
their boxes still overlap.
---
CHANGELOG.md | 39 +++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 26 ++--
.../fidelity/DocxNeighbourFillGuardTest.java | 14 +--
.../semantic/docx/DocxSemanticBackend.java | 115 +++++++++++-------
.../DocxDrawingUnderANeighboursFillTest.java | 98 ++++++++++++---
6 files changed, 199 insertions(+), 95 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf27b5c75..1e7814f5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,15 +32,20 @@ follow semantic versioning; release dates are ISO 8601.
rather than written as a cell — where one is written as a cell, holding text, a shape above it
is still covered, and named. A node bearing borders alone carries no fill and is not counted at
all; a fill inside either of those is.
- - Four things leave such a drawing behind the text. A painted layer the page lays over the drawing
- in turn: there it belongs under that layer's shading, and in front it would cover the shading
- instead — the same loss the other way round. A drawing whose box holds a line of the flow or a
- picture, which in front it would cover, as inside a painted panel. Another drawing over the same
- painted layer that has to stay behind for either of those reasons: the stack's order is kept
- whole or not taken at all, since Word draws every shape in front over every shape behind, and
- lifting one of them alone would put the lower of two over the higher. And a drawing that is no
- shape of a node's own: a picture drawn where the page puts it, and a pass of the layout's own
- such as a timeline's rail, are not asked the question at all.
+ - Three things leave such a drawing behind the text. A painted layer the page lays over the
+ drawing in turn: there it belongs under that layer's shading, and in front it would cover the
+ shading instead — the same loss the other way round. A box holding a line of the flow or a
+ picture, which in front it would cover, as inside a painted panel. And **anything else of the
+ stack drawn over that painted layer**: a shape is lifted only where it is the only drawing over
+ it, because Word draws every shape in front over every shape behind and lifting one of two
+ would put the lower over the higher. That last one is deliberately blunt — it asks whether
+ another drawing is there, not whether that drawing would have stayed behind. Asking the second
+ question was wrong three times over, at a picture drawn where the page puts it, at a badge
+ handed over already in front, and at a shape whose node stands at more than one place, each
+ time by letting the lower of two shapes be drawn over the higher.
+ - So a drawing that is no shape of a node's own is never lifted either — a picture drawn where the
+ page puts it, a pass of the layout's own such as a timeline's rail — and over a painted
+ neighbour it stays covered, which the report names where it stands.
- The report carries an `APPROXIMATED` note for a drawing left behind wherever a panel it wrote a
fill for lies under it, naming the drawing and one such panel. The panels are those the export
writes a fill for, not the tree's, so a row written as a panel is named and a panel bearing
@@ -62,15 +67,13 @@ follow semantic versioning; release dates are ISO 8601.
plainly sees as well: the same fixture with its panel unpainted keeps its anchor's
`behindDoc="1"` while showing all 3237 pixels of its shape in Word. It fails closed on two
fixtures of its own, one that must come back named and one that must come back in front.
- - Taking the order whole has a price — a stack in which one drawing has to stay behind keeps all
- of them there, so the order is not carried for it at all — and it is worth saying where that
- price falls: nowhere in the fidelity corpus. Two of its 71 documents change, by 38 anchors: the
- one that holds the case, and `cv-sidebar_portrait`, 37 of whose drawings stand over its painted
- sidebar. Word and LibreOffice draw that preset's page exactly as they drew it before, 0 of
- 3,868,706 pixels differing at 200 dpi in each, nothing having been painted over those 37 to
- begin with. No document of the corpus holds a stack the rule refuses, so what it costs is
- measured only on a built case. Over the changed corpus `drawings-follow-text` re-measures in
- Word 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as
+ - Lifting only a lone drawing has a price, and it falls on real documents: a stack that draws
+ twice over a painted layer carries no order at all, even where lifting both would have been
+ safe. **One** of the fidelity corpus's 71 documents changes — the one that holds the case — by
+ **one** anchor; the other seventy are byte for byte what the export wrote before.
+ `cv-sidebar_portrait` is among them, and it is the price: it draws 37 times over its painted
+ sidebar, so none of those is lifted. Over the changed corpus `drawings-follow-text` re-measures
+ in Word 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as
before.
- In LibreOffice 24.2 a shape anchored in a table cell stands the cell's left padding to the right
of where Word and the page put it, so the icon that was drawn nowhere at all is now drawn whole
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index f18fc1d35..d2d334bcf 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -132,7 +132,7 @@ honour an option ignores it (documented contract).
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row; in a table cell, `w:keepLines` alone, as Word reads `w:keepNext` on the paragraph a cell opens with as keeping the row with what follows its table. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list, such a line or a rule the layout moves to a new page in the flow holds its space above there as a paragraph does, and a table, a row or a panel on that one page holds the gap the layout carries there with its own top edge. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed; where the page after a cut has other top and bottom margins, and the page above has room for it, the paragraph between the parts ends a Word section with them (per-page margins, below) |
-| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export shades in one direction (whether a node that paints becomes a shaded cell is decided as it is written, so the tree cannot be asked, and a neighbour counted that writes no shading only lifts a shape nothing would have covered) and narrower in another (a shape container's own fill is not counted, being usually drawn rather than written as a cell; where one is written as a cell, holding text, a shape above it is still covered, and named); a node bearing borders alone is not counted at all. Four things leave such a drawing behind the text: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; another drawing over the same painted layer that has to stay behind for either of those reasons, the stack's order being kept whole or not taken at all, since Word draws every shape in front over every shape behind; and a drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — which is not asked the question. The report names a drawing left behind wherever a panel it wrote a fill for lies under it, with one such panel. In LibreOffice 24.2 a shape anchored in a cell stands the cell's left padding right of where Word and the page put it — measured 14.16pt on the lifted icon and 14.40pt right with 13.92pt low on the same icon left behind, Word within 0.24pt of the page in both — so where that icon was drawn nowhere before, it is now drawn whole and that far across. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
+| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export shades in one direction (whether a node that paints becomes a shaded cell is decided as it is written, so the tree cannot be asked, and a neighbour counted that writes no shading only lifts a shape nothing would have covered) and narrower in another (a shape container's own fill is not counted, being usually drawn rather than written as a cell; where one is written as a cell, holding text, a shape above it is still covered, and named); a node bearing borders alone is not counted at all. Three things leave such a drawing behind the text: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; and anything else of the stack drawn over that painted layer, a shape being lifted only where it is the only drawing over it, since Word draws every shape in front over every shape behind and lifting one of two would put the lower over the higher (asked as "is another drawing there", not "would it have stayed behind": the second question was wrong at a picture drawn where the page puts it, at a badge handed over already in front, and at a shape whose node stands at more than one place, and its price is that a stack drawing twice over a painted layer carries no order at all — `cv-sidebar_portrait` and its thirty-seven over its sidebar). A drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — is never lifted, so over a painted neighbour it stays covered. The report names a drawing left behind wherever a panel it wrote a fill for lies under it, with one such panel. In LibreOffice 24.2 a shape anchored in a cell stands the cell's left padding right of where Word and the page put it — measured 14.16pt on the lifted icon and 14.40pt right with 13.92pt low on the same icon left behind, Word within 0.24pt of the page in both — so where that icon was drawn nowhere before, it is now drawn whole and that far across. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
## Output surface and lifecycle
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 70f1e8bb5..3b86da665 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1280,18 +1280,24 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
text, a shape above it is still covered, and named. A node bearing
borders alone carries no fill and is not counted at all; a fill inside
either of those is.
- Four things leave such a drawing behind the text. A painted layer the
+ Three things leave such a drawing behind the text. A painted layer the
page lays over the drawing in turn: there the drawing belongs under that
layer's shading, and in front it would cover the shading instead — the
- same loss the other way round. A drawing whose box holds a line of the
- flow or a picture, which in front it would cover, as inside a painted
- panel. Another drawing over the same painted layer that has to stay
- behind for either of those reasons: the stack's order is kept whole, or
- not taken at all, since Word draws every shape in front over every shape
- behind, and lifting one of them alone would put the lower of two over the
- higher. And a drawing that is not a shape a node paints itself: a picture
- drawn where the page puts it and a pass of the layout's own, a timeline's
- rail, are not asked the question at all.
+ same loss the other way round. A box holding a line of the flow or a
+ picture, which in front it would cover, as inside a painted panel. And
+ anything else of the stack drawn over that painted layer: a shape is
+ lifted only where it is the only drawing over it, since Word draws every
+ shape in front over every shape behind and lifting one of two would put
+ the lower over the higher. That last one asks whether another drawing is
+ there, not whether it would have stayed behind — asking the second
+ question was wrong at a picture drawn where the page puts it, at a badge
+ handed over already in front, and at a shape whose node stands at more
+ than one place. Its price is that a stack drawing twice over a painted
+ layer carries no order at all, which `cv-sidebar_portrait` and its
+ thirty-seven drawings over its sidebar pay. A drawing that is not a shape
+ a node paints itself — a picture drawn where the page puts it, a pass of
+ the layout's own such as a timeline's rail — is never lifted, so over a
+ painted neighbour it stays covered.
The report names a drawing left behind wherever a panel it wrote a fill
for lies under it, naming the drawing and one such panel, and in a
document of several sections the section the loss is
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 1274e23aa..20db137a9 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -211,14 +211,14 @@ class DocxNeighbourFillGuardTest {
* The drawings of the corpus the export lifts in front of the text over a painted layer under
* them.
*
- * Thirty-eight, in two documents: the icon over the card, which is the
- * case this was measured on, and thirty-seven of {@code cv-sidebar_portrait}'s over its
- * painted sidebar. Word and LibreOffice draw that preset's page exactly as they drew it before
- * — 0 of 3,868,706 pixels differing at 200 dpi in each — nothing having been painted over
- * those thirty-seven to begin with. Asserted exactly rather than as a floor, so that a corpus
- * which starts lifting more, or stops lifting these, is seen here.
+ * One, in one document: the icon over the card, which is the case this was
+ * measured on. A shape is lifted only where it is the only drawing over that painted layer,
+ * and the seventy other documents hold no such stack — {@code cv-sidebar_portrait} draws
+ * thirty-seven times over its painted sidebar, so none of those is lifted and that document is
+ * byte for byte what the export wrote before. Asserted exactly rather than as a floor, so that
+ * a corpus which starts lifting more, or stops lifting this one, is seen here.
*/
- private static final int LIFTED_IN_THE_CORPUS = 38;
+ private static final int LIFTED_IN_THE_CORPUS = 1;
@Test
void everyDrawingUnderANeighboursFillIsLedgered() throws Exception {
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 137d533c4..fc79ef591 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4480,14 +4480,20 @@ private static List layer
* below: there the page covers the node, and a shape in front would cover the panel instead,
* which is the same loss the other way round.
*
- * So does a drawing over that painted layer which has to stay behind the text, the node's
- * own shapes included ({@code staysBehindTheText}): the stack's order is kept whole, or not
- * taken at all. Word draws every shape in front over every shape behind, so lifting one
- * drawing of a stack while another stays there would put the lower of the two over the
- * higher — the loss this fixes, in the one place it would be made worse. Nothing of the
- * fidelity corpus asks this: no drawing lifted there has a panel or a drawing painted after
- * it at all ({@code DocxNeighbourFillGuardTest} counts the pairs, and the count is zero), so
- * the rule is held by the unit fixtures written for it.
+ * So does anything else of the stack drawn over that painted layer
+ * ({@code drawnOverThatLayer}): a shape is lifted where it is the only drawing over it, and
+ * otherwise none is. Word draws every shape in front over every shape behind, so lifting one
+ * drawing of a stack while another stays there puts the lower of the two over the higher —
+ * the loss this fixes, made worse.
+ *
+ * It asks whether anything else is drawn, not whether that other drawing would stay
+ * behind, and the reason is a measurement: asking the second question was wrong three times
+ * over, at a picture drawn where the page puts it, at a badge handed over already in front,
+ * and at a shape whose node stands at more than one place. Each was a kind of drawing that
+ * stays behind for a reason of its own, and each had to be remembered here. There is nothing
+ * to remember about being alone. The price is that a stack drawing twice over a painted layer
+ * keeps both behind, where lifting both would have been safe; {@code cv-sidebar_portrait}'s
+ * thirty-seven over its sidebar are that case.
*
* A node standing inside a painted surface is another case, decided by the surface it is
* in ({@code surfaceBehind}) rather than by its neighbours. Only the shapes a node paints
@@ -4556,10 +4562,13 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
if (paintedBelow < 0) {
continue;
}
- // Whole, or not at all: a drawing over that painted layer which has to stay behind
- // the text keeps every drawing over it there too, the node's own shapes included.
+ if (framesTextWithAShape(node)) {
+ return false;
+ }
+ // Alone, or not at all: nothing else of the stack may be drawn over that painted
+ // layer (drawnOverThatLayer).
for (int i = paintedBelow + 1; i < order.size(); i++) {
- if (staysBehindTheText(order.get(i).node())) {
+ if (drawnOverThatLayer(order.get(i).node(), node)) {
return false;
}
}
@@ -4569,42 +4578,60 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
}
/**
- * Whether a node, or anything it holds, is drawn behind the text and stays there — so that
- * nothing of its stack may be lifted over it.
- *
- *
A shape answers yes where its box holds a line of the flow or a picture, which in front
- * it would cover. Only a node the export draws as shapes is asked, and only the fragments it
- * draws them from: a paragraph's own fragment, and the decoration fragment a painted or
- * bordered wrapper is given, are in the list the test reads from, so asking the test of those
- * would find their own centre in their own box and refuse the lift for every stack bearing
- * text or a border. A badge holding its initials is not asked either: its shape is handed over
- * in front wherever it stands, so nothing is lifted past it.
- *
- * A picture is never lifted — {@code drawWhereThePagePutsIt} asks the surface it stands on,
- * not its neighbours — so a shape lifted over one would cover a picture the page draws over
- * the shape. Measured, that happened to a badge's glyph: the badge's outline came back in
- * front of the photo it frames. It is this test that stops it, the outline framing that
- * picture, and a picture needs no rule of its own here: of the shapes a picture can take above
- * a painted layer, the badge's glyph is the one the export draws where the page puts it, and
- * a picture beside its label in a layer of a stack is written in the flow instead and is
- * covered by nothing. A rule for the picture itself was written, found to redden no test when
- * sabotaged, and taken out rather than kept as conservatism nothing drives.
- */
- private boolean staysBehindTheText(DocumentNode node) {
- boolean drawn = isDrawing(node)
- || node instanceof ShapeContainerNode badge && textBadgeParagraph(badge) == null;
- if (drawn && !Double.isNaN(canvasHeight)) {
- for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
- if (DocxDrawings.of(fragment, canvasHeight).isEmpty()) {
- continue;
- }
- if (framesText(fragment, layout.textOnPage(fragment.pageIndex()))) {
- return true;
- }
+ * Whether one of a node's own shapes holds a line of the flow or a picture of its page, which
+ * in front it would cover.
+ *
+ * Asked of the node being lifted and of nothing else. Only the fragments a shape is drawn
+ * from are read: a paragraph's own fragment, and the decoration fragment the layout gives a
+ * painted or bordered wrapper, are in the list the test reads from, so asking the test of
+ * those would find their own centre in their own box.
+ */
+ private boolean framesTextWithAShape(DocumentNode node) {
+ if (Double.isNaN(canvasHeight)) {
+ return false;
+ }
+ for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
+ if (!DocxDrawings.of(fragment, canvasHeight).isEmpty()
+ && framesText(fragment, layout.textOnPage(fragment.pageIndex()))) {
+ return true;
}
}
+ return false;
+ }
+
+ /**
+ * Whether a layer, or anything it holds, draws something that is not {@code lifted} or part
+ * of it.
+ *
+ * Read as widely as the export draws. A fragment a shape is drawn from counts, whosever it
+ * is: that is what the export draws shapes from, so it covers a node kind the export does not
+ * know as well as a shape of its own, and it counts a painted wrapper's decoration too, which
+ * only refuses a lift. And a picture counts, drawn where the page puts it and never lifted —
+ * measured: a shape lifted over one came back painted over the picture the page draws over
+ * it.
+ */
+ private boolean drawnOverThatLayer(DocumentNode node, DocumentNode lifted) {
+ if (node == lifted) {
+ return false;
+ }
+ if (node instanceof ImageNode || drawsAShape(node)) {
+ return true;
+ }
for (DocumentNode child : node.children()) {
- if (staysBehindTheText(child)) {
+ if (drawnOverThatLayer(child, lifted)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Whether the layout gives a node a fragment a shape is drawn from. */
+ private boolean drawsAShape(DocumentNode node) {
+ if (Double.isNaN(canvasHeight)) {
+ return false;
+ }
+ for (com.demcha.compose.document.layout.PlacedFragment fragment : layout.ownFragments(node)) {
+ if (!DocxDrawings.of(fragment, canvasHeight).isEmpty()) {
return true;
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index 92eee3223..b15a0aa6f 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -251,11 +251,13 @@ void anUnpaintedShapeContainerOverThePaintedLayerKeepsNothingBehindIt() throws E
}
@Test
- void aBadgeHoldingItsInitialsKeepsNothingBehindIt() throws Exception {
- // A badge holding its initials is handed over in front wherever it stands, so it cannot be
- // put under anything and must not keep the pin behind: measured, counting its own box
- // among the things that stay behind left the badge in front and the pin behind it, which
- // is the inversion the whole-stack rule exists to stop.
+ void aStackDrawingABadgeBesideTheShapeLiftsNeither() throws Exception {
+ // Alone, or not at all: the badge is a second drawing of the stack, so the pin is not
+ // lifted. This is the rule's price, and a badge is the shape it is cheapest to see on —
+ // a badge holding its initials is handed over in front wherever it stands, so lifting the
+ // pin would have been safe here. The rule does not ask that, on purpose: asking why the
+ // other drawing stays behind was wrong at a picture, at this badge, and at a node standing
+ // twice, each time by letting the lower of two shapes be drawn over the higher.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
@@ -269,17 +271,19 @@ void aBadgeHoldingItsInitialsKeepsNothingBehindIt() throws Exception {
.build())));
assertThat(pinAnchor(export))
- .as("the pin is lifted with the badge, not left under it")
- .contains("behindDoc=\"0\"");
- assertThat(covered(export.report())).isEmpty();
+ .as("the pin stays behind, the badge beside it being a drawing of the stack too")
+ .contains("behindDoc=\"1\"");
+ assertThat(covered(export.report()))
+ .as("and the band is named as covering it")
+ .anySatisfy(named -> assertThat(named).contains("Pin").contains("Band[0]"));
}
@Test
- void aShapeOverABorderedNeighbourBearingTextIsStillLifted() throws Exception {
- // A bordered wrapper is written as a cell, never as a drawing, though the layout gives it
- // a decoration fragment of its own. Counting that fragment among the shapes that stay
- // behind refused the lift for the whole stack — measured — so only the nodes the export
- // draws as shapes are asked.
+ void aStackDrawingABorderedNeighbourLiftsNothing() throws Exception {
+ // A bordered wrapper is written as a cell rather than as a drawing, but the layout gives
+ // it a fragment a shape is drawn from, and that is what the rule reads: anything drawn
+ // over the painted layer keeps the stack behind. Counting it costs a lift and can only
+ // cost a lift, which is the side to be wrong on.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
@@ -293,8 +297,47 @@ void aShapeOverABorderedNeighbourBearingTextIsStillLifted() throws Exception {
150, 2, LayerAlign.TOP_LEFT, 2)
.build())));
- assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
- assertThat(covered(export.report())).isEmpty();
+ assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(covered(export.report()))
+ .anySatisfy(named -> assertThat(named).contains("Pin").contains("Band[0]"));
+ }
+
+ @Test
+ void aStackDrawingAPictureBesideItsLabelLiftsNothing() throws Exception {
+ // A picture drawn beside the text it labels, in a nested layer of the stack, nowhere near
+ // the pin. The picture is never lifted — the surface it stands on decides, not its
+ // neighbours — so lifting the pin would paint the pin over a picture the page draws over
+ // it. Measured on exactly this shape: the pin came back behindDoc="0" and the picture
+ // behindDoc="1", and they overlap.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 140, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new LayerStackBuilder().name("Fact")
+ .layer(new com.demcha.compose.document.dsl.ImageBuilder()
+ .name("Icon").source(pngBytes()).size(10, 10).build(),
+ LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Label").text("Label").build(),
+ 14, 0, LayerAlign.CENTER_LEFT, 1)
+ .build(),
+ 168, 0, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ // The icon's centre stands past the pin's right edge, so the pin does not frame it and
+ // it is not that test keeping the pin behind — the boxes still overlap by two points,
+ // which is where the file would have drawn the pin over the picture.
+ assertThat(detailsOf(export.report(), "/Icon"))
+ .as("the picture is drawn where the page puts it, which is why it cannot be lifted")
+ .anySatisfy(detail -> assertThat(detail).contains("drawn beside the text it labels"));
+ assertThat(pinAnchor(export))
+ .as("the pin stays behind, the picture above it never being lifted")
+ .contains("behindDoc=\"1\"");
+ assertThat(covered(export.report()))
+ .as("and the band is named as covering the pin, so the pin was judged at all")
+ .anySatisfy(named -> assertThat(named).contains("Pin").contains("Band[0]"));
}
@Test
@@ -320,6 +363,31 @@ void aShapeTheSameInstanceOfWhichStandsTwiceIsNotLifted() throws Exception {
.allSatisfy(anchor -> assertThat(anchor).contains("behindDoc=\"1\""));
}
+ @Test
+ void aShapeBesideOneStandingTwiceIsNotLiftedOverIt() throws Exception {
+ // The refusal above is asked of the node being lifted, so on its own it would let a
+ // neighbour be lifted over the places it refuses — measured: the lone shape came back
+ // behindDoc="0" and both of the repeated instance's places behindDoc="1", the lower of
+ // them drawn over the higher. Being alone is what answers here: the repeated instance is
+ // a second drawing of the stack.
+ com.demcha.compose.document.node.DocumentNode shared =
+ new ShapeBuilder().name("Shared").size(40, 10).fillColor(OVER).build();
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(shared, 140, 8, LayerAlign.TOP_LEFT, 2)
+ .position(shared, 140, 24, LayerAlign.TOP_LEFT, 3)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the lone shape stays behind with the places it would have been drawn over")
+ .contains("behindDoc=\"1\"");
+ }
+
@Test
void twoPaintedLayersUnderAShapeStillLiftIt() throws Exception {
// The topmost painted layer is the one the shapes above it are judged from; a second
From 8e40350636fb5863eed19ebe9125a04ccd94c192 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 2 Nov 2026 00:37:00 +0000
Subject: [PATCH 6/7] fix(docx): refuse the lift wherever anything is drawn
after the shape
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rule was applied only at the overlay that holds the painted layer. Once an
inner stack had granted a lift, every outer overlay without a fill of its own
was skipped, so a drawing that is a layer of the OUTER stack — painted after the
inner stack whole — was never asked about. Measured on a document built from
the public DSL: the shape came back behindDoc="0" with a lower relativeHeight
than the hood over it, so Word drew the shape over a hood the page draws over
the shape.
The refusal now runs at every overlay of the climb, painted or not, and asks one
thing: is anything drawn after this shape. That also fixes the range the other
way. It was refusing because of siblings painted between the painted layer and
the shape — which stand UNDER the shape, where the file keeps them under it just
as the page does — so the rule is both safer and less conservative than before.
Two of its own checks went when breaking them reddened no test. Skipping the
shape itself while walking its neighbours: the range starts above the shape, so
the shape is never in it, and the parameter went with the check. And a clause
for a painted layer above the shape: a layer that paints is drawn, so the one
question catches it.
Two checks that stay cannot be driven by a fixture, and the javadoc says so
rather than leaving them looking held. A node one instance of which stands at
more than one place is refused because its box and its path are one among those
places — but which place the index keeps is not something a test chooses, and a
stack built to need the refusal is as likely to be answered from the place that
refuses anyway. A layer not found among its parent's is refused, and no shape of
this repository's templates reaches that. Both can only refuse a lift.
Two sentences of javadoc were false and are now limits rather than claims. The
walk is not "as wide as the export draws": a pass of the layout's own, a
timeline's rail, is drawn from a fragment no node owns, so neither walk sees it
— no case where that bites could be built, every rail tried bringing a marker or
a line of text that refused the lift anyway. And the walk is over children(),
which a table, a chart, a list and a paragraph do not implement, so a fill or a
drawing composed inside one of them is reached through the node's own fragments
instead.
Fixtures for what the corpus cannot ask: a shape of a nested stack under an
outer stack's drawing, one standing in two stacks at once, and a layer of words
over the shape that draws nothing and so keeps nothing behind. Two fixtures
turned the other way with the range: a badge and a bordered neighbour stand
UNDER the shape, so lifting it is right, and they are renamed for what they
now hold.
The corpus is unchanged by all of this: one document of the seventy-one, by one
anchor, the synthetic one the case was measured on.
---
CHANGELOG.md | 35 +++---
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 33 +++---
.../fidelity/DocxNeighbourFillGuardTest.java | 12 +-
.../semantic/docx/DocxSemanticBackend.java | 105 ++++++++++-------
.../DocxDrawingUnderANeighboursFillTest.java | 111 +++++++++++++-----
6 files changed, 186 insertions(+), 112 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e7814f5c..00f29463a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,17 +32,16 @@ follow semantic versioning; release dates are ISO 8601.
rather than written as a cell — where one is written as a cell, holding text, a shape above it
is still covered, and named. A node bearing borders alone carries no fill and is not counted at
all; a fill inside either of those is.
- - Three things leave such a drawing behind the text. A painted layer the page lays over the
- drawing in turn: there it belongs under that layer's shading, and in front it would cover the
- shading instead — the same loss the other way round. A box holding a line of the flow or a
- picture, which in front it would cover, as inside a painted panel. And **anything else of the
- stack drawn over that painted layer**: a shape is lifted only where it is the only drawing over
- it, because Word draws every shape in front over every shape behind and lifting one of two
- would put the lower over the higher. That last one is deliberately blunt — it asks whether
- another drawing is there, not whether that drawing would have stayed behind. Asking the second
- question was wrong three times over, at a picture drawn where the page puts it, at a badge
- handed over already in front, and at a shape whose node stands at more than one place, each
- time by letting the lower of two shapes be drawn over the higher.
+ - Two things leave such a drawing behind the text. A box holding a line of the flow or a picture,
+ which in front it would cover, as inside a painted panel. And **anything drawn after it**, in
+ any overlay it stands in: Word draws every shape in front over every shape behind, so lifting a
+ shape the page covers with another drawing puts the lower of the two over the higher. A drawing
+ the page paints *before* the shape is not asked about — the file keeps it below, as the page
+ does. That rule is deliberately blunt: it asks whether something is drawn over the shape, not
+ whether that drawing would have stayed behind. Asking the second question was wrong three times
+ over, at a picture drawn where the page puts it, at a badge handed over already in front, and at
+ a shape whose node stands at more than one place, each time by letting the lower of two shapes
+ be drawn over the higher.
- So a drawing that is no shape of a node's own is never lifted either — a picture drawn where the
page puts it, a pass of the layout's own such as a timeline's rail — and over a painted
neighbour it stays covered, which the report names where it stands.
@@ -67,14 +66,14 @@ follow semantic versioning; release dates are ISO 8601.
plainly sees as well: the same fixture with its panel unpainted keeps its anchor's
`behindDoc="1"` while showing all 3237 pixels of its shape in Word. It fails closed on two
fixtures of its own, one that must come back named and one that must come back in front.
- - Lifting only a lone drawing has a price, and it falls on real documents: a stack that draws
- twice over a painted layer carries no order at all, even where lifting both would have been
- safe. **One** of the fidelity corpus's 71 documents changes — the one that holds the case — by
- **one** anchor; the other seventy are byte for byte what the export wrote before.
+ - That has a price, and it falls on real documents: a shape with anything drawn over it carries no
+ order at all, even where lifting it would have been safe — over a badge the file already holds
+ in front, for one. **One** of the fidelity corpus's 71 documents changes — the one that holds
+ the case — by **one** anchor; the other seventy are byte for byte what the export wrote before.
`cv-sidebar_portrait` is among them, and it is the price: it draws 37 times over its painted
- sidebar, so none of those is lifted. Over the changed corpus `drawings-follow-text` re-measures
- in Word 16.0.20527 at 309 of 325 drawings following their text, 95.08% — the same two counts as
- before.
+ sidebar, so nothing of it is lifted and the z-order is not carried there at all. Over the
+ changed corpus `drawings-follow-text` re-measures in Word 16.0.20527 at 309 of 325 drawings
+ following their text, 95.08% — the same two counts as before.
- In LibreOffice 24.2 a shape anchored in a table cell stands the cell's left padding to the right
of where Word and the page put it, so the icon that was drawn nowhere at all is now drawn whole
and that far across: measured 14.16pt on the lifted icon, and 14.40pt right with 13.92pt low on
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index d2d334bcf..7dd194a4a 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -132,7 +132,7 @@ honour an option ignores it (documented contract).
| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) |
| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a |
| Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row; in a table cell, `w:keepLines` alone, as Word reads `w:keepNext` on the paragraph a cell opens with as keeping the row with what follows its table. A layer stack or a shape container in the flow is kept so too, as the layout moves it to a new page whole, a band of layers included; the first block of a band the layout moves to a new page — a paragraph, a picture, a list, a title and its dates set as one line or a spacer, a backdrop drawn before it or not — holds the space above it there, as one in the flow does: in a line kept with it, or in a spacer's own line; a table, a row or a panel opening it holds the band's top edge so with its own, the gap before the band left at the foot of the page above. A list, such a line or a rule the layout moves to a new page in the flow holds its space above there as a paragraph does, and a table, a row or a panel on that one page holds the gap the layout carries there with its own top edge. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing. A panel in the body the layout breaks over pages inside a table it holds is a table a page, cut at the row each page starts with (`cutThePanelsAtTheirPages`): Word repeats no header row of a table nested in a cell, so each part after a cut opens with copies of the table's header rows, where it has them, and the page breaks between the parts are fixed; where the page after a cut has other top and bottom margins, and the page above has room for it, the paragraph between the parts ends a Word section with them (per-page margins, below) |
-| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export shades in one direction (whether a node that paints becomes a shaded cell is decided as it is written, so the tree cannot be asked, and a neighbour counted that writes no shading only lifts a shape nothing would have covered) and narrower in another (a shape container's own fill is not counted, being usually drawn rather than written as a cell; where one is written as a cell, holding text, a shape above it is still covered, and named); a node bearing borders alone is not counted at all. Three things leave such a drawing behind the text: a painted layer the page lays over it in turn, where in front it would cover that layer's shading instead; a box holding a line of the flow or a picture, which in front it would cover; and anything else of the stack drawn over that painted layer, a shape being lifted only where it is the only drawing over it, since Word draws every shape in front over every shape behind and lifting one of two would put the lower over the higher (asked as "is another drawing there", not "would it have stayed behind": the second question was wrong at a picture drawn where the page puts it, at a badge handed over already in front, and at a shape whose node stands at more than one place, and its price is that a stack drawing twice over a painted layer carries no order at all — `cv-sidebar_portrait` and its thirty-seven over its sidebar). A drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — is never lifted, so over a painted neighbour it stays covered. The report names a drawing left behind wherever a panel it wrote a fill for lies under it, with one such panel. In LibreOffice 24.2 a shape anchored in a cell stands the cell's left padding right of where Word and the page put it — measured 14.16pt on the lifted icon and 14.40pt right with 13.92pt low on the same icon left behind, Word within 0.24pt of the page in both — so where that icon was drawn nowhere before, it is now drawn whole and that far across. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
+| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it, to the block's content: what the block holds above that content past the gap — its margin, its padding, a contained picture's room — comes out of its own edge (`DocxLayerColumns.resume`) where it is a paragraph, a picture, a list or a table in its layer's own flow with its edge above at zero or more, and a list keeps its own edges after the gap, as a table does. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer whose content the page starts above where those before it end — a caption over its photo, a label in a card's corner, not a block giving back its edge whose padding or contained picture's room alone reaches there — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. A painted layer, though, is a shaded cell rather than a drawing, and its fill belongs to the text layer: it is painted over every drawing behind the text, a drawing on a layer *above* it included — an icon laid over a card's corner drew none of its ink in Word 16.0.20527 or LibreOffice 24.2 against 4308 pixels of it at 300 dpi in the engine's PDF. So the stack's order is carried into the document: a shape a node paints itself, on a layer the page paints after a painted layer of an overlay it stands in, is drawn in front of the text, by the tree's order rather than the geometry — whether the two boxes meet or not — a neighbour counting as painted where it or anything it holds carries a fill in the tree, which is wider than what the export shades in one direction (whether a node that paints becomes a shaded cell is decided as it is written, so the tree cannot be asked, and a neighbour counted that writes no shading only lifts a shape nothing would have covered) and narrower in another (a shape container's own fill is not counted, being usually drawn rather than written as a cell; where one is written as a cell, holding text, a shape above it is still covered, and named); a node bearing borders alone is not counted at all. Two things leave such a drawing behind the text: a box holding a line of the flow or a picture, which in front it would cover; and anything drawn after it in any overlay it stands in, since Word draws every shape in front over every shape behind and lifting a shape the page covers with another drawing would put the lower of the two over the higher, a drawing painted before the shape not being asked about (the rule asks "is something drawn over it", not "would that have stayed behind": the second question was wrong at a picture drawn where the page puts it, at a badge handed over already in front, and at a shape whose node stands at more than one place, and its price is that a shape with anything drawn over it carries no order at all — `cv-sidebar_portrait` draws thirty-seven times over its sidebar, so nothing of it is lifted). A drawing that is no shape of a node's own — a picture drawn where the page puts it, a timeline's rail — is never lifted, so over a painted neighbour it stays covered. The report names a drawing left behind wherever a panel it wrote a fill for lies under it, with one such panel. In LibreOffice 24.2 a shape anchored in a cell stands the cell's left padding right of where Word and the page put it — measured 14.16pt on the lifted icon and 14.40pt right with 13.92pt low on the same icon left behind, Word within 0.24pt of the page in both — so where that icon was drawn nowhere before, it is now drawn whole and that far across. The panels are taken where the export writes their fill, so that a panel bearing borders alone is not named and a row written as a panel is. Not named: a drawing under a panel the layout breaks across pages or composed in an authored table cell, one under a table cell's own shading, a cell's paint the table draws itself, and the second panel over a drawing lying over two (`DocxNeighbourFillGuardTest` holds the corpus to a ledger that is now empty, and writes those edges out). Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured |
## Output surface and lifecycle
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 3b86da665..5dba7ddfc 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1280,23 +1280,22 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
text, a shape above it is still covered, and named. A node bearing
borders alone carries no fill and is not counted at all; a fill inside
either of those is.
- Three things leave such a drawing behind the text. A painted layer the
- page lays over the drawing in turn: there the drawing belongs under that
- layer's shading, and in front it would cover the shading instead — the
- same loss the other way round. A box holding a line of the flow or a
- picture, which in front it would cover, as inside a painted panel. And
- anything else of the stack drawn over that painted layer: a shape is
- lifted only where it is the only drawing over it, since Word draws every
- shape in front over every shape behind and lifting one of two would put
- the lower over the higher. That last one asks whether another drawing is
- there, not whether it would have stayed behind — asking the second
- question was wrong at a picture drawn where the page puts it, at a badge
- handed over already in front, and at a shape whose node stands at more
- than one place. Its price is that a stack drawing twice over a painted
- layer carries no order at all, which `cv-sidebar_portrait` and its
- thirty-seven drawings over its sidebar pay. A drawing that is not a shape
- a node paints itself — a picture drawn where the page puts it, a pass of
- the layout's own such as a timeline's rail — is never lifted, so over a
+ Two things leave such a drawing behind the text. A box holding a line of
+ the flow or a picture, which in front it would cover, as inside a painted
+ panel. And anything drawn after it, in any overlay it stands in, since
+ Word draws every shape in front over every shape behind and lifting a
+ shape the page covers with another drawing puts the lower of the two over
+ the higher; a drawing the page paints before the shape is not asked
+ about, the file keeping it below as the page does. That rule asks whether
+ something is drawn over the shape, not whether it would have stayed
+ behind — asking the second question was wrong at a picture drawn where
+ the page puts it, at a badge handed over already in front, and at a shape
+ whose node stands at more than one place. Its price is that a shape with
+ anything drawn over it carries no order at all, which
+ `cv-sidebar_portrait` pays: it draws thirty-seven times over its painted
+ sidebar, so nothing of it is lifted. A drawing that is not a shape a node
+ paints itself — a picture drawn where the page puts it, a pass of the
+ layout's own such as a timeline's rail — is never lifted, so over a
painted neighbour it stays covered.
The report names a drawing left behind wherever a panel it wrote a fill
for lies under it, naming the drawing and one such panel, and in a
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
index 20db137a9..b8db9dd2e 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/fidelity/DocxNeighbourFillGuardTest.java
@@ -458,6 +458,7 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
List behind = new ArrayList<>();
List lifted = new ArrayList<>();
+ List unplaceable = new ArrayList<>();
Set asShapes = new HashSet<>();
List unjudged = new ArrayList<>();
List unclassified = new ArrayList<>();
@@ -467,9 +468,13 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
// Drawn where the page puts it and never lifted, so a lifted shape must not come
// to rest over it. It is no shape of a node's own, so it is not counted among the
// drawings read, and the panels are not asked about it: only the other way round.
- if (note.path() != null && placed.containsKey(note.path())
- && !detail.contains(IN_FRONT)) {
- behind.add(note.path());
+ if (note.path() != null && !detail.contains(IN_FRONT)) {
+ if (placed.containsKey(note.path())) {
+ behind.add(note.path());
+ } else {
+ // Judged against nothing, and said so rather than dropped.
+ unplaceable.add("picture " + note.path());
+ }
}
continue;
}
@@ -538,7 +543,6 @@ private static Scan scan(DocumentSession session, DocxExportReport report) {
// "nothing found" cannot mean "nothing looked at".
List liftedUnderALater = new ArrayList<>();
List liftedOverALater = new ArrayList<>();
- List unplaceable = new ArrayList<>();
int pairs = 0;
for (String drawing : lifted) {
Integer drawnAt = paintedAt.get(drawing);
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index fc79ef591..9a31ccefd 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4476,24 +4476,30 @@ private static List layer
* the engine does. So the order is taken from the tree: a layer the page paints after a
* painted one stands in front of the text.
*
- * A painted layer the page paints after the node answers false, whatever lies
- * below: there the page covers the node, and a shape in front would cover the panel instead,
- * which is the same loss the other way round.
- *
- * So does anything else of the stack drawn over that painted layer
- * ({@code drawnOverThatLayer}): a shape is lifted where it is the only drawing over it, and
- * otherwise none is. Word draws every shape in front over every shape behind, so lifting one
- * drawing of a stack while another stays there puts the lower of the two over the higher —
- * the loss this fixes, made worse.
+ * A painted layer the page paints after the node needs no clause of its own: a
+ * layer that paints is drawn, so the test below catches it along with everything else painted
+ * after the node. It was written as its own check first and removed when breaking it reddened
+ * no test — there the page covers the node, and a shape in front would cover that layer's
+ * shading instead, which is the same loss the other way round and the same answer.
+ *
+ * So does anything of any overlay it stands in that the page paints after it
+ * ({@code drawnOverThatLayer}): a shape is lifted where nothing is drawn over it, and
+ * otherwise not. Word draws every shape in front over every shape behind, so lifting a shape
+ * the page covers with another drawing puts the lower of the two over the higher — the loss
+ * this fixes, made worse. Asked at every overlay of the climb, painted or not: a sibling in
+ * an outer stack is painted after the inner stack whole, so an inner stack's shape lifted
+ * without asking it would be drawn over the sibling the page draws over the shape. A drawing
+ * the page paints before the node is not asked about — the file keeps it below, as
+ * the page does.
*
* It asks whether anything else is drawn, not whether that other drawing would stay
* behind, and the reason is a measurement: asking the second question was wrong three times
* over, at a picture drawn where the page puts it, at a badge handed over already in front,
* and at a shape whose node stands at more than one place. Each was a kind of drawing that
* stays behind for a reason of its own, and each had to be remembered here. There is nothing
- * to remember about being alone. The price is that a stack drawing twice over a painted layer
- * keeps both behind, where lifting both would have been safe; {@code cv-sidebar_portrait}'s
- * thirty-seven over its sidebar are that case.
+ * to remember about being alone above. The price is that a stack drawing anything over the
+ * shape keeps the shape behind, where lifting it would sometimes have been safe — over a
+ * badge handed to the file in front, for one.
*
* A node standing inside a painted surface is another case, decided by the surface it is
* in ({@code surfaceBehind}) rather than by its neighbours. Only the shapes a node paints
@@ -4523,6 +4529,12 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
// One instance standing at more than one place has one path and one box among them,
// so what lies under it at one place is not what lies under it at another, and the
// answer would be given for all of them from whichever the index kept.
+ //
+ // No fixture drives this: which of the places the index keeps is not something a test
+ // can choose, so a stack built to need the refusal is as likely to be answered from
+ // the place that refuses anyway. It is kept because it can only refuse a lift, and
+ // because a shared instance did produce an inversion once, before the rule below took
+ // its own shape.
return false;
}
boolean over = false;
@@ -4550,31 +4562,25 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
return false;
}
int paintedBelow = -1;
- for (int i = 0; i < order.size(); i++) {
- if (i == itsPlace || !paintsAFill(order.get(i).node())) {
- continue;
- }
- if (i > itsPlace) {
- return false;
+ for (int i = 0; i < itsPlace; i++) {
+ if (paintsAFill(order.get(i).node())) {
+ paintedBelow = i;
}
- paintedBelow = Math.max(paintedBelow, i);
- }
- if (paintedBelow < 0) {
- continue;
}
- if (framesTextWithAShape(node)) {
- return false;
- }
- // Alone, or not at all: nothing else of the stack may be drawn over that painted
- // layer (drawnOverThatLayer).
- for (int i = paintedBelow + 1; i < order.size(); i++) {
- if (drawnOverThatLayer(order.get(i).node(), node)) {
+ // Alone above, or not at all: nothing of this overlay may be drawn after the node.
+ // Asked at every overlay it stands in, painted or not — a sibling in an outer stack
+ // is painted after the inner stack whole, and lifting a shape of that inner stack
+ // would draw it over the sibling the page draws over it.
+ for (int i = itsPlace + 1; i < order.size(); i++) {
+ if (drawnOverThatLayer(order.get(i).node())) {
return false;
}
}
- over = true;
+ if (paintedBelow >= 0) {
+ over = true;
+ }
}
- return over;
+ return over && !framesTextWithAShape(node);
}
/**
@@ -4600,25 +4606,32 @@ && framesText(fragment, layout.textOnPage(fragment.pageIndex()))) {
}
/**
- * Whether a layer, or anything it holds, draws something that is not {@code lifted} or part
- * of it.
+ * Whether a layer, or anything it holds, draws anything.
*
- *
Read as widely as the export draws. A fragment a shape is drawn from counts, whosever it
- * is: that is what the export draws shapes from, so it covers a node kind the export does not
- * know as well as a shape of its own, and it counts a painted wrapper's decoration too, which
- * only refuses a lift. And a picture counts, drawn where the page puts it and never lifted —
- * measured: a shape lifted over one came back painted over the picture the page draws over
- * it.
+ * Asked only of layers the page paints after the one being lifted, so the shape being
+ * lifted is never among them and needs no excluding.
+ *
+ * A fragment a shape is drawn from counts, whosever it is: that is what the export draws
+ * node shapes from, so it covers a node kind the export does not know as well as a shape of
+ * its own, and it counts a painted wrapper's decoration too, which only refuses a lift. And a
+ * picture counts, drawn where the page puts it and never lifted — measured: a shape lifted
+ * over one came back painted over the picture the page draws over it.
+ *
+ * Two things it cannot see, both of which would leave a drawing behind a lifted shape. A
+ * pass of the layout's own — a timeline's rail — is drawn from a fragment no node owns, so it
+ * is in neither walk; no case where that bites could be built, every rail tried bringing a
+ * marker or a line of text that refused the lift anyway. And the walk is over
+ * {@code children()}, which a table, a chart, a list and a paragraph do not implement, so what
+ * they compose is not reached — there the node's own fragments carry the drawing instead and
+ * are seen, which is why a composed cell's drawing and a chart both refuse a lift when
+ * measured.
*/
- private boolean drawnOverThatLayer(DocumentNode node, DocumentNode lifted) {
- if (node == lifted) {
- return false;
- }
+ private boolean drawnOverThatLayer(DocumentNode node) {
if (node instanceof ImageNode || drawsAShape(node)) {
return true;
}
for (DocumentNode child : node.children()) {
- if (drawnOverThatLayer(child, lifted)) {
+ if (drawnOverThatLayer(child)) {
return true;
}
}
@@ -4655,7 +4668,9 @@ private boolean drawsAShape(DocumentNode node) {
* cell, so where one is written as a cell — holding text — a drawing above it is
* still covered, and the naming pass names it. A node bearing borders alone writes no shading
* and is not counted at all; a table cell's own shading belongs to a cell and not to a node.
- * A fill inside any of those is counted, the walk being over everything the node holds.
+ * A fill inside any of those is counted as far as the walk reaches, which is
+ * {@code children()}: a table, a chart, a list and a paragraph do not implement it, so a fill
+ * composed inside one of them is not seen. That errs toward fewer lifts.
*
* Not to be confused with {@code paintsAPanel}, which asks whether a node paints anything
* at all, borders included, or {@code paintsAPanelOfItsOwn}, which asks whether a row's paint
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index b15a0aa6f..dd59d44c9 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -204,26 +204,26 @@ void aStackWhoseUpperShapeMustStayBehindKeepsEveryShapeOfItThere() throws Except
@Test
void aLayerBearingTextOverThePaintedOneDoesNotKeepTheShapeBehind() throws Exception {
- // What keeps a stack's shapes behind is a SHAPE of it that has to stay there, not any
- // layer that happens to carry text. A layer of words over the band, nowhere near the pin,
- // used to refuse the lift for the whole stack: the test of whether a box holds a line of
- // the flow was asked of the words' own fragment, which holds its own line, so every
- // text-bearing layer answered yes whatever the geometry. `[painted background, text,
- // icons]` is the ordinary layer stack, so that answer was nearly every stack there is.
+ // What keeps a shape behind is another DRAWING painted over it, not any layer that
+ // happens to carry text. The words are a layer above the pin, so they are asked — and
+ // they draw nothing, so they cover nothing. Counting their own fragment as one a shape is
+ // drawn from refused the lift for stacks of the shape `[painted background, text, icons]`,
+ // which is the ordinary layer stack, so the rule fired almost nowhere.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
.addParagraph(p -> p.name("Stage").text("Stage two")).build(),
LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
+ 150, 2, LayerAlign.TOP_LEFT, 1)
.position(new SectionBuilder().name("Words").fixedWidth(90)
.addParagraph(p -> p.name("Note").text("A line of words")).build(),
- 2, 40, LayerAlign.TOP_LEFT, 1)
- .position(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(),
- 150, 2, LayerAlign.TOP_LEFT, 2)
+ 2, 40, LayerAlign.TOP_LEFT, 2)
.build())));
assertThat(pinAnchor(export))
- .as("the words carry no shape, so they keep nothing behind")
+ .as("the words are painted over the pin but draw nothing, so they cover nothing "
+ + "and keep it behind nothing")
.contains("behindDoc=\"0\"");
assertThat(covered(export.report())).isEmpty();
}
@@ -251,13 +251,11 @@ void anUnpaintedShapeContainerOverThePaintedLayerKeepsNothingBehindIt() throws E
}
@Test
- void aStackDrawingABadgeBesideTheShapeLiftsNeither() throws Exception {
- // Alone, or not at all: the badge is a second drawing of the stack, so the pin is not
- // lifted. This is the rule's price, and a badge is the shape it is cheapest to see on —
- // a badge holding its initials is handed over in front wherever it stands, so lifting the
- // pin would have been safe here. The rule does not ask that, on purpose: asking why the
- // other drawing stays behind was wrong at a picture, at this badge, and at a node standing
- // twice, each time by letting the lower of two shapes be drawn over the higher.
+ void aShapeOverABadgeAndAPaintedLayerIsStillLifted() throws Exception {
+ // What the rule asks is whether anything is drawn OVER the shape, not whether anything is
+ // drawn at all. The badge is painted before the pin, so the file keeps the pin above it
+ // just as the page does, and the lift is safe whatever the badge's own place turns out to
+ // be.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
@@ -271,19 +269,18 @@ void aStackDrawingABadgeBesideTheShapeLiftsNeither() throws Exception {
.build())));
assertThat(pinAnchor(export))
- .as("the pin stays behind, the badge beside it being a drawing of the stack too")
- .contains("behindDoc=\"1\"");
+ .as("the pin is lifted, the badge standing under it on the page and in the file")
+ .contains("behindDoc=\"0\"");
assertThat(covered(export.report()))
- .as("and the band is named as covering it")
- .anySatisfy(named -> assertThat(named).contains("Pin").contains("Band[0]"));
+ .as("nothing covers the pin, so nothing is named for it")
+ .noneMatch(named -> named.contains("/Pin"));
}
@Test
- void aStackDrawingABorderedNeighbourLiftsNothing() throws Exception {
- // A bordered wrapper is written as a cell rather than as a drawing, but the layout gives
- // it a fragment a shape is drawn from, and that is what the rule reads: anything drawn
- // over the painted layer keeps the stack behind. Counting it costs a lift and can only
- // cost a lift, which is the side to be wrong on.
+ void aShapeOverABorderedNeighbourAndAPaintedLayerIsStillLifted() throws Exception {
+ // The bordered wrapper draws — the layout gives it a fragment a shape is drawn from — but
+ // it is painted before the pin, so it is not something drawn over the pin and the lift
+ // stands.
Export export = export(session -> session.pageFlow(page -> page
.add(new LayerStackBuilder().name("Stack")
.layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
@@ -297,8 +294,68 @@ void aStackDrawingABorderedNeighbourLiftsNothing() throws Exception {
150, 2, LayerAlign.TOP_LEFT, 2)
.build())));
- assertThat(pinAnchor(export)).contains("behindDoc=\"1\"");
+ assertThat(pinAnchor(export)).contains("behindDoc=\"0\"");
+ assertThat(covered(export.report())).noneMatch(named -> named.contains("/Pin"));
+ }
+
+ @Test
+ void aShapeStandingInTwoStacksAtOnceIsNotLifted() throws Exception {
+ // One instance used in two stacks: the layout keeps one path and one box for it, so an
+ // answer taken at one of its places is given at both. Here the first place is alone over
+ // a painted band, where a lift would be right, and the second is under a hood that the
+ // page paints over it, where the same lift draws the shape over the hood.
+ com.demcha.compose.document.node.DocumentNode shared =
+ new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build();
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Painted")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(shared, 150, 2, LayerAlign.TOP_LEFT, 1)
+ .build())
+ .add(new LayerStackBuilder().name("Hooded")
+ .layer(new SectionBuilder().name("Band two").fillColor(BAND).fixedWidth(200)
+ .addParagraph(p -> p.name("Other").text("Stage three")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(shared, 150, 2, LayerAlign.TOP_LEFT, 1)
+ .position(new ShapeBuilder().name("Hood").size(40, 20).fillColor(OVER).build(),
+ 145, 0, LayerAlign.TOP_LEFT, 2)
+ .build())));
+
+ assertThat(anchorsHolding(export, PIN_INK))
+ .as("both places behind the text, neither answered from the other's")
+ .hasSizeGreaterThan(1)
+ .allSatisfy(anchor -> assertThat(anchor).contains("behindDoc=\"1\""));
+ }
+
+ @Test
+ void aShapeOfANestedStackUnderAnOuterStacksDrawingIsNotLifted() throws Exception {
+ // The painted layer is in the inner stack, and the drawing that would be inverted is a
+ // layer of the OUTER one — painted after the inner stack whole. Asking only the overlay
+ // that holds the painted layer left this lifted: measured, the pin came back
+ // behindDoc="0" with relativeHeight below the hood's, so Word drew the pin over a hood
+ // the page draws over it.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Outer")
+ .layer(new LayerStackBuilder().name("Inner")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND)
+ .fixedWidth(200)
+ .addParagraph(p -> p.name("Stage").text("Stage two"))
+ .build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Pin").size(120, 30)
+ .fillColor(ACCENT).build(), 60, 2, LayerAlign.TOP_LEFT, 1)
+ .build(),
+ LayerAlign.TOP_LEFT, 0)
+ .position(new ShapeBuilder().name("Hood").size(70, 30).fillColor(OVER).build(),
+ 0, 0, LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the pin stays behind the hood the outer stack paints over it")
+ .contains("behindDoc=\"1\"");
assertThat(covered(export.report()))
+ .as("and the band is named as covering it, so the pin was judged at all")
.anySatisfy(named -> assertThat(named).contains("Pin").contains("Band[0]"));
}
From 313a0735768167eac37914be1459394ed9b3cf77 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 2 Nov 2026 01:20:42 +0000
Subject: [PATCH 7/7] fix(docx): read every overlay's own order in the climb, a
canvas included
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A canvas lays its children over one another as a stack does, but says so with
their order rather than with z-indices, so the climb — which looked for layers —
went past it. What a canvas lays over the shape was therefore never asked about,
and the lift was granted: measured on a document built from the public DSL, a
canvas holding a shape and a picture over a painted band gave the shape
behindDoc="0" and the picture behindDoc="1", their boxes overlapping by 4pt, so
Word drew the shape over a picture the page draws over it. The band stopped short
of the shape, so no note was written either.
The climb now reads any overlay through the one function that already knew both
forms — a stack's and a container's layers by z-index, a canvas's children in the
order it was given them — which also takes two helpers out of this method.
The javadoc said a canvas "is climbed past rather than read", as though that were
a neutral fact. It is not: climbing past is safe for finding a painted layer
below, where it only costs a lift, and unsafe for finding what is drawn after the
shape, where it grants one. That sentence is why the hole survived six rounds of
review, and it now says which direction it was unsafe in.
A fixture of that shape, and a sabotage that climbs past a canvas again, which
reddens it.
The corpus does not move: one document of the seventy-one, by one anchor, as
before.
---
.../semantic/docx/DocxSemanticBackend.java | 29 +++++++++------
.../DocxDrawingUnderANeighboursFillTest.java | 36 +++++++++++++++++++
2 files changed, 54 insertions(+), 11 deletions(-)
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 9a31ccefd..099b6ad9c 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4519,10 +4519,16 @@ private static List layer
* being one among those places. The climb itself ends at a node the layout gives no parent —
* the top of the tree, and equally a node composed in an authored table cell — with whatever
* the stacks below it answered; that is a defence against guessing at the places it did read,
- * not a claim about the ones it did not. A canvas lays its children over one another too but
- * holds no layers, so it is climbed past rather than read. The unfound-layer case is
- * defence in depth: no shape of this repository's own templates reaches it, and no fixture
- * drives it.
+ * not a claim about the ones it did not. The unfound-layer case is defence in depth: no shape
+ * of this repository's own templates reaches it, and no fixture drives it.
+ *
+ * Every overlay is read, however it says what it lays over what: a stack's and a
+ * container's layers by z-index, and a canvas's children in the order it was given them. A
+ * canvas was climbed past once, on the grounds that it holds no layers, which read as a
+ * harmless gap and was not. Climbing past is safe for finding a painted layer below, where it
+ * only costs a lift, and unsafe for finding what is drawn after the shape, where it grants
+ * one: measured, a canvas holding a shape and a picture over a painted band had the shape
+ * lifted over the picture the page draws over it.
*/
private boolean laidOverAPaintedNeighbour(DocumentNode node) {
if (layout.placedMoreThanOnce(node)) {
@@ -4541,21 +4547,22 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
DocumentNode child = node;
for (DocumentNode parent = layout.parentOf(child); parent != null;
child = parent, parent = layout.parentOf(child)) {
- List layers = layersOf(parent);
- if (layers == null) {
+ if (!isOverlay(parent)) {
continue;
}
- List order = layersInPaintOrder(layers);
+ // A stack's and a container's layers by z-index, a canvas's children in the order it
+ // was given them: what the parent lays over what, however it says so.
+ List order = inPaintOrder(parent);
int itsPlace = -1;
for (int i = 0; i < order.size(); i++) {
// By identity: two layers holding equal nodes are two layers.
- if (order.get(i).node() == child) {
+ if (order.get(i) == child) {
itsPlace = i;
break;
}
}
if (itsPlace < 0) {
- // The layout's parent is an overlay whose layers do not hold this child: the tree
+ // The layout's parent is an overlay that does not lay out this child: the tree
// does not say where in the stack it is, so neither does this — and a lift an
// inner stack had granted goes with it, that outer stack's own painted layers
// never having been read.
@@ -4563,7 +4570,7 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
}
int paintedBelow = -1;
for (int i = 0; i < itsPlace; i++) {
- if (paintsAFill(order.get(i).node())) {
+ if (paintsAFill(order.get(i))) {
paintedBelow = i;
}
}
@@ -4572,7 +4579,7 @@ private boolean laidOverAPaintedNeighbour(DocumentNode node) {
// is painted after the inner stack whole, and lifting a shape of that inner stack
// would draw it over the sibling the page draws over it.
for (int i = itsPlace + 1; i < order.size(); i++) {
- if (drawnOverThatLayer(order.get(i).node())) {
+ if (drawnOverThatLayer(order.get(i))) {
return false;
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
index dd59d44c9..48e22afb0 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingUnderANeighboursFillTest.java
@@ -298,6 +298,42 @@ void aShapeOverABorderedNeighbourAndAPaintedLayerIsStillLifted() throws Exceptio
assertThat(covered(export.report())).noneMatch(named -> named.contains("/Pin"));
}
+ @Test
+ void aShapeOnACanvasUnderThatCanvasPictureIsNotLifted() throws Exception {
+ // A canvas lays its children over one another as a stack does, but says so with their
+ // order rather than with z-indices. Climbed past for want of layers, it granted this
+ // lift: measured, the pin came back behindDoc="0" and the picture the canvas places after
+ // it behindDoc="1", their boxes overlapping by 4pt, so Word drew the pin over a picture
+ // the page draws over it. The band is narrower than the pin reaches, so nothing names it
+ // either.
+ Export export = export(session -> session.pageFlow(page -> page
+ .add(new LayerStackBuilder().name("Stack")
+ .layer(new SectionBuilder().name("Band").fillColor(BAND).fixedWidth(160)
+ .addParagraph(p -> p.name("Stage").text("Stage two")).build(),
+ LayerAlign.TOP_LEFT, 0)
+ .layer(new com.demcha.compose.document.dsl.CanvasLayerBuilder(200, 40)
+ .name("Plate")
+ .position(new ShapeBuilder().name("Pin").size(30, 14)
+ .fillColor(ACCENT).build(), 150, 2)
+ .position(new LayerStackBuilder().name("Fact")
+ .layer(new com.demcha.compose.document.dsl.ImageBuilder()
+ .name("Icon").source(pngBytes())
+ .size(10, 10).build(),
+ LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Label")
+ .text("Label").build(),
+ 14, 0, LayerAlign.CENTER_LEFT, 1)
+ .build(),
+ 176, 0)
+ .build(),
+ LayerAlign.TOP_LEFT, 1)
+ .build())));
+
+ assertThat(pinAnchor(export))
+ .as("the pin stays behind the picture the canvas places after it")
+ .contains("behindDoc=\"1\"");
+ }
+
@Test
void aShapeStandingInTwoStacksAtOnceIsNotLifted() throws Exception {
// One instance used in two stacks: the layout keeps one path and one box for it, so an