diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00e373ef8..ea69baac0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,22 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export sets a panel's layer where its alignment sets it.** A filled or outlined
+ rectangle or rounded rectangle with text, composed in a table cell, is written as a panel, a
+ table of one cell, and its cell was always written centred. Measured in Word and LibreOffice on
+ Windows, in a 100×60 panel, a label set at the top-left corner stood at the middle, 22.5pt low.
+ One set at the bottom-right corner stood at the middle and at the left, 46pt left of the
+ page's. Neither was named.
+ - The cell now sets what it writes at the top, the middle or the foot, as its first written
+ layer is set. A paragraph is held in across to its box where its own alignment does not lean
+ the way its layer's does, or where an offset moves it across. Its width is read off the lines
+ its table laid out for it. Both labels stand where the page sets them, in both editors.
+ - What the cell cannot set is named (`layer offset`):
+ - a written layer after the first, which the cell writes under the layers before it;
+ - an offset down or up;
+ - a box set past the shape's right edge, or left of it;
+ - a layer set across by a box the layout does not give: a layer other than a paragraph, or a
+ paragraph whose lines are not found.
- **A DOCX export sets a layer across where its offset moves it.** Measured in Word and
LibreOffice on Windows, a stack's one layer moved 40pt in stood at the stack's left edge in
both. A band's layer the page sets 20pt left of the stack stood at the stack's edge, the page
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 08c4b14c0..568a4705e 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -123,7 +123,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. 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 |
-| 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. 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 layer the page starts above where those before it end — a caption over its photo, a label in a card's corner — 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. 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. 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 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 the page starts above where those before it end — a caption over its photo, a label in a card's corner — 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. 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 4efc724b1..4c4deb593 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -980,8 +980,16 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
held at least its outline's height less the borders both editors draw
outside it where its padding does not hold its top border, with its layers inside,
its corners squared and reported — a rota's shift chips keep their colour and their
- size. A one-line label the shape centres top to bottom, on a line taller
- than the outline leaves it, is written that much shorter, down to the
+ size. Its cell sets what it writes at the top, the middle or the foot, where the
+ first written layer's alignment sets that layer. A paragraph whose own alignment does
+ not lean the way its layer's does, or that an offset moves across, is held in to its
+ box: the cell's room less the width its table laid its lines out at, set at the
+ alignment's share and moved by its offset. What the cell cannot set where the page does
+ is named (`layer offset`): a written layer after the first, which the cell writes under
+ the layers before it; an offset down or up; a box set past the shape's right edge or
+ left of it; and a layer set across by a box the layout does not give — not a paragraph,
+ or one whose lines are not found. A one-line label the shape centres top to bottom, on
+ a line taller than the outline leaves it, is written that much shorter, down to the
room Word leaves the cell's content — the outline less the margins written
and the borders, which Word keeps outside its content, as wide as it draws
them — its text seated
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 a2fa13ef3..acfbb80a0 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
@@ -6705,7 +6705,8 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint
onANewPage = true;
}
List The page sets a layer narrower than the shape at its alignment's side or middle: a label
+ * set at the bottom-right corner stood at the left in Word and LibreOffice. A paragraph whose
+ * own alignment leans the same way, unmoved, already stands there. Any other is held to its
+ * box, the shape's room less its width set at its alignment's share and moved by its offset.
+ * What is not measured is named ({@link #reportTheLayersOfItsPanel}).
+ *
+ */
+ private void reportTheLayersOfItsPanel(ShapeContainerNode shape) {
+ double down = Double.NaN;
+ for (com.demcha.compose.document.node.LayerStackNode.Layer layer : shape.layers()) {
+ if (!writtenInItsPanel(layer)) {
+ continue;
+ }
+ List
Measured in Word 16 and LibreOffice on Windows, in a 100×60 panel: a label set at the + * top-left corner stood at the middle, 22.5pt low, and one set at the bottom-right corner at the + * middle and at the left, 46pt left of the page's — the panel's cell was always written centred. + * Set at the top, the foot or the middle as their layer is, and held in across to their box, both + * stood where the page sets them, in both.
+ * + * @author Artem Demchyshyn + */ +class DocxPanelLayerAlignTest { + + private static final DocumentColor LIGHT = DocumentColor.rgb(220, 230, 245); + private static final double ROOM = 100; + + @Test + void aLayerAtTheTopSetsThePanelsCellAtTheTop() throws Exception { + Exported exported = export(panel(text("TopLeft"), LayerAlign.TOP_LEFT, 0, 0)); + + assertThat(exported.vertical()).as("Word's own default, not written").isNull(); + assertThat(exported.left("TopLeft")).isZero(); + assertThat(exported.right("TopLeft")).as("set from the left as its layer is, not held to its box").isZero(); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aLayerAtTheFootSetsThePanelsCellAtTheFootAndItsBoxAtItsSide() throws Exception { + Exported exported = export(panel(text("BotRight"), LayerAlign.BOTTOM_RIGHT, 0, 0)); + + assertThat(exported.vertical()).isEqualTo("bottom"); + assertThat(exported.left("BotRight")).as("held in to its box at the right") + .isCloseTo(Math.round((ROOM - exported.widthOf("BotRight")) * 20), within(2L)); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void aCentredLayerSetsThePanelsCellAtTheMiddleAndALeftParagraphInItsMiddle() throws Exception { + Exported exported = export(panel(text("Middle"), LayerAlign.CENTER, 0, 0)); + + assertThat(exported.vertical()).isEqualTo("center"); + double width = exported.widthOf("Middle"); + assertThat(exported.left("Middle")).as("its box centred, its text set from the box's left") + .isCloseTo(Math.round((ROOM - width) / 2 * 20), within(2L)); + // On the right, the room to spare a line set from its left is given, and what Word's face + // asks of its measure. + assertThat(exported.right("Middle")).as("held in on the right to its box too") + .isCloseTo(Math.round(((ROOM - width) / 2 - Math.max(2, width * 0.05)) * 20), within(30L)); + } + + @Test + void aParagraphLeaningAsItsLayerIsNotHeldIn() throws Exception { + Exported exported = export(panel(new ParagraphBuilder().text("Middle").align(TextAlign.CENTER).build(), + LayerAlign.CENTER, 0, 0)); + + assertThat(exported.left("Middle")).as("centred by its own alignment").isZero(); + } + + @Test + void anOffsetAcrossMovesTheBox() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, 10, 0)); + + assertThat(exported.left("Moved")).isCloseTo(200L, within(2L)); + assertThat(exported.offsetNotes()).isEmpty(); + } + + @Test + void anOffsetLeftOfThePanelIsWrittenAtItsEdgeAndNamed() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, -6, 0)); + + assertThat(exported.left("Moved")).isZero(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("the page sets it 6pt left of the edge it is written from; in its table cell it is " + + "written at that edge"); + } + + @Test + void anOffsetDownIsNamed() throws Exception { + Exported exported = export(panel(text("Moved"), LayerAlign.TOP_LEFT, 0, 6)); + + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: its offset moves it 6pt down, which its cell does not write"); + } + + @Test + void layersAtDifferentHeightsAreSetAtTheFirstsAndTheOthersNamed() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT) + .layer(text("Top"), LayerAlign.TOP_LEFT) + .layer(text("Foot"), LayerAlign.BOTTOM_LEFT) + .build(); + Exported exported = export(panel); + + assertThat(exported.vertical()).as("the first's").isNull(); + // Composed in a table cell, it has no path of its own in the layout. + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it at the foot of the shape, and its cell " + + "sets all it holds at the top, it under the layers before it"); + } + + @Test + void layersAtOneHeightAreWrittenOneUnderTheOtherAndNamed() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT) + .layer(text("Title"), LayerAlign.TOP_LEFT) + .layer(text("$9"), LayerAlign.TOP_RIGHT) + .build(); + Exported exported = export(panel); + + assertThat(exported.left("$9")).as("held in to the right still").isPositive(); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it at the height of the layers before it, " + + "and its cell writes it under them"); + } + + @Test + void aCentredParagraphMovedAcrossPastTheShapesEdgeIsNamed() throws Exception { + // A centred paragraph is as wide as the shape: moved 10pt right, it runs 10pt past it. + Exported exported = export(panel(new ParagraphBuilder().text("Hi").align(TextAlign.CENTER).build(), + LayerAlign.CENTER, 10, 0)); + + assertThat(exported.left("Hi")).as("moved in 10pt, less half the room to spare a centred line is given") + .isCloseTo(Math.round((10 - Math.max(2, ROOM * 0.05) / 2) * 20), within(2L)); + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it 10pt past the shape's right edge, where " + + "its cell's text ends"); + } + + @Test + void aPaddedShapesLayerIsHeldInTheRoomItsPaddingLeaves() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT).padding(5) + .position(text("BotRight"), 0, 0, LayerAlign.BOTTOM_RIGHT).build(); + Exported exported = export(panel); + + assertThat(exported.left("BotRight")).as("at the right of the cell's text, its margins the padding") + .isCloseTo(Math.round((ROOM - 10 - exported.widthOf("BotRight")) * 20), within(2L)); + } + + @Test + void aSectionSetAcrossIsWrittenFromTheLeftAndNamed() throws Exception { + DocumentNode section = new SectionBuilder().addParagraph("Inside").build(); + Exported exported = export(panel(section, LayerAlign.TOP_RIGHT, 0, 0)); + + assertThat(exported.offsetNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("written as a panel's content: the page sets it across by a box the layout does not give " + + "where it is composed in a table cell (SectionNode), and it is written from the shape's " + + "left"); + } + + private static DocumentNode panel(DocumentNode layer, LayerAlign align, double dx, double dy) { + return new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT).position(layer, dx, dy, align).build(); + } + + private static DocumentNode text(String text) { + return new ParagraphBuilder().text(text).build(); + } + + private record Exported(XWPFDocument document, List