diff --git a/CHANGELOG.md b/CHANGELOG.md index 765382ae0..00f29463a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,39 +8,78 @@ 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 — 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. + - 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. + - 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 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. + - 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 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 + 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 0f2e76af0..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, 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 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 ac34e5e0a..5dba7ddfc 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -1257,21 +1257,56 @@ 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. 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. + 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 — 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. + 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 + 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..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 @@ -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,39 @@ * 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, 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 */ 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. @@ -148,9 +157,69 @@ 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"; + /** 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 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, 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, 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 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. + * + *

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 = 1; + @Test void everyDrawingUnderANeighboursFillIsLedgered() throws Exception { Map> found = new TreeMap<>(); @@ -160,8 +229,13 @@ void everyDrawingUnderANeighboursFillIsLedgered() throws Exception { List unclassified = new ArrayList<>(); List unjudged = new ArrayList<>(); List acrossPages = new ArrayList<>(); + List liftedUnderALater = 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; @@ -173,6 +247,11 @@ 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.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)); @@ -229,12 +308,35 @@ 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") + .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 - 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 +344,74 @@ 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 layer under it") + .anyMatch(note -> note.path() != null && note.path().contains("Pin") + && note.detail() != null + && note.detail().contains(LIFTED_OVER_A_NEIGHBOUR)); + } + + /** + * 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 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) { + Map placed, List panels, + 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. */ @@ -295,12 +446,38 @@ 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<>(); + List unplaceable = new ArrayList<>(); Set asShapes = new HashSet<>(); List unjudged = new ArrayList<>(); 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 && !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; + } if (!detail.contains("drawn as a shape")) { continue; } @@ -321,6 +498,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 +534,55 @@ private static Scan scan(DocumentSession session, DocxExportReport report) { } } } - return new Scan(asShapes.size(), acrossPages, covered, unjudged, unclassified, placed, panels); + // 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 liftedOverALater = new ArrayList<>(); + int pairs = 0; + for (String drawing : lifted) { + Integer drawnAt = paintedAt.get(drawing); + if (drawnAt == null) { + unplaceable.add("lifted " + drawing); + continue; + } + for (String panel : panels) { + Integer panelAt = paintedAt.get(panel); + 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); + } + } + 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, 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 de7f8a542..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 @@ -4434,19 +4434,268 @@ 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 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 + * 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 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 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 + * 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.

+ * + *

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. 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)) { + // 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; + DocumentNode child = node; + for (DocumentNode parent = layout.parentOf(child); parent != null; + child = parent, parent = layout.parentOf(child)) { + if (!isOverlay(parent)) { + continue; + } + // 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) == child) { + itsPlace = i; + break; + } + } + if (itsPlace < 0) { + // 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. + return false; + } + int paintedBelow = -1; + for (int i = 0; i < itsPlace; i++) { + if (paintsAFill(order.get(i))) { + paintedBelow = i; + } + } + // 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))) { + return false; + } + } + if (paintedBelow >= 0) { + over = true; + } + } + return over && !framesTextWithAShape(node); + } + + /** + * 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 anything. + * + *

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) { + if (node instanceof ImageNode || drawsAShape(node)) { + return true; + } + for (DocumentNode child : node.children()) { + if (drawnOverThatLayer(child)) { + 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; + } + } + 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. + * + *

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.

+ * + *

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 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 + * can be written as a panel here.

+ */ + 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 +5335,10 @@ private boolean drawOwnFragments(DocumentNode node) { private boolean drawAndNameOwnFragments(DocumentNode node) { boolean overlayFront = drawsInFront(); + // 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. @@ -5102,16 +5355,21 @@ 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. + // 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; @@ -5126,8 +5384,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 panel's shading 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 aedc7a763..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 @@ -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,560 @@ 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 a layer the page paints under it carries a fill")); + } + + @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) + .build()))); + + assertThat(pinAnchor(export)).contains("behindDoc=\"1\""); + assertThat(covered(export.report())).isEmpty(); + } + + @Test + 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) + .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) + .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) + .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)).singleElement().asString() + 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 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(), + LayerAlign.TOP_LEFT, 0) + .position(new ShapeBuilder().name("Pin").size(190, 30).fillColor(ACCENT).build(), + 2, 0, LayerAlign.TOP_LEFT, 1) + .build()))); + + assertThat(pinAnchor(export)).contains("behindDoc=\"1\""); + assertThat(covered(export.report())).singleElement().asString() .contains("Pin").contains("Band[0]").contains(COVERED); } @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 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").fixedWidth(200) - .addParagraph(p -> p.name("Stage").text("Stage two")).build(), + .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 aLayerBearingTextOverThePaintedOneDoesNotKeepTheShapeBehind() throws Exception { + // 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, 2) + .build()))); + + assertThat(pinAnchor(export)) + .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(); + } + + @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 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) + .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, the badge standing under it on the page and in the file") + .contains("behindDoc=\"0\""); + assertThat(covered(export.report())) + .as("nothing covers the pin, so nothing is named for it") + .noneMatch(named -> named.contains("/Pin")); + } + + @Test + 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) + .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())).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 + // 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(new ShapeBuilder().name("Pin").size(30, 14).fillColor(ACCENT).build(), 150, 2, LayerAlign.TOP_LEFT, 1) + .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(covered(report)).isEmpty(); + 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 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 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]")); + } + + @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 + 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 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 + // 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 + // 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 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) - .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 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(covered(report)).isEmpty(); + 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 + 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 +617,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 +625,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 +636,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 +647,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 +677,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 +690,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 +742,59 @@ 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 { + 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 found = new ArrayList<>(); + Matcher matcher = Pattern.compile("", Pattern.DOTALL) + .matcher(document.getDocument().xmlText()); + while (matcher.find()) { + if (matcher.group().contains(ink)) { + found.add(matcher.group()); + } + } + return found; + } + } + + /** What one export gives: the file, to read the anchors out of, and the report. */ + 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; 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