diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e4b6096a..ea7ab8c5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,31 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export sets a later layer where the page does when its first block's edge reaches
+ above the layers before it.** A layer written after another starts at the page's gap below
+ them, less what its first block writes above its content itself. Where the block held more
+ than that gap, the rest was dropped and the block wrote all of it.
+ - Measured in Word and LibreOffice on Windows:
+ - a caption with a 20pt margin starting 7pt under the line above stood 12.7pt low, with
+ nothing named, and so did one centred by an alignment wrapper;
+ - a table with a 20pt margin stood 12.6pt low, with nothing named;
+ - a caption with a 20pt padding stood 12.7pt low, and a picture contained in a box level with
+ that line 12.9pt. The report said each was written under the line, though the page sets it
+ below.
+ - The block's own edge now gives that back, in a band and in a column's cell, where the block is
+ a paragraph, a picture, a list or a table in its layer's own flow (under plain sections and
+ containers, alignment and anchor wrappers alone), its edge above at zero or more. Each measured
+ one stands within 0.4pt of the page's distance from the line above, and the
+ padded caption and the picture are no longer named. A block in a row, a panel or a nested stack
+ writes its edge inside them, and keeps the old gap and the overlap measured from its box, as
+ does a block with an edge below zero.
+ - A list a layer opens now keeps its own margin and padding after the layer's gap, as a table
+ does. The gap took their place: a list with a 20pt margin opening a band stood 20pt high, one
+ with 10pt 10pt high, and one in a later layer 7pt high. Each now stands within 0.4pt.
+ - A layer whose content the page starts above the layers before it is still written under them
+ and named. A caption over a photo's foot with a 6pt margin stood that much lower again; it now
+ gives the margin back.
+ - None of the corpus's 62 DOCX changes, and none of its 1246 notes.
- **A DOCX export keeps a band of layers and a shape container on the page the layout puts them
on.** The layout moves a layer stack or a shape container to a new page whole. A stack of more
than one layer is written as a band, its layers one after the other, and Word was told nothing
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 3e7fd1b00..55edd51d4 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 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 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 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 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 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, 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. 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 b94af2767..9915e00b6 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -855,7 +855,19 @@ recolour what is under it in Word and it keeps the colour it was flattened to.
spacer level with another layer's content only keeps its place and is not
written, the space above the first block written and below the last is the
page's distance from the stack's edges, and a later layer resumes the page's
- distance below the blocks above it. A badge — a ring, drawn, with initials centred
+ distance below the blocks above it, to its first block's content. What that block
+ holds above its content past that distance — a margin, a padding, a contained
+ picture's room reaching above where the layers before it end — comes out of its own
+ edge, in a band as in a column's cell, where the block is a paragraph, a picture, a
+ list or a table in its layer's own flow — under plain sections and containers, and
+ alignment and anchor wrappers — its edge above at zero or more; not in a row,
+ a panel or a nested stack, which keep the gap to the block's box and name the overlap
+ from it. Kept whole, a caption with a 20pt margin 7pt under the line above stood 12.7pt
+ low in Word and LibreOffice, a picture contained in a box level with that line 12.9pt,
+ and a table with a 20pt margin 12.6pt. A list a layer opens keeps its own margin and
+ padding after the layer's resume, as a table does: lost there, a list with a 20pt
+ margin opening a band stood 20pt high, and one in a later layer 7pt. A badge — a ring,
+ drawn, with initials centred
in it, over a spacer that keeps its place in the flow — writes its initials where
the ring centres them and keeps its height below them. Each layer, here and in a
shape container, is held in across to its box in the stack, with a few points to
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java
index 570e1f150..ffe85e47d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerColumns.java
@@ -1,10 +1,18 @@
package com.demcha.compose.document.backend.semantic.docx;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.node.AlignNode;
+import com.demcha.compose.document.node.ContainerNode;
import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.ImageNode;
import com.demcha.compose.document.node.LayerAlign;
import com.demcha.compose.document.node.LayerStackNode;
+import com.demcha.compose.document.node.ListNode;
+import com.demcha.compose.document.node.ParagraphNode;
+import com.demcha.compose.document.node.SectionNode;
import com.demcha.compose.document.node.SpacerNode;
+import com.demcha.compose.document.node.TableNode;
import java.util.ArrayList;
import java.util.Collections;
@@ -14,6 +22,7 @@
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
+import java.util.function.ToDoubleFunction;
/**
* A layer stack whose layers are side-by-side columns, as the columns of one table row.
@@ -61,7 +70,8 @@ record Column(double left, double right, List layers) {
* @param columns the columns, left to right
* @param standIns the spacers that hold another layer's place, to be left out
* @param resumes for each layer written after another in its cell, the space above its
- * first block, in points
+ * first block, in points; below zero where the block's own edge gives some
+ * back ({@link #resume})
* @param over for each layer the page starts above where the content of the layers before
* it in its cell ends, by how much, in points (see {@link #over(DocumentNode)})
* @param moves what is written in place of a stand-in instead (see {@link Moves})
@@ -78,8 +88,9 @@ record Plan(double width, List columns, Set standIns,
* The space above a layer's first block when it follows another layer in its cell.
*
* @param layer a layer of the stack
- * @return the space in points, or {@code NaN} when the layer opens its cell or the
- * layout placed too little of it to tell
+ * @return the space in points, below zero where the block's own edge gives some back
+ * ({@link DocxLayerColumns#resume}), or {@code NaN} when the layer opens its cell
+ * or the layout placed too little of it to tell
*/
double resume(DocumentNode layer) {
Double points = resumes.get(layer);
@@ -114,23 +125,27 @@ record Plan(double width, List columns, Set standIns,
* no column, and may stand over one — a card grid lays each card's mark
* over the edge of its text and a rule between two cards, and was written
* one card under the other, each a column lower and further right
+ * @param inset how far below the top of its box the page starts a leaf's content, which the
+ * leaf writes above that content itself: its padding, a contained picture's
+ * room in its box
* @return the plan, or {@code null} when the stack is not a set of side-by-side columns
*/
static Plan of(LayerStackNode stack, DocxLayoutMetrics layout, Predicate plain,
- Predicate painted, Predicate drawn) {
+ Predicate painted, Predicate drawn, ToDoubleFunction inset) {
// Every layer a column first: a stack that already forms columns — a portrait's band
// beside a text band — is written as it always was. Only one whose drawn layers stand
// across the columns is tried again without them.
- Plan whole = columnsOf(stack, layout, plain, painted, node -> false);
+ Plan whole = columnsOf(stack, layout, plain, painted, node -> false, inset);
if (whole != null) {
return whole;
}
- Plan withoutDrawing = columnsOf(stack, layout, plain, painted, drawn);
+ Plan withoutDrawing = columnsOf(stack, layout, plain, painted, drawn, inset);
return withoutDrawing == null || withoutDrawing.drawn().isEmpty() ? null : withoutDrawing;
}
private static Plan columnsOf(LayerStackNode stack, DocxLayoutMetrics layout, Predicate plain,
- Predicate painted, Predicate drawn) {
+ Predicate painted, Predicate drawn,
+ ToDoubleFunction inset) {
PlacedNode box = layout.placement(stack);
if (box == null || stack.layers().size() < 2) {
return null;
@@ -184,7 +199,8 @@ private static Plan columnsOf(LayerStackNode stack, DocxLayoutMetrics layout, Pr
Set closing = Collections.newSetFromMap(new IdentityHashMap<>());
Moves moves = new Moves();
for (Column column : columns) {
- flow(column.layers(), layout, node -> false, painted, painted, standIns, resumes, over, closing, moves);
+ flow(column.layers(), layout, node -> false, painted, painted, inset, standIns, resumes, over, closing,
+ moves);
}
return new Plan(width, List.copyOf(columns), standIns, resumes, over, moves, List.copyOf(drawnLayers),
closing);
@@ -233,7 +249,8 @@ private boolean filled(DocumentNode standIn) {
*
* @param layers the stack's layers, in its order
* @param standIns the spacers that hold another layer's place, to be left out
- * @param resumes for each layer after the first, the space above its first block
+ * @param resumes for each layer after the first, the space above its first block, below zero
+ * where the block's own edge gives some back ({@link #resume})
* @param over for each layer the page starts above where the content of the layers before
* it ends, by how much, in points (see {@link Plan#over(DocumentNode)})
* @param above from the stack's top to its first written block, that block's own margin
@@ -276,10 +293,11 @@ record Band(List layers, Set standIns, Map drawing,
- Predicate painted) {
+ Predicate painted, ToDoubleFunction inset) {
PlacedNode box = layout.placement(stack);
if (box == null || stack.children().size() < 2) {
return null;
@@ -293,7 +311,7 @@ static Band band(DocumentNode stack, DocxLayoutMetrics layout, Predicate false, painted, standIns, resumes, over, closing, none);
+ flow(layers, layout, drawing, node -> false, painted, inset, standIns, resumes, over, closing, none);
// A stack nested in a layer is a band of its own, and its stand-ins are not written
// either: the band's first and lowest blocks are measured past them too, or a badge's
// place-holding spacer would set where the initials over it start.
@@ -335,9 +353,9 @@ private static Predicate written(Set standIns, Moves
private static void nestedStandIns(DocumentNode node, DocxLayoutMetrics layout,
Predicate drawing, Set standIns) {
if (node instanceof LayerStackNode nested) {
- flow(nested.children(), layout, drawing, candidate -> false, candidate -> false, standIns,
- new IdentityHashMap<>(), new IdentityHashMap<>(), Collections.newSetFromMap(new IdentityHashMap<>()),
- new Moves());
+ flow(nested.children(), layout, drawing, candidate -> false, candidate -> false, candidate -> 0,
+ standIns, new IdentityHashMap<>(), new IdentityHashMap<>(),
+ Collections.newSetFromMap(new IdentityHashMap<>()), new Moves());
}
for (DocumentNode child : node.children()) {
nestedStandIns(child, layout, drawing, standIns);
@@ -351,12 +369,13 @@ private static void nestedStandIns(DocumentNode node, DocxLayoutMetrics layout,
*
* @param painted the panels content moves into and resumes are measured from
* @param panels the panels where the layers before a layer end, when one is the lowest
+ * @param inset how far below its box's top a leaf's content starts (see {@link #of})
*/
private static void flow(List layers, DocxLayoutMetrics layout,
Predicate drawing, Predicate painted,
- Predicate panels, Set standIns,
- Map resumes, Map over,
- Set closing, Moves moves) {
+ Predicate panels, ToDoubleFunction inset,
+ Set standIns, Map resumes,
+ Map over, Set closing, Moves moves) {
if (layers.size() < 2) {
return;
}
@@ -406,24 +425,96 @@ private static void flow(List layers, DocxLayoutMetrics layout,
DocumentNode[] first = new DocumentNode[1];
double space = spaceAbove(layers.subList(0, index), layers.get(index), layout, standIns, drawing,
painted, moves, from, first);
+ DocumentNode layer = layers.get(index);
+ // What a block gives back is where its content starts, and so is where the overlap the
+ // report names starts; any other block stands where its box does, as it is written.
+ ToDoubleFunction givenBack = leaf -> givesItsEdgeBack(layer, leaf, panels)
+ ? Math.max(0, inset.applyAsDouble(leaf)) : 0;
if (!Double.isNaN(space)) {
- // Its first block writes its own margin above it. Written after them, the layer
- // starts under them however far above their end the page starts it.
- resumes.put(layers.get(index), Math.max(0, space - first[0].margin().top()));
+ resumes.put(layer, resume(space, layer, first[0], givenBack, panels));
if (from[0] != null) {
closing.add(from[0]);
}
}
// A panel among them ends where its box does, below its text; the layer starts at its
- // highest block, not its first where its blocks stand side by side, as a row's do.
+ // highest content, not its first where its blocks stand side by side, as a row's do.
double end = endOf(layers.subList(0, index), layout, standIns, drawing, panels, moves, new PlacedNode[1]);
- PlacedNode highest = highestLeaf(layers.get(index), layout, written(standIns, moves, true), drawing, null);
- if (highest != null && top(highest) - end > EDGE) {
- over.put(layers.get(index), top(highest) - end);
+ double highest = highestContent(layer, layout, written(standIns, moves, true), drawing, givenBack,
+ Double.NaN);
+ if (!Double.isNaN(highest) && highest - end > EDGE) {
+ over.put(layers.get(index), highest - end);
}
}
}
+ /**
+ * A later layer's resume: the space Word writes above its first block, from the last block of
+ * the layers above, before the block writes what it holds above its own content — its margin,
+ * and inside its box its padding and a contained picture's room.
+ *
+ * That is the page's gap to the block's content less what the block writes, and below zero
+ * where the block holds more above its content than the gap: a caption whose 20pt margin
+ * starts it 7pt under the line above, or a picture contained in a box that starts level with
+ * that line. The block's own edge then gives that much back. Kept at zero, it was written
+ * whole after the layers before it, and the caption stood 12.7pt low in Word and LibreOffice,
+ * the picture 12.9pt (measured). Where the page starts the content itself above where they
+ * end, it is written under them, no further up than its own edge gives, and named
+ * ({@link Band#over}).
+ *
+ * Only a block that {@link #givesItsEdgeBack} does so. Any other keeps the gap to its box
+ * less its margin, never below zero, as it always has: one in a row, a panel, a nested stack or
+ * a shape writes its edge inside them; a spacer writes neither its margin nor its padding,
+ * though its margin comes off the gap, and the report names it; and a block with an edge below
+ * zero takes it out where it is written, by its own writer's rules.
+ *
+ * @param space from the end of the layers above to the top of the block's box, below zero
+ * where the box starts above that end ({@link #spaceAbove})
+ * @param inset what the block writes above its content inside its box, where it gives its edge
+ * back, and none elsewhere
+ */
+ private static double resume(double space, DocumentNode layer, DocumentNode block,
+ ToDoubleFunction inset, Predicate painted) {
+ if (!givesItsEdgeBack(layer, block, painted)) {
+ return Math.max(0, space - block.margin().top());
+ }
+ double inside = inset.applyAsDouble(block);
+ return Math.max(0, space + inside) - block.margin().top() - inside;
+ }
+
+ /**
+ * Whether a later layer's first block writes its own edge above its content where the layer
+ * resumes, so that edge can give back what reaches above the layers before it: a paragraph, a
+ * picture, a list or a table in the layer's own flow, under plain containers alone, its margin
+ * and padding above at zero or more.
+ */
+ private static boolean givesItsEdgeBack(DocumentNode layer, DocumentNode block, Predicate painted) {
+ return (block instanceof ParagraphNode || block instanceof ImageNode
+ || block instanceof ListNode || block instanceof TableNode)
+ && block.margin().top() >= 0 && block.padding().top() >= 0
+ && inThePlainFlowOf(layer, block, painted);
+ }
+
+ /**
+ * Whether {@code block} is {@code node} or under it through plain containers alone: unpainted
+ * sections and containers, and the alignment and anchor wrappers written as their content.
+ */
+ private static boolean inThePlainFlowOf(DocumentNode node, DocumentNode block, Predicate painted) {
+ if (node == block) {
+ return true;
+ }
+ boolean plain = node instanceof SectionNode || node instanceof ContainerNode || node instanceof AlignNode
+ || node instanceof LayoutAnchorNode;
+ if (!plain || painted.test(node)) {
+ return false;
+ }
+ for (DocumentNode child : node.children()) {
+ if (inThePlainFlowOf(child, block, painted)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Moves into a stand-in the outermost blocks under {@code node} that lie inside its place
* and hold content: a subtitle set in a chip moves with its chip.
@@ -488,8 +579,8 @@ private static boolean inside(PlacedNode box, PlacedNode place, double[] across)
* layers' content already takes. Written after them, it is space above that content a
* second time. What the cell needs instead is the gap the page shows between the two:
* from the bottom of the lowest block above, its own padding included, to the top of the
- * layer's first block — less that block's margin, which the block writes itself, it is the
- * layer's resume. A block inside a painted panel ends where the panel does: the panel is a
+ * layer's first block — less what that block writes above its content itself, it is the
+ * layer's resume ({@link #resume}). A block inside a painted panel ends where the panel does: the panel is a
* table that writes its own padding below the block, so that padding is not part of the gap.
*
* @param first set to the layer's first block
@@ -581,19 +672,28 @@ private static PlacedNode lowestLeaf(DocumentNode node, DocxLayoutMetrics layout
return lowest;
}
- /** The placed leaf under {@code node} whose top is highest on the page, as {@link #lowestLeaf} reads them. */
- private static PlacedNode highestLeaf(DocumentNode node, DocxLayoutMetrics layout,
- Predicate written, Predicate drawing,
- PlacedNode highest) {
+ /**
+ * Where on the page the highest written content under {@code node} starts, as
+ * {@link #lowestLeaf} reads its leaves: a leaf's box's top less what the leaf writes above its
+ * content inside it ({@code inset}). Measured up from the page's foot, so higher is larger;
+ * {@code NaN} while none is found.
+ */
+ private static double highestContent(DocumentNode node, DocxLayoutMetrics layout,
+ Predicate written, Predicate drawing,
+ ToDoubleFunction inset, double highest) {
if (!written.test(node)) {
return highest;
}
if (node.children().isEmpty()) {
PlacedNode placed = drawing.test(node) ? null : layout.placement(node);
- return placed != null && (highest == null || top(placed) > top(highest)) ? placed : highest;
+ if (placed == null) {
+ return highest;
+ }
+ double content = top(placed) - inset.applyAsDouble(node);
+ return Double.isNaN(highest) || content > highest ? content : highest;
}
for (DocumentNode child : node.children()) {
- highest = highestLeaf(child, layout, written, drawing, highest);
+ highest = highestContent(child, layout, written, drawing, inset, highest);
}
return highest;
}
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 294c33a74..6922fc9f7 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
@@ -3933,7 +3933,7 @@ private void writeNodeContent(XWPFDocument document, DocumentNode node) throws E
candidate -> (candidate instanceof SectionNode || candidate instanceof ContainerNode)
&& paintOf(candidate).isEmpty(),
DocxSemanticBackend::paintsAPanel,
- this::drawnOverTheColumns);
+ this::drawnOverTheColumns, this::contentInset);
if (columns != null) {
// Side by side, nothing in the stack overlaps: it is not an overlay.
writeLayerColumns(document, stack, columns);
@@ -4268,7 +4268,7 @@ private void writeNodeContentOf(XWPFDocument document, DocumentNode node) throws
if (node instanceof com.demcha.compose.document.node.LayerStackNode
|| node instanceof ShapeContainerNode && writesOneLayer(node)) {
DocxLayerColumns.Band band = DocxLayerColumns.band(node, layout, drawnIn(node),
- DocxSemanticBackend::paintsAPanel);
+ DocxSemanticBackend::paintsAPanel, this::contentInset);
if (band != null) {
if (node instanceof ShapeContainerNode container) {
writeShapeContainer(document, container, band);
@@ -5435,6 +5435,11 @@ private void writeList(XWPFDocument document,
// object either — its items are paragraphs written where it stood — so its edges go
// where every other block's go, and itemSpacing becomes the gap above each item
// after the first.
+ // A list a band's or a column's layer opens is that layer's first block: it starts where
+ // the layer resumes, and its own edges come after, as a table's do. Left to its first
+ // item's paragraph, the resume took their place, and a list with a 20pt margin in a later
+ // layer stood 7pt high in Word and LibreOffice (measured).
+ resumeHere();
carriedSpacingBefore += list.margin().top() + list.padding().top();
double previousItemSpacing = pendingItemSpacing;
pendingItemSpacing = list.itemSpacing();
@@ -9342,6 +9347,20 @@ private static boolean paintedOrFramed(CTTcPr properties) {
|| drawn(borders.getRight()) || drawn(borders.getStart()) || drawn(borders.getEnd());
}
+ /**
+ * Resumes a later layer of a band or a column at the page's distance below the layers before
+ * it ({@link DocxLayerColumns#resume}). Below zero, its first block holds more above its
+ * content than the page's gap, and its own edge gives the rest back, as it gives back a pull
+ * out of the block above: a caption with a 20pt margin 7pt under the line above, written
+ * 20pt under it, stood 12.7pt low in Word and LibreOffice (measured).
+ *
+ * @param resume the space in points, or {@code NaN} where the layout tells none
+ */
+ private void resumeLayer(double resume) {
+ pullBelow = resume < 0 ? -resume : 0;
+ resumeSpacing = resume < 0 ? 0 : resume;
+ }
+
/**
* Makes the space owed above the block about to be written the resumed one, when a
* column layer has just started after another in its cell (see {@link #writeLayerColumns}).
@@ -13437,6 +13456,28 @@ private XWPFPicture addFittedPicture(XWPFRun run, byte[] bytes, ImageData resolv
return picture;
}
+ /**
+ * How far below the top of its box the page starts a leaf's content, which the leaf writes
+ * above that content itself: its padding, and the room above a picture contained in its box
+ * ({@link #roomRound}), as {@link #writeImage} writes it.
+ */
+ private double contentInset(DocumentNode leaf) {
+ double inset = Math.max(0, leaf.padding().top());
+ if (leaf instanceof ImageNode image && image.fitMode() == DocumentImageFitMode.CONTAIN) {
+ com.demcha.compose.document.layout.PlacedNode placed = layout.placement(image);
+ if (placed != null) {
+ DocumentInsets padding = image.padding();
+ double width = placed.placementWidth() - padding.left() - padding.right();
+ double height = placed.placementHeight() - padding.top() - padding.bottom();
+ if (width > 0 && height > 0) {
+ ImageData resolved = NodeDefinitionSupport.toImageData(image.imageData());
+ inset += roomRound(resolved, new NodeDefinitionSupport.ImageDimensions(width, height))[0];
+ }
+ }
+ }
+ return inset;
+ }
+
/** The size a picture contained in its box is drawn at, {@code {width, height}}: by the smaller ratio. */
private static double[] containedIn(ImageData resolved, NodeDefinitionSupport.ImageDimensions box) {
double sourceWidth = Math.max(1, resolved.getMetadata().width());
@@ -16634,8 +16675,7 @@ private void writeLayerColumns(XWPFDocument document,
// from a last line set as high as it was raised.
double raised = raiseOwedByTheLastLine();
pendingSpacingAfter = 0;
- pullBelow = 0;
- resumeSpacing = plan.resume(node) + raised;
+ resumeLayer(plan.resume(node) + raised);
}
// A column layer is written as what it holds, never dispatched itself:
// no bookmark goes round it, and a page reference to it is taken back to
@@ -16791,8 +16831,7 @@ private void writeOverlayBand(XWPFDocument document, DocumentNode stack, DocxLay
double raised = raiseOwedByTheLastLine();
pendingSpacingAfter = 0;
carriedSpacingBefore = 0;
- pullBelow = 0;
- resumeSpacing = resume + raised;
+ resumeLayer(resume + raised);
}
reportWrittenUnderTheLayersBefore(layers.get(index), band.over(layers.get(index)),
stack instanceof ShapeContainerNode ? "shape container" : "stack");
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerResumeTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerResumeTest.java
new file mode 100644
index 000000000..aed25c543
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLayerResumeTest.java
@@ -0,0 +1,286 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.LayerStackBuilder;
+import com.demcha.compose.document.dsl.ListBuilder;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.dsl.ShapeBuilder;
+import com.demcha.compose.document.dsl.TableBuilder;
+import com.demcha.compose.document.image.DocumentImageData;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.poi.xwpf.usermodel.XWPFTableRow;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.util.function.Consumer;
+import java.util.function.UnaryOperator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A later layer of a band or a column resumes at the page's distance below the layers before it,
+ * though its first block holds more above its content than that distance.
+ *
+ * A layer after another is written after it, and the space above its first block is the page's
+ * gap from the layers above to the block's content, less what the block writes above that content
+ * itself: its margin, its padding, a contained picture's room in its box. Where the block holds
+ * more than the gap, the difference is below zero and was kept at zero, so the block wrote all of
+ * it after the layers before it. Measured in Word 16 and LibreOffice on Windows, a caption with a
+ * 20pt margin 7pt under the line above stood 12.7pt low, so did one with a 20pt padding, a picture
+ * contained in a box level with that line 12.9pt, and a table with a 20pt margin 12.6pt. A list
+ * lost its own margin there instead, the resume taking its place, and one in a later layer stood 7pt
+ * high, one opening a band 10 to 20pt high. Given back, each stands within 0.4pt of the page's
+ * distance from the line above it.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxLayerResumeTest {
+
+ /** The page's gap from "Opening" to a block whose 20pt edge starts at the band's top: 7.05pt. */
+ private static final long GAP = 141;
+
+ /** Word may raise a line into the space above it, a point at most here. */
+ private static final long RAISE = 20;
+
+ @Test
+ void aLaterLayersMarginReachingAboveTheLayersBeforeIsGivenBack() throws Exception {
+ try (XWPFDocument document = export(band(new ParagraphBuilder().text("Caption")
+ .margin(DocumentInsets.top(20)).build()))) {
+ assertThat(before(anywhere(document, "Caption"))).as("the page's 7pt gap, not the 20pt margin")
+ .isBetween(GAP - RAISE, GAP);
+ }
+ }
+
+ @Test
+ void aLaterLayersPaddingReachingAboveIsGivenBackAndNotNamed() throws Exception {
+ Consumer content = band(new ParagraphBuilder().text("Caption")
+ .padding(DocumentInsets.top(20)).build());
+ try (XWPFDocument document = export(content)) {
+ assertThat(before(anywhere(document, "Caption"))).isBetween(GAP - RAISE, GAP);
+ }
+ assertThat(DocxExports.report(400, 600, 20, content).bySubject().get("overlapping layer"))
+ .as("its text starts below the line above").isNull();
+ }
+
+ @Test
+ void aContainedPicturesRoomReachingAboveIsGivenBackAndNotNamed() throws Exception {
+ // A 200x100 picture contained in a 100pt box at the band's top has 25pt of room above it:
+ // the page draws it 12.05pt under the line above.
+ Consumer content = band(new ImageBuilder().source(picture(200, 100)).fitToBounds(100, 100)
+ .build());
+ try (XWPFDocument document = export(content)) {
+ assertThat(before(pictureIn(document))).as("12.05pt, not its 25pt of room").isEqualTo(241);
+ }
+ assertThat(DocxExports.report(400, 600, 20, content).bySubject().get("overlapping layer"))
+ .as("the picture starts below the line above, its box above it").isNull();
+ }
+
+ @Test
+ void anAlignedCaptionGivesItsMarginBackToo() throws Exception {
+ // An alignment wrapper is written as its content: measured, a centred caption with a 20pt
+ // margin stood 12.7pt low in Word and LibreOffice, with nothing named.
+ try (XWPFDocument document = export(band(new SectionBuilder().addAligned(
+ com.demcha.compose.document.node.HorizontalAlign.CENTER,
+ new ParagraphBuilder().text("Caption").margin(DocumentInsets.top(20)).build()).build()))) {
+ assertThat(before(anywhere(document, "Caption"))).isBetween(GAP - RAISE, GAP);
+ }
+ }
+
+ @Test
+ void aColumnsLaterLayerGivesItsMarginBackInItsCell() throws Exception {
+ try (XWPFDocument document = export(page -> page.addParagraph("Above").addLayerStack(stack -> stack
+ .topLeft(new SectionBuilder().padding(new DocumentInsets(0, 150, 0, 0)).addParagraph("Opening").build())
+ .topLeft(new SectionBuilder().padding(new DocumentInsets(0, 150, 0, 0))
+ .addParagraph(p -> p.text("Caption").margin(DocumentInsets.top(20))).build())
+ .topLeft(new SectionBuilder().padding(new DocumentInsets(0, 0, 0, 150)).addParagraph("Beside").build())))) {
+ assertThat(document.getTables()).as("the premise: written as columns").hasSize(1);
+ assertThat(before(anywhere(document, "Caption"))).isBetween(GAP - RAISE, GAP);
+ }
+ }
+
+ @Test
+ void aTableInALaterLayerTakesItsMarginOutOfTheGap() throws Exception {
+ try (XWPFDocument document = export(band(new TableBuilder().columns(DocumentTableColumn.auto())
+ .row("Caption").margin(DocumentInsets.top(20)).build()))) {
+ assertThat(after(anywhere(document, "Opening"))).as("the gap above the table: 7pt, not 20")
+ .isBetween(GAP - RAISE, GAP + RAISE);
+ }
+ }
+
+ @Test
+ void aListInALaterLayerKeepsItsMarginAfterTheResume() throws Exception {
+ try (XWPFDocument document = export(band(new ListBuilder().items("Caption")
+ .margin(DocumentInsets.top(20)).build()))) {
+ assertThat(before(anywhere(document, "Caption"))).as("the page's gap, its margin no longer lost")
+ .isBetween(GAP - RAISE, GAP);
+ }
+ }
+
+ @Test
+ void aListOpeningABandKeepsItsMargin() throws Exception {
+ // The band starts at the list's margin box: the space above its first item is the margin.
+ try (XWPFDocument document = export(page -> page.addParagraph("Above").addLayerStack(stack -> stack
+ .back(new ShapeBuilder().size(200, 80).build())
+ .layer(new ListBuilder().margin(DocumentInsets.top(20)).items("Title").build(), LayerAlign.TOP_LEFT)))) {
+ XWPFParagraph title = anywhere(document, "Title");
+
+ assertThat(before(title)).isBetween(400 - RAISE, 400L);
+ assertThat(title.getRuns()).as("raised into that space, if at all, not by its position")
+ .noneMatch(run -> run.getCTR().isSetRPr() && run.getCTR().getRPr().sizeOfPositionArray() > 0);
+ }
+ }
+
+ @Test
+ void aCaptionOverAPictureIsWrittenUnderItAndNamed() throws Exception {
+ // The page sets the caption over the picture's foot: Word writes it right under the picture,
+ // its 6pt margin given back, and the report names it.
+ Consumer content = page -> page.addParagraph("Above").addLayerStack(stack -> stack
+ .topLeft(new ImageBuilder().source(picture(200, 100)).size(120, 60).build())
+ .bottomRight(new ParagraphBuilder().text("Caption").margin(DocumentInsets.top(6)).build()));
+ try (XWPFDocument document = export(content)) {
+ assertThat(before(anywhere(document, "Caption"))).isZero();
+ }
+ assertThat(DocxExports.report(400, 600, 20, content).bySubject().get("overlapping layer")).isNotNull();
+ }
+
+ @Test
+ void aGapWiderThanTheMarginIsTheGap() throws Exception {
+ // Set 30pt down its stack with a 5pt margin, the caption starts 22pt under the line above.
+ try (XWPFDocument document = export(band(stack -> stack.position(new ParagraphBuilder().text("Caption")
+ .margin(DocumentInsets.top(5)).build(), 0, 30, LayerAlign.TOP_LEFT)))) {
+ assertThat(before(anywhere(document, "Caption"))).isBetween(441 - RAISE, 441L);
+ }
+ }
+
+ @Test
+ void aCaptionWithAnEdgeBelowZeroKeepsTheOldGap() throws Exception {
+ // A block takes an edge below zero out where it is written, by its own rules: the layer keeps
+ // the gap less that edge, at zero, and the caption stands right under the picture, as one
+ // with no margin does, not 4pt lower.
+ try (XWPFDocument document = export(page -> page.addParagraph("Above").addLayerStack(stack -> stack
+ .topLeft(new ImageBuilder().source(picture(200, 100)).size(120, 60).build())
+ .bottomRight(new ParagraphBuilder().text("Caption").margin(new DocumentInsets(-4, 0, 0, 0)).build())))) {
+ assertThat(before(anywhere(document, "Caption"))).isZero();
+ }
+ }
+
+ @Test
+ void aBlockInARowKeepsTheGapAndIsNotNamedForIt() throws Exception {
+ // The caption's margin is written in the row's cell, not where the layer resumes: the layer
+ // keeps the gap less the margin, at zero, and nothing is named as pulled over the line above.
+ Consumer content = band(new com.demcha.compose.document.dsl.RowBuilder()
+ .addParagraph(p -> p.text("Caption").margin(DocumentInsets.top(20))).addParagraph("Beside").build());
+ DocxExportReport report = DocxExports.report(400, 600, 20, content);
+
+ assertThat(report.bySubject().get("overlapping layer")).as("the premise: the row overlaps the line above")
+ .isNotNull();
+ assertThat(report.bySubject().get("space above")).isNull();
+ }
+
+ @Test
+ void aBlockInAPaintedPanelKeepsTheGapAndIsNamedWhereItsBoxReachesAbove() throws Exception {
+ // The panel writes the caption's 20pt padding in its cell, below where the layer resumes: the
+ // caption is written that much lower than the page sets it, and its box, which starts above
+ // the line before it, is what the report measures the overlap from.
+ Consumer content = band(new SectionBuilder()
+ .fillColor(com.demcha.compose.document.style.DocumentColor.LIGHT_GRAY)
+ .addParagraph(p -> p.text("Caption").padding(DocumentInsets.top(20))).build());
+ DocxExportReport report = DocxExports.report(400, 600, 20, content);
+
+ assertThat(report.bySubject().get("overlapping layer")).isNotNull();
+ assertThat(report.bySubject().get("space above")).isNull();
+ }
+
+ @Test
+ void aSpacerOpeningALaterLayerKeepsItsHeight() throws Exception {
+ // A spacer writes neither its margin nor its padding, so it has no edge to give back: the
+ // layer resumes at the gap less its margin, at zero, as before, the margin named with the
+ // spacer, and the spacer's 8pt stands whole above the caption.
+ Consumer content = band(new SectionBuilder()
+ .add(new com.demcha.compose.document.node.SpacerNode("Gap", 0, 8, DocumentInsets.zero(),
+ DocumentInsets.top(20)))
+ .addParagraph("Caption").build());
+ try (XWPFDocument document = export(content)) {
+ assertThat(before(anywhere(document, "Caption"))).as("the spacer's 8pt, less its hairline")
+ .isBetween(8 * 20 - 2 - RAISE, 8 * 20 - 2L);
+ }
+ assertThat(DocxExports.report(400, 600, 20, content).bySubject().get("space above")).isNull();
+ }
+
+ /** A band under a line: "Opening" at its top, then the given layer at its foot. */
+ private static Consumer band(DocumentNode later) {
+ return band(stack -> stack.bottomLeft(later));
+ }
+
+ private static Consumer band(UnaryOperator later) {
+ return page -> page.addParagraph("Above").addLayerStack(stack -> later.apply(stack
+ .topLeft(new ParagraphBuilder().text("Opening").build())));
+ }
+
+ private static XWPFDocument export(Consumer content) throws Exception {
+ return DocxExports.withLayout(400, 600, 20, content);
+ }
+
+ /** The paragraph reading {@code text}, in the body or in a table's cell. */
+ private static XWPFParagraph anywhere(XWPFDocument document, String text) {
+ for (XWPFParagraph paragraph : document.getParagraphs()) {
+ if (paragraph.getText().equals(text)) {
+ return paragraph;
+ }
+ }
+ for (XWPFTable table : document.getTables()) {
+ for (XWPFTableRow row : table.getRows()) {
+ for (XWPFTableCell cell : row.getTableCells()) {
+ for (XWPFParagraph paragraph : cell.getParagraphs()) {
+ if (paragraph.getText().equals(text)) {
+ return paragraph;
+ }
+ }
+ }
+ }
+ }
+ throw new AssertionError("no paragraph reads " + text);
+ }
+
+ private static XWPFParagraph pictureIn(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getRuns().stream().anyMatch(run -> !run.getEmbeddedPictures().isEmpty()))
+ .findFirst().orElseThrow();
+ }
+
+ private static long before(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing != null && spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0;
+ }
+
+ private static long after(XWPFParagraph paragraph) {
+ CTSpacing spacing = spacing(paragraph);
+ return spacing != null && spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0;
+ }
+
+ private static CTSpacing spacing(XWPFParagraph paragraph) {
+ return paragraph.getCTP().isSetPPr() ? paragraph.getCTP().getPPr().getSpacing() : null;
+ }
+
+ private static DocumentImageData picture(int width, int height) {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ ByteArrayOutputStream png = new ByteArrayOutputStream();
+ try {
+ javax.imageio.ImageIO.write(image, "png", png);
+ } catch (java.io.IOException failure) {
+ throw new java.io.UncheckedIOException(failure);
+ }
+ return DocumentImageData.fromBytes(png.toByteArray());
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
index bf1e211aa..52a2bf920 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemLowerTest.java
@@ -649,7 +649,7 @@ void anItemInAStackOverADrawingKeepsTheSpaceAboveIt() throws Exception {
assertThat(item.getCTP().getPPr().isSetNumPr()).as("the premise: a Word list's item").isTrue();
assertThat(0.8 * line(item) - LATO_ASCENT * 24).as("the premise: Word's baseline is the higher")
.isLessThan(-0.5);
- assertThat(before(item)).as("nothing grown above it").isZero();
+ assertThat(before(item)).as("its own 10pt margin, nothing grown above it").isEqualTo(200);
assertThat(position(item)).isZero();
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
index 1576857ff..82417ba3e 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxListItemSeatTest.java
@@ -139,13 +139,13 @@ void anItemWhoseMarkerWordDrawsRisesAsFarAsTheSpaceAboveAndNoFurther() throws Ex
@Test
void anItemOpeningABandWithNoSpaceWrittenAboveItIsRaisedByItsPosition() throws Exception {
// A band pays a raise owed below an item in it (DocxBandRaiseTest), but this item, the
- // band's first block, has no space written above it to move into: the list's margin is not
- // written in a band.
+ // band's first block, has no space written above it to move into: the band opens with it,
+ // and its list has no margin.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addParagraph(p -> p.text("Above").textStyle(LEAD))
.addLayerStack(stack -> stack.name("Stack")
.back(new ShapeBuilder().name("Backdrop").size(200, 80).build())
- .layer(new ListBuilder().noMarker().textStyle(spectral(30)).margin(DocumentInsets.top(20))
+ .layer(new ListBuilder().noMarker().textStyle(spectral(30))
.items("Title").build(), LayerAlign.TOP_LEFT)))) {
XWPFParagraph item = anywhere(document, "Title");
double raise = 0.8 * line(item) - SPECTRAL_ASCENT * 30;