From e2dbba72cae9ff6689306f622a27b71760092807 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 01:36:11 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(api):=20RowBuilder.verticalAlign(...)?= =?UTF-8?q?=20=E2=80=94=20cross-axis=20row=20child=20alignment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row sizes its band to the tallest child, but shorter children always sat flush with the band top; aligning a small label beside a large price, or an icon with a line of text, meant computing offsets by hand. verticalAlign(RowVerticalAlign.{TOP, CENTER, BOTTOM}) seats a row's children on the cross axis within the band — the align-items analogue for a horizontal row. The default is TOP, and the measure phase is untouched, so an existing row computes the same band height and places its children byte-for-byte as before; only CENTER/BOTTOM shift a child, by the band slack (accounting for the child's own margin). Applies to both leaf and container children. BASELINE is intentionally omitted for now — first-line ascent is not carried through the measure result, so it cannot be honoured without a measure-layer change; the enum admits it additively later. Tests: RowVerticalAlignTest seats a short child beside a 60pt-tall one and asserts its bottom lands 40 / 20 / 0 pt above the tall child's for TOP / CENTER / BOTTOM, covers a container child at CENTER and a child whose own bottom margin offsets BOTTOM, and confirms the default and the back-compat RowNode constructor resolve to TOP. Example: RowVerticalAlignExample (a price row at all three alignments). Full suite green, no visual baselines changed. --- CHANGELOG.md | 7 ++ assets/readme/examples/row-vertical-align.pdf | Bin 0 -> 1331 bytes examples/README.md | 18 +++ .../demcha/examples/GenerateAllExamples.java | 2 + .../layout/RowVerticalAlignExample.java | 86 ++++++++++++++ .../compose/document/dsl/RowBuilder.java | 19 ++- .../document/layout/LayoutCompiler.java | 16 ++- .../demcha/compose/document/node/RowNode.java | 43 ++++++- .../document/node/RowVerticalAlign.java | 19 +++ .../document/dsl/RowVerticalAlignTest.java | 110 ++++++++++++++++++ 10 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 assets/readme/examples/row-vertical-align.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java create mode 100644 src/main/java/com/demcha/compose/document/node/RowVerticalAlign.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/RowVerticalAlignTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index d4401594d..9d195bfae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`RowBuilder.verticalAlign(...)` + `RowVerticalAlign`** (`@since 1.9.0`). Seats a + row's children on the cross axis within the row band, whose height is that of the + tallest child: `TOP` (the default), `CENTER`, or `BOTTOM` — the `align-items` + analogue for a horizontal row, without manual coordinates. The measure phase is + unchanged and `TOP` rows render byte-for-byte as before, so existing documents are + unaffected. + - **`GraphCompose.documents()` + `MultiSectionDocumentBuilder` / `MultiSectionDocument`** (`@since 1.9.0`). Concatenates several independently authored `DocumentSession` sections — each with its own page size, margins, fonts, and footer numbering — diff --git a/assets/readme/examples/row-vertical-align.pdf b/assets/readme/examples/row-vertical-align.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ff57810afa6dcb9f4265ad5dec70c5204ddd3df2 GIT binary patch literal 1331 zcmY!laBZ^4=fsl4ocwey{jk)c;>`R! z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y zE~&}+DXAb`#U(|liMhO76?3*u*`0UWK%}kQ?x>`d1^40KtQuTbdhf0*&CWQZblU5U zQ>m1<$HndM1zo+iJXUpFaE2ay6mDJC`pv;mb!KK@W$q4cdqchiOzVvY4!Gid((eA-*&7|3YOcp zC@g4tV0&V@D-nrmibK!*c+27Lx&3w_)9TF#htW~N#Gk57BwN|@heHqE3K2>*qRV@2HXVHX9B8wW9-YT4(pESAn`w2=Z$5wfvHWu8q)B@@rt+VffAHX5wu6>WBkB+J@8{_6 zPtu&R)6jeKhEo;?S!Un%zo=?yFlW8k-otNLuHKH^_^Yt`tC!D?m$%pN>7U_K*mrV^ zG+VxjY22fK0v~_qCMlVOEfZE@l0MAiryl&+Yz{|Vp{-QA#yx#u(aRZNp=UQK%4F9Eeu1>E%>@Dv+{XqV@B>&3=r$Q&yG${V!Q}L1PRXNhK z`PDhi4L>eR-{QI$QHj#}x@x*6-clD_J;Kd=w{IX#*el;j}k2bTCOFcY7l z1u&^X(y@P1R&Yr!m%g8ZDfaXTOr%A{B??C7s3|kF?=Dx9gFtKiQya(4uk#x@G+hN8 zBQCU7BkaM-Xs_{Sw!A)(0DZ#k*u-1^-NvakIl zF2rqPTbeJ<6|x|vKa0VA)<0`U_KT;iF8fc(IcP9-f5}|my|Z%mZrDA~+O^f7>W%bo zr5TRvt|mNZTX^|%^V(Lkd;AZ~o`;`foYU?1xU`_CNoLAbw=Is@CnM#FHrzx{2(rUPZxz~8*@iT3v)vQBV!kH zV^cR{XE!5L14~y^Co@ZDLsv653r7=ICu0{kBNHxv>~tzG6XUSmN3Z)2|v!C zIB@>Jkpql}dK}IiIq`r!$(Y%YTlsP(8-q?eQzn)WD=tYaDyb*}y1>xDz=TUx)z#mP F3jpZ79TWfn literal 0 HcmV?d00001 diff --git a/examples/README.md b/examples/README.md index 3d8deb3d1..0a0cfd787 100644 --- a/examples/README.md +++ b/examples/README.md @@ -77,6 +77,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Block alignment](#block-alignment) | `addAligned(align, node)` / `addSvgIcon(icon, w, align)` — seat any fixed-size node left / centre / right across the content width | [PDF](../assets/readme/examples/block-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) | | [Content bleed](#content-bleed) | `band.bleedToEdge(TOP, LEFT, RIGHT)` / `bleed(DocumentBleed.of(...))` — a section's fill reaches the trimmed page edge while its children stay in the content margin | [PDF](../assets/readme/examples/content-bleed.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) | | [Row columns & TOC](#row-columns--toc) | `row.columns(auto(), weight(1), auto())` — size columns by content / fixed points / weight; with `line().fill()` it builds a dot-leader table of contents | [PDF](../assets/readme/examples/row-columns.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) | +| [Row vertical align](#row-vertical-align) | `row.verticalAlign(TOP / CENTER / BOTTOM)` — seat a row's children on the cross axis within the band set by the tallest child | [PDF](../assets/readme/examples/row-vertical-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java) | ### 📋 Templates recommended @@ -494,6 +495,23 @@ flow.addRow(r -> r.columns(auto(), weight(1), auto()) [📄 View PDF](../assets/readme/examples/row-columns.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) +### Row vertical align + +`RowBuilder.verticalAlign(...)` seats a row's children on the cross axis within +the row band, whose height is that of the tallest child. A short label beside a +large price moves from the top to the middle to the bottom of the band as the +alignment changes — the `align-items` analogue for a horizontal row, no manual +coordinates. `TOP` is the default, so existing rows are unchanged. + +```java +flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM) + .addParagraph(bigPrice) // tallest child sets the band height + .addParagraph(smallLabel)); // seated on the band bottom +``` + +[📄 View PDF](../assets/readme/examples/row-vertical-align.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java) + ### Advanced tables `DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`. diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 8148a5087..4f4af036d 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -9,6 +9,7 @@ import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.layout.BleedExample; import com.demcha.examples.features.layout.RowColumnsExample; +import com.demcha.examples.features.layout.RowVerticalAlignExample; import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.LineCapExample; @@ -157,6 +158,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + BlockAlignExample.generate()); System.out.println("Generated: " + BleedExample.generate()); System.out.println("Generated: " + RowColumnsExample.generate()); + System.out.println("Generated: " + RowVerticalAlignExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java b/examples/src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java new file mode 100644 index 000000000..ccb2735dd --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java @@ -0,0 +1,86 @@ +package com.demcha.examples.features.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.node.RowVerticalAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.9 {@code RowBuilder.verticalAlign(...)}: cross-axis + * placement of a row's children within the band set by the tallest child. A + * short {@code / month} label seated beside a large price moves from the top to + * the middle to the bottom of the band as the alignment changes — no manual + * coordinates. + * + *
{@code
+ * flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM)
+ *     .addParagraph(bigPrice)     // tallest child -> sets the band height
+ *     .addParagraph(smallLabel)); // seated on the band bottom
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class RowVerticalAlignExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135); + private static final DocumentColor BAND = DocumentColor.rgb(238, 241, 246); + + private RowVerticalAlignExample() { + } + + /** + * Renders the same price row at {@code TOP}, {@code CENTER}, and {@code BOTTOM} + * vertical alignment. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/layout", "row-vertical-align.pdf"); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(380, 300) + .margin(DocumentInsets.of(34)) + .create()) { + document.pageFlow(page -> { + page.addParagraph(p -> p.text("Row verticalAlign") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))); + page.addParagraph(p -> p.text("the short label is seated against the band set by the price") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED)) + .padding(DocumentInsets.bottom(10))); + + priceRow(page, RowVerticalAlign.TOP); + priceRow(page, RowVerticalAlign.CENTER); + priceRow(page, RowVerticalAlign.BOTTOM); + }); + + document.buildPdf(); + } + + return pdfFile; + } + + private static void priceRow(PageFlowBuilder page, RowVerticalAlign align) { + page.addRow(r -> r.verticalAlign(align) + .fillColor(BAND) + .cornerRadius(8) + .padding(DocumentInsets.of(12)) + .gap(10) + .addParagraph(p -> p.text("$49") + .textStyle(DocumentTextStyle.DEFAULT.withSize(30).withColor(INK))) + .addParagraph(p -> p.text("/ month · verticalAlign(" + align + ")") + .textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED)))); + page.addSpacer(s -> s.height(10)); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index cc699b753..d590c8138 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -42,6 +42,7 @@ public final class RowBuilder { private DocumentStroke stroke; private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO; private DocumentBorders borders = DocumentBorders.NONE; + private RowVerticalAlign verticalAlign = RowVerticalAlign.TOP; /** * Creates a row builder. @@ -179,6 +180,21 @@ public RowBuilder borders(DocumentBorders borders) { return this; } + /** + * Sets the cross-axis (vertical) placement of the row's children within the + * row band, whose height is that of the tallest child. Shorter children align + * to the top, middle, or bottom without manual coordinates. + * + * @param verticalAlign cross-axis alignment; {@code null} resets to + * {@link RowVerticalAlign#TOP} + * @return this builder + * @since 1.9.0 + */ + public RowBuilder verticalAlign(RowVerticalAlign verticalAlign) { + this.verticalAlign = verticalAlign == null ? RowVerticalAlign.TOP : verticalAlign; + return this; + } + /** * Replaces the per-child weights used to distribute the row's inner width. * @@ -473,7 +489,8 @@ public RowNode build() { stroke, cornerRadius, borders, - List.copyOf(columns)); + List.copyOf(columns), + verticalAlign); } private void validate() { diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 69b7ab6ff..2aa777fd5 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -6,6 +6,7 @@ import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.PageBreakNode; import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.RowVerticalAlign; import com.demcha.compose.document.style.DocumentBleed; import com.demcha.compose.document.style.DocumentEdge; import com.demcha.compose.document.style.DocumentRowColumn; @@ -518,6 +519,9 @@ private void compileHorizontalRow(PreparedNode prepared, slotWidths = distributeRowSlotWidths(children, layoutSpec.weights(), layoutSpec.spacing(), childRegionWidth); } + RowVerticalAlign verticalAlign = node instanceof RowNode rowNode + ? rowNode.verticalAlign() : RowVerticalAlign.TOP; + double bandContentHeight = naturalMeasure.height() - padding.vertical(); double cursorX = placementX + padding.left(); for (int index = 0; index < children.size(); index++) { @@ -539,6 +543,14 @@ private void compileHorizontalRow(PreparedNode prepared, + "Reduce the child height, shorten its content, or increase the row height."); } + // Cross-axis seating within the row band. TOP yields offset 0.0, + // so the band-top expression below is byte-identical to the + // pre-verticalAlign placement; only CENTER/BOTTOM shift the child. + double verticalOffset = verticalAlign == RowVerticalAlign.TOP + ? 0.0 + : (bandContentHeight - childMargin.vertical() - childMeasure.height()) + * (verticalAlign == RowVerticalAlign.CENTER ? 0.5 : 1.0); + if (childPrepared.isComposite()) { PlacementContext slotCtx = new FixedSlotPlacementContext( state.pageIndex, state.canvas, prepareContext, fragmentContext, nodes, fragments); @@ -551,7 +563,7 @@ private void compileHorizontalRow(PreparedNode prepared, index, depth + 1, cursorX, - rowInnerY, + rowInnerY - verticalOffset, slotWidth, FixedSlotKind.ROW_SLOT, slotCtx); @@ -561,7 +573,7 @@ private void compileHorizontalRow(PreparedNode prepared, String childPath = pathFor(child, path, index); String childSemanticName = semanticName(child); - double childTopY = rowInnerY - childMargin.top(); + double childTopY = rowInnerY - childMargin.top() - verticalOffset; double childPlacementY = childTopY - childMeasure.height(); Padding childPadding = toPadding(child.padding()); diff --git a/src/main/java/com/demcha/compose/document/node/RowNode.java b/src/main/java/com/demcha/compose/document/node/RowNode.java index cf6b4d408..54650e3f2 100644 --- a/src/main/java/com/demcha/compose/document/node/RowNode.java +++ b/src/main/java/com/demcha/compose/document/node/RowNode.java @@ -32,6 +32,8 @@ * @param columns optional per-child column widths (fixed / intrinsic / weight); * length must match children, or be empty. Mutually exclusive * with {@code weights}. + * @param verticalAlign cross-axis placement of children within the row band + * (defaults to {@link RowVerticalAlign#TOP}) * @author Artem Demchyshyn */ public record RowNode( @@ -45,7 +47,8 @@ public record RowNode( DocumentStroke stroke, DocumentCornerRadius cornerRadius, DocumentBorders borders, - List columns + List columns, + RowVerticalAlign verticalAlign ) implements DocumentNode { /** * Creates a normalized horizontal row container. @@ -83,11 +86,43 @@ public record RowNode( margin = margin == null ? DocumentInsets.zero() : margin; cornerRadius = cornerRadius == null ? DocumentCornerRadius.ZERO : cornerRadius; borders = borders == null ? DocumentBorders.NONE : borders; + verticalAlign = verticalAlign == null ? RowVerticalAlign.TOP : verticalAlign; if (gap < 0 || Double.isNaN(gap) || Double.isInfinite(gap)) { throw new IllegalArgumentException("gap must be finite and non-negative: " + gap); } } + /** + * Backwards-compatible constructor without a cross-axis vertical alignment — + * defaults to {@link RowVerticalAlign#TOP}. + * + * @param name node name used in snapshots and layout graph paths + * @param children child semantic nodes in source order + * @param weights optional per-child weights (length must match children, or be empty) + * @param gap horizontal gap between children + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side border strokes + * @param columns optional per-child column widths + */ + public RowNode(String name, + List children, + List weights, + double gap, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders, + List columns) { + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, columns, + RowVerticalAlign.TOP); + } + /** * Backwards-compatible constructor without per-child columns — defaults to an * empty column list (weights / even split). @@ -113,7 +148,8 @@ public RowNode(String name, DocumentStroke stroke, DocumentCornerRadius cornerRadius, DocumentBorders borders) { - this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of()); + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of(), + RowVerticalAlign.TOP); } /** @@ -138,6 +174,7 @@ public RowNode(String name, DocumentColor fillColor, DocumentStroke stroke, DocumentCornerRadius cornerRadius) { - this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE, List.of()); + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE, + List.of(), RowVerticalAlign.TOP); } } diff --git a/src/main/java/com/demcha/compose/document/node/RowVerticalAlign.java b/src/main/java/com/demcha/compose/document/node/RowVerticalAlign.java new file mode 100644 index 000000000..92fb0599c --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/RowVerticalAlign.java @@ -0,0 +1,19 @@ +package com.demcha.compose.document.node; + +/** + * Cross-axis (vertical) placement of a row's children within the row band, whose + * height is the tallest child. The {@code align-items} analogue for a horizontal + * row — line up shorter children to the top, middle, or bottom without manual + * coordinates. + * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public enum RowVerticalAlign { + /** Children sit flush with the top of the row band (the default). */ + TOP, + /** Children are centred vertically within the row band. */ + CENTER, + /** Children sit flush with the bottom of the row band. */ + BOTTOM +} diff --git a/src/test/java/com/demcha/compose/document/dsl/RowVerticalAlignTest.java b/src/test/java/com/demcha/compose/document/dsl/RowVerticalAlignTest.java new file mode 100644 index 000000000..c948c9939 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/RowVerticalAlignTest.java @@ -0,0 +1,110 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.RowVerticalAlign; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * {@code RowBuilder.verticalAlign(...)} seats a row's children on the cross axis + * within the row band (height = tallest child). A short child sitting beside a + * 60pt-tall one moves down by the band slack as the alignment changes; TOP (the + * default) leaves it flush with the band top. + */ +class RowVerticalAlignTest { + + /** Bottom (PDF y) of the named placed node. */ + private static double bottomOf(DocumentSession session, String name) { + return session.layoutGraph().nodes().stream() + .filter(n -> name.equals(n.semanticName())) + .map(PlacedNode::placementY) + .findFirst() + .orElseThrow(() -> new AssertionError(name + " not placed")); + } + + private static DocumentSession rowWith(RowVerticalAlign align) { + DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(20)) + .create(); + session.pageFlow(page -> page.addRow(row -> row.verticalAlign(align) + .addSpacer(s -> s.name("tall").size(10, 60)) + .addSpacer(s -> s.name("short").size(10, 20)))); + return session; + } + + @Test + void topAlignSeatsTheShortChildFlushWithTheBandTop() { + try (DocumentSession session = rowWith(RowVerticalAlign.TOP)) { + // Band is 60 tall; the 20-tall child sits at the top, so its bottom is + // 40 above the 60-tall child's bottom. + assertThat(bottomOf(session, "short") - bottomOf(session, "tall")).isCloseTo(40.0, within(0.5)); + } + } + + @Test + void centerAlignCentersTheShortChildInTheBand() { + try (DocumentSession session = rowWith(RowVerticalAlign.CENTER)) { + assertThat(bottomOf(session, "short") - bottomOf(session, "tall")).isCloseTo(20.0, within(0.5)); + } + } + + @Test + void bottomAlignSeatsTheShortChildOnTheBandBottom() { + try (DocumentSession session = rowWith(RowVerticalAlign.BOTTOM)) { + // Bottoms align. + assertThat(bottomOf(session, "short") - bottomOf(session, "tall")).isCloseTo(0.0, within(0.5)); + } + } + + @Test + void centerAlignAlsoSeatsCompositeChildren() { + // A section is a composite (container) row child, placed through the + // compileNodeInFixedSlot path rather than the leaf path. + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addRow(row -> row.verticalAlign(RowVerticalAlign.CENTER) + .addSpacer(s -> s.name("tall").size(10, 60)) + .addSection(sec -> sec.name("box").addSpacer(s -> s.height(20))))); + assertThat(bottomOf(session, "box") - bottomOf(session, "tall")).isCloseTo(20.0, within(0.5)); + } + } + + @Test + void bottomAlignAccountsForTheChildsOwnMargin() { + // The short child carries an 8pt bottom margin, so BOTTOM seats its + // content 8pt above the band bottom — exercising the childMargin.vertical() + // term of the slack (without it the delta would be 0). + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(page -> page.addRow(row -> row.verticalAlign(RowVerticalAlign.BOTTOM) + .addSpacer(s -> s.name("tall").size(10, 60)) + .addSpacer(s -> s.name("short").size(10, 20).margin(DocumentInsets.bottom(8))))); + assertThat(bottomOf(session, "short") - bottomOf(session, "tall")).isCloseTo(8.0, within(0.5)); + } + } + + @Test + void unalignedRowDefaultsToTop() { + RowNode row = new RowBuilder() + .addSpacer(s -> s.name("a").size(10, 10)) + .build(); + assertThat(row.verticalAlign()).isEqualTo(RowVerticalAlign.TOP); + } + + @Test + void backwardCompatibleConstructorDefaultsToTop() { + // The pre-verticalAlign 11-arg constructor still resolves and defaults TOP. + RowNode row = new RowNode("r", List.of(), List.of(), 0.0, DocumentInsets.zero(), DocumentInsets.zero(), + null, null, null, null, List.of()); + assertThat(row.verticalAlign()).isEqualTo(RowVerticalAlign.TOP); + } +} From 8b709ed5d93119fd3b5c8dd790e92be111a929dc Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 26 Jun 2026 01:45:29 +0100 Subject: [PATCH 2/2] docs(engine): note the row vertical-align slack is non-negative The cross-axis offset subtracts the child margin from the band slack; add a comment that the measure phase guarantees that slack stays non-negative (the band is the tallest child's margin-box), so the offset never lifts a child above the band top. --- .../com/demcha/compose/document/layout/LayoutCompiler.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 2aa777fd5..d0511124c 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -546,6 +546,9 @@ private void compileHorizontalRow(PreparedNode prepared, // Cross-axis seating within the row band. TOP yields offset 0.0, // so the band-top expression below is byte-identical to the // pre-verticalAlign placement; only CENTER/BOTTOM shift the child. + // The slack is never negative: the measure phase sets the band to + // the tallest child's margin-box, so bandContentHeight >= + // childMargin.vertical() + childMeasure.height() for every child. double verticalOffset = verticalAlign == RowVerticalAlign.TOP ? 0.0 : (bandContentHeight - childMargin.vertical() - childMeasure.height())