Skip to content

Commit cb01014

Browse files
authored
feat(api): RowBuilder.verticalAlign(...) — cross-axis row child alignment (#239)
* feat(api): RowBuilder.verticalAlign(...) — cross-axis row child alignment 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. * 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.
1 parent d0e8d1b commit cb01014

10 files changed

Lines changed: 317 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged.
1212

1313
### Public API
1414

15+
- **`RowBuilder.verticalAlign(...)` + `RowVerticalAlign`** (`@since 1.9.0`). Seats a
16+
row's children on the cross axis within the row band, whose height is that of the
17+
tallest child: `TOP` (the default), `CENTER`, or `BOTTOM` — the `align-items`
18+
analogue for a horizontal row, without manual coordinates. The measure phase is
19+
unchanged and `TOP` rows render byte-for-byte as before, so existing documents are
20+
unaffected.
21+
1522
- **`GraphCompose.documents()` + `MultiSectionDocumentBuilder` / `MultiSectionDocument`**
1623
(`@since 1.9.0`). Concatenates several independently authored `DocumentSession`
1724
sections — each with its own page size, margins, fonts, and footer numbering —
1.3 KB
Binary file not shown.

examples/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ are with the canonical DSL, then jump to its detailed section below.
7777
| [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) |
7878
| [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) |
7979
| [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) |
80+
| [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) |
8081

8182
### 📋 Templates recommended
8283

@@ -494,6 +495,23 @@ flow.addRow(r -> r.columns(auto(), weight(1), auto())
494495
[📄 View PDF](../assets/readme/examples/row-columns.pdf) ·
495496
[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java)
496497

498+
### Row vertical align
499+
500+
`RowBuilder.verticalAlign(...)` seats a row's children on the cross axis within
501+
the row band, whose height is that of the tallest child. A short label beside a
502+
large price moves from the top to the middle to the bottom of the band as the
503+
alignment changes — the `align-items` analogue for a horizontal row, no manual
504+
coordinates. `TOP` is the default, so existing rows are unchanged.
505+
506+
```java
507+
flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM)
508+
.addParagraph(bigPrice) // tallest child sets the band height
509+
.addParagraph(smallLabel)); // seated on the band bottom
510+
```
511+
512+
[📄 View PDF](../assets/readme/examples/row-vertical-align.pdf) ·
513+
[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowVerticalAlignExample.java)
514+
497515
### Advanced tables
498516

499517
`DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`.

examples/src/main/java/com/demcha/examples/GenerateAllExamples.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.demcha.examples.features.chrome.PdfChromeExample;
1010
import com.demcha.examples.features.layout.BleedExample;
1111
import com.demcha.examples.features.layout.RowColumnsExample;
12+
import com.demcha.examples.features.layout.RowVerticalAlignExample;
1213
import com.demcha.examples.features.layout.BlockAlignExample;
1314
import com.demcha.examples.features.lists.NestedListExample;
1415
import com.demcha.examples.features.shapes.LineCapExample;
@@ -157,6 +158,7 @@ public static void main(String[] args) throws Exception {
157158
System.out.println("Generated: " + BlockAlignExample.generate());
158159
System.out.println("Generated: " + BleedExample.generate());
159160
System.out.println("Generated: " + RowColumnsExample.generate());
161+
System.out.println("Generated: " + RowVerticalAlignExample.generate());
160162
System.out.println("Generated: " + TransformsExample.generate());
161163
System.out.println("Generated: " + TableAdvancedExample.generate());
162164

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.demcha.examples.features.layout;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentSession;
5+
import com.demcha.compose.document.dsl.PageFlowBuilder;
6+
import com.demcha.compose.document.node.RowVerticalAlign;
7+
import com.demcha.compose.document.style.DocumentColor;
8+
import com.demcha.compose.document.style.DocumentInsets;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.examples.support.ExampleOutputPaths;
11+
12+
import java.nio.file.Path;
13+
14+
/**
15+
* Runnable showcase for v1.9 {@code RowBuilder.verticalAlign(...)}: cross-axis
16+
* placement of a row's children within the band set by the tallest child. A
17+
* short {@code / month} label seated beside a large price moves from the top to
18+
* the middle to the bottom of the band as the alignment changes — no manual
19+
* coordinates.
20+
*
21+
* <pre>{@code
22+
* flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM)
23+
* .addParagraph(bigPrice) // tallest child -> sets the band height
24+
* .addParagraph(smallLabel)); // seated on the band bottom
25+
* }</pre>
26+
*
27+
* @author Artem Demchyshyn
28+
*/
29+
public final class RowVerticalAlignExample {
30+
31+
private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38);
32+
private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135);
33+
private static final DocumentColor BAND = DocumentColor.rgb(238, 241, 246);
34+
35+
private RowVerticalAlignExample() {
36+
}
37+
38+
/**
39+
* Renders the same price row at {@code TOP}, {@code CENTER}, and {@code BOTTOM}
40+
* vertical alignment.
41+
*
42+
* @return path to the generated PDF
43+
* @throws Exception if rendering or file IO fails
44+
*/
45+
public static Path generate() throws Exception {
46+
Path pdfFile = ExampleOutputPaths.prepare("features/layout", "row-vertical-align.pdf");
47+
48+
try (DocumentSession document = GraphCompose.document(pdfFile)
49+
.pageSize(380, 300)
50+
.margin(DocumentInsets.of(34))
51+
.create()) {
52+
document.pageFlow(page -> {
53+
page.addParagraph(p -> p.text("Row verticalAlign")
54+
.textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK)));
55+
page.addParagraph(p -> p.text("the short label is seated against the band set by the price")
56+
.textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED))
57+
.padding(DocumentInsets.bottom(10)));
58+
59+
priceRow(page, RowVerticalAlign.TOP);
60+
priceRow(page, RowVerticalAlign.CENTER);
61+
priceRow(page, RowVerticalAlign.BOTTOM);
62+
});
63+
64+
document.buildPdf();
65+
}
66+
67+
return pdfFile;
68+
}
69+
70+
private static void priceRow(PageFlowBuilder page, RowVerticalAlign align) {
71+
page.addRow(r -> r.verticalAlign(align)
72+
.fillColor(BAND)
73+
.cornerRadius(8)
74+
.padding(DocumentInsets.of(12))
75+
.gap(10)
76+
.addParagraph(p -> p.text("$49")
77+
.textStyle(DocumentTextStyle.DEFAULT.withSize(30).withColor(INK)))
78+
.addParagraph(p -> p.text("/ month · verticalAlign(" + align + ")")
79+
.textStyle(DocumentTextStyle.DEFAULT.withSize(11).withColor(MUTED))));
80+
page.addSpacer(s -> s.height(10));
81+
}
82+
83+
public static void main(String[] args) throws Exception {
84+
System.out.println("Generated: " + generate());
85+
}
86+
}

src/main/java/com/demcha/compose/document/dsl/RowBuilder.java

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public final class RowBuilder {
4242
private DocumentStroke stroke;
4343
private DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO;
4444
private DocumentBorders borders = DocumentBorders.NONE;
45+
private RowVerticalAlign verticalAlign = RowVerticalAlign.TOP;
4546

4647
/**
4748
* Creates a row builder.
@@ -179,6 +180,21 @@ public RowBuilder borders(DocumentBorders borders) {
179180
return this;
180181
}
181182

183+
/**
184+
* Sets the cross-axis (vertical) placement of the row's children within the
185+
* row band, whose height is that of the tallest child. Shorter children align
186+
* to the top, middle, or bottom without manual coordinates.
187+
*
188+
* @param verticalAlign cross-axis alignment; {@code null} resets to
189+
* {@link RowVerticalAlign#TOP}
190+
* @return this builder
191+
* @since 1.9.0
192+
*/
193+
public RowBuilder verticalAlign(RowVerticalAlign verticalAlign) {
194+
this.verticalAlign = verticalAlign == null ? RowVerticalAlign.TOP : verticalAlign;
195+
return this;
196+
}
197+
182198
/**
183199
* Replaces the per-child weights used to distribute the row's inner width.
184200
*
@@ -473,7 +489,8 @@ public RowNode build() {
473489
stroke,
474490
cornerRadius,
475491
borders,
476-
List.copyOf(columns));
492+
List.copyOf(columns),
493+
verticalAlign);
477494
}
478495

479496
private void validate() {

src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.demcha.compose.document.node.LayerStackNode;
77
import com.demcha.compose.document.node.PageBreakNode;
88
import com.demcha.compose.document.node.RowNode;
9+
import com.demcha.compose.document.node.RowVerticalAlign;
910
import com.demcha.compose.document.style.DocumentBleed;
1011
import com.demcha.compose.document.style.DocumentEdge;
1112
import com.demcha.compose.document.style.DocumentRowColumn;
@@ -518,6 +519,9 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
518519
slotWidths = distributeRowSlotWidths(children, layoutSpec.weights(),
519520
layoutSpec.spacing(), childRegionWidth);
520521
}
522+
RowVerticalAlign verticalAlign = node instanceof RowNode rowNode
523+
? rowNode.verticalAlign() : RowVerticalAlign.TOP;
524+
double bandContentHeight = naturalMeasure.height() - padding.vertical();
521525
double cursorX = placementX + padding.left();
522526

523527
for (int index = 0; index < children.size(); index++) {
@@ -539,6 +543,17 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
539543
+ "Reduce the child height, shorten its content, or increase the row height.");
540544
}
541545

546+
// Cross-axis seating within the row band. TOP yields offset 0.0,
547+
// so the band-top expression below is byte-identical to the
548+
// pre-verticalAlign placement; only CENTER/BOTTOM shift the child.
549+
// The slack is never negative: the measure phase sets the band to
550+
// the tallest child's margin-box, so bandContentHeight >=
551+
// childMargin.vertical() + childMeasure.height() for every child.
552+
double verticalOffset = verticalAlign == RowVerticalAlign.TOP
553+
? 0.0
554+
: (bandContentHeight - childMargin.vertical() - childMeasure.height())
555+
* (verticalAlign == RowVerticalAlign.CENTER ? 0.5 : 1.0);
556+
542557
if (childPrepared.isComposite()) {
543558
PlacementContext slotCtx = new FixedSlotPlacementContext(
544559
state.pageIndex, state.canvas, prepareContext, fragmentContext, nodes, fragments);
@@ -551,7 +566,7 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
551566
index,
552567
depth + 1,
553568
cursorX,
554-
rowInnerY,
569+
rowInnerY - verticalOffset,
555570
slotWidth,
556571
FixedSlotKind.ROW_SLOT,
557572
slotCtx);
@@ -561,7 +576,7 @@ private void compileHorizontalRow(PreparedNode<DocumentNode> prepared,
561576

562577
String childPath = pathFor(child, path, index);
563578
String childSemanticName = semanticName(child);
564-
double childTopY = rowInnerY - childMargin.top();
579+
double childTopY = rowInnerY - childMargin.top() - verticalOffset;
565580
double childPlacementY = childTopY - childMeasure.height();
566581
Padding childPadding = toPadding(child.padding());
567582

src/main/java/com/demcha/compose/document/node/RowNode.java

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
* @param columns optional per-child column widths (fixed / intrinsic / weight);
3333
* length must match children, or be empty. Mutually exclusive
3434
* with {@code weights}.
35+
* @param verticalAlign cross-axis placement of children within the row band
36+
* (defaults to {@link RowVerticalAlign#TOP})
3537
* @author Artem Demchyshyn
3638
*/
3739
public record RowNode(
@@ -45,7 +47,8 @@ public record RowNode(
4547
DocumentStroke stroke,
4648
DocumentCornerRadius cornerRadius,
4749
DocumentBorders borders,
48-
List<DocumentRowColumn> columns
50+
List<DocumentRowColumn> columns,
51+
RowVerticalAlign verticalAlign
4952
) implements DocumentNode {
5053
/**
5154
* Creates a normalized horizontal row container.
@@ -83,11 +86,43 @@ public record RowNode(
8386
margin = margin == null ? DocumentInsets.zero() : margin;
8487
cornerRadius = cornerRadius == null ? DocumentCornerRadius.ZERO : cornerRadius;
8588
borders = borders == null ? DocumentBorders.NONE : borders;
89+
verticalAlign = verticalAlign == null ? RowVerticalAlign.TOP : verticalAlign;
8690
if (gap < 0 || Double.isNaN(gap) || Double.isInfinite(gap)) {
8791
throw new IllegalArgumentException("gap must be finite and non-negative: " + gap);
8892
}
8993
}
9094

95+
/**
96+
* Backwards-compatible constructor without a cross-axis vertical alignment —
97+
* defaults to {@link RowVerticalAlign#TOP}.
98+
*
99+
* @param name node name used in snapshots and layout graph paths
100+
* @param children child semantic nodes in source order
101+
* @param weights optional per-child weights (length must match children, or be empty)
102+
* @param gap horizontal gap between children
103+
* @param padding inner padding
104+
* @param margin outer margin
105+
* @param fillColor optional background fill
106+
* @param stroke optional border stroke
107+
* @param cornerRadius optional render-only corner radius
108+
* @param borders optional per-side border strokes
109+
* @param columns optional per-child column widths
110+
*/
111+
public RowNode(String name,
112+
List<DocumentNode> children,
113+
List<Double> weights,
114+
double gap,
115+
DocumentInsets padding,
116+
DocumentInsets margin,
117+
DocumentColor fillColor,
118+
DocumentStroke stroke,
119+
DocumentCornerRadius cornerRadius,
120+
DocumentBorders borders,
121+
List<DocumentRowColumn> columns) {
122+
this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, columns,
123+
RowVerticalAlign.TOP);
124+
}
125+
91126
/**
92127
* Backwards-compatible constructor without per-child columns — defaults to an
93128
* empty column list (weights / even split).
@@ -113,7 +148,8 @@ public RowNode(String name,
113148
DocumentStroke stroke,
114149
DocumentCornerRadius cornerRadius,
115150
DocumentBorders borders) {
116-
this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of());
151+
this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of(),
152+
RowVerticalAlign.TOP);
117153
}
118154

119155
/**
@@ -138,6 +174,7 @@ public RowNode(String name,
138174
DocumentColor fillColor,
139175
DocumentStroke stroke,
140176
DocumentCornerRadius cornerRadius) {
141-
this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE, List.of());
177+
this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE,
178+
List.of(), RowVerticalAlign.TOP);
142179
}
143180
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.demcha.compose.document.node;
2+
3+
/**
4+
* Cross-axis (vertical) placement of a row's children within the row band, whose
5+
* height is the tallest child. The {@code align-items} analogue for a horizontal
6+
* row — line up shorter children to the top, middle, or bottom without manual
7+
* coordinates.
8+
*
9+
* @author Artem Demchyshyn
10+
* @since 1.9.0
11+
*/
12+
public enum RowVerticalAlign {
13+
/** Children sit flush with the top of the row band (the default). */
14+
TOP,
15+
/** Children are centred vertically within the row band. */
16+
CENTER,
17+
/** Children sit flush with the bottom of the row band. */
18+
BOTTOM
19+
}

0 commit comments

Comments
 (0)