From 3a88585f3d138c101c1eef62c5376e41f3c78fc1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 10:25:37 +0100 Subject: [PATCH] feat(layout): block-level horizontal alignment for fixed-size flow children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed-size flow children (paths, images, SVG icons, barcodes) left-align by default; centring one meant wrapping it in a full-width container and hand-computing the content width (the recurring friction behind the roadmap item). - HorizontalAlign (LEFT/CENTER/RIGHT) + AlignNode: fills the available width and seats its single child via the existing stack placement engine (one anchor) — no new render handler, no hot-path change - DSL: addAligned(align, node) for any fixed node, plus the icon sugar addSvgIcon(icon, width, align) - BlockAlignExample (caption=API / body=result), registered + README row - 5 tests pinning exact placement x for LEFT/CENTER/RIGHT + the icon overload --- CHANGELOG.md | 9 ++ assets/readme/examples/block-align.pdf | Bin 0 -> 1641 bytes examples/README.md | 21 +++- .../demcha/examples/GenerateAllExamples.java | 2 + .../features/layout/BlockAlignExample.java | 109 +++++++++++++++++ .../examples/support/ShowcaseMetadata.java | 2 + .../document/dsl/AbstractFlowBuilder.java | 36 ++++++ .../layout/BuiltInNodeDefinitions.java | 1 + .../layout/definitions/AlignDefinition.java | 84 +++++++++++++ .../compose/document/node/AlignNode.java | 58 +++++++++ .../document/node/HorizontalAlign.java | 19 +++ .../compose/document/dsl/BlockAlignTest.java | 110 ++++++++++++++++++ 12 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 assets/readme/examples/block-align.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java create mode 100644 src/main/java/com/demcha/compose/document/layout/definitions/AlignDefinition.java create mode 100644 src/main/java/com/demcha/compose/document/node/AlignNode.java create mode 100644 src/main/java/com/demcha/compose/document/node/HorizontalAlign.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/BlockAlignTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 07796361d..0a505cd83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ Entries land here as they merge. ### Public API +- **Block-level horizontal alignment** (`@since 1.8.0`). Fixed-size flow + children (paths, images, SVG icons, barcodes, shape containers) left-align + by default — there was no built-in way to centre or right-align one without + wrapping it in a full-width container and hand-computing the content width. + New `AlignNode` + `HorizontalAlign` (LEFT / CENTER / RIGHT) seat any node + across the available width: `flow.addAligned(HorizontalAlign.CENTER, node)` + and the icon sugar `flow.addSvgIcon(icon, width, HorizontalAlign.CENTER)`. + The wrapper fills the width and reuses the stack placement engine (one + anchor), so there is no new render handler and no hot-path change. - **Native vector charts** (`@since 1.8.0`). New `com.demcha.compose.document.chart` package with a layered, serialization-friendly API: `ChartData` (categories + series, type/colour-agnostic), sealed `ChartSpec` (`bar()` / `line()` with diff --git a/assets/readme/examples/block-align.pdf b/assets/readme/examples/block-align.pdf new file mode 100644 index 0000000000000000000000000000000000000000..5a3ec41811b9bd9047732ed19530a7f4af08914d GIT binary patch literal 1641 zcmbu9doYExTs;xrNEhluK>YCd#$eowQ?e zi)pl^tjJ|{F?7`?w{*lIa+}#D%7us;K*i48PA`e95-J{wt)Nyk4 zq1m4q;Ci^{y=!-g4s>P5xoAQ{claN#Uo^ z%@cC7V?f@=tK^d~|7~Grm>pGlJi*+}cK-PJ$dzT%{8+Q#?Loi6 z{Wu%kiq7xZi=esYOsevd;yWd{ez+vrT~%4_b z(X!eICGl06Vp2P99e+7hQQl7PdU3Lbmh^3utwrS-Ayoq%!02v@n9Z1b4vV&Tvs^vo zUUYx&qs`U!qL)%PFC?n`c2BEa^s@KT+iQ}ZMXXNtojkv~k)2s@E+*F#r$sFWpgtEl zeClpC<57ofO}6HD^J7Fx73FzkxgD}Nr#x|3b27HEM9U2JKJ~1Kby|r(l-vJke5!VW zX8zDRmKuG!v-=Y?zE%^b)~E`&U%DRcPOYkgA&&* zNMSCL$BWfDs;lXD&5W$gL*iFD-zMH3&s$K`S(vmen4bL=(il* zy|U=^LSeuxjAA>E|o9hkU1Zkk^%z zwy$ zSUveMa^f>HuTX*|j)Bchp7u@f%I;opO17VIO)}*!KgO`(;F=aQioQ(!yi3A(OQg zfvd7>a$H-Q`a!!B=dMSrRYcd2^XP4w&pb4Q!jE3qPLSCp74!kbdqW!(fxm5F)MrkoRFRBWn z896bTab8)Km9@=EAW@)nA=XC6JM5ye0n~~*Qa35d4v3;zm~`(xL8v8jWYVLYU}~b( zfvr6W_C~Si2X`1qcBq7P{an*k8KQV5vzFq#m0QfY2iI1x=8;T{<8RC>813qbI2zNP z_v3phtm@w4@zEi<-6a8u)4%W@m3RiQ$5VD}Z<`fmh;`#K=B zI3FhlgAH!fr4s-@P6RzoF=!mx7;S~Yq7YVAcsv4!L6h(p5&?@gLZUGQq9xJ_NjOZh zva&M5V(}=fF$#?#;YkDn9%D(w5-rWa|Nn&7mw$6}g8TqDB`^vCkqGgDp}tTA=!pPv zVudT{i4-?CQat`=FZ+0VFFtK;c-kOn*wmD0(IIGkD@#X9A-BP{!K5Vgmo{;}kr<0j VW3nTd;tP;y{@code + * flow.addSvgIcon(icon, 48, HorizontalAlign.CENTER); + * flow.addAligned(HorizontalAlign.RIGHT, anyFixedNode); + * } + * + * @author Artem Demchyshyn + */ +public final class BlockAlignExample { + + private static final DocumentColor INK = DocumentColor.rgb(34, 38, 50); + private static final DocumentColor TEAL = DocumentColor.rgb(20, 80, 95); + + /** Inline two-tone badge so the example needs no icon resource. */ + private static final String BADGE_SVG = """ + + + + + """; + + private BlockAlignExample() { + } + + /** + * Renders the alignment sheet: one icon and one path, each shown + * left / centre / right aligned. + * + * @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", "block-align.pdf"); + + SvgIcon icon = SvgIcon.parse(BADGE_SVG); + DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(INK); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(420, 400) + .margin(DocumentInsets.of(28)) + .create()) { + document.pageFlow(page -> { + page.addParagraph(p -> p + .text("Block alignment") + .textStyle(DocumentTextStyle.DEFAULT.withSize(20))); + page.addParagraph(p -> p + .text("A fixed-size node left-aligns in the flow by default. " + + "addSvgIcon(icon, w, align) / addAligned(align, node) seats it " + + "left, centre, or right across the content width — no manual maths.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9.5) + .withColor(DocumentColor.rgb(90, 96, 105))) + .padding(DocumentInsets.bottom(8))); + + for (HorizontalAlign align : HorizontalAlign.values()) { + page.addParagraph(p -> p + .text("addSvgIcon(icon, 44, HorizontalAlign." + align + ")") + .textStyle(caption) + .padding(DocumentInsets.top(6))); + page.addSvgIcon(icon, 44, align); + } + + page.addParagraph(p -> p + .text("addAligned(CENTER, anyNode) — works for any fixed node, e.g. a path") + .textStyle(caption) + .padding(DocumentInsets.top(10))); + page.addAligned(HorizontalAlign.CENTER, chevron()); + }); + + document.buildPdf(); + } + + return pdfFile; + } + + /** A small stroked chevron path to show alignment is not icon-specific. */ + private static com.demcha.compose.document.node.DocumentNode chevron() { + return new com.demcha.compose.document.dsl.PathBuilder() + .name("Chevron") + .size(60, 30) + .moveTo(0.0, 1.0).lineTo(0.5, 0.0).lineTo(1.0, 1.0) + .stroke(DocumentStroke.of(TEAL, 4)) + .lineCap(com.demcha.compose.document.style.DocumentLineCap.ROUND) + .lineJoin(com.demcha.compose.document.style.DocumentLineJoin.ROUND) + .build(); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 14ed92b21..f866741f4 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -97,6 +97,7 @@ record Entry(String title, String description, List tags, String codeUrl feature("shapes", "shape-container", "Shape-as-Container", "Rounded rect, ellipse, circle containers with ClipPolicy and layered children.", "shapes", "clip"); feature("svg", "svg-icon-gallery", "SVG Icon Gallery", "34 real-world multicolour svgrepo icons through SvgIcon.parse — native vector layers, the whole set 156 KB of sources.", "svg", "icons", "v1.8"); feature("shapes", "vector-path", "Vector Paths (Bézier)", "addPath(...) — free-form design shapes with native cubic Bézier curves: stroked waves, filled blobs, mixed line/curve ribbons. No tessellation.", "shapes", "bezier", "v1.8"); + feature("layout", "block-align", "Block Alignment", "addAligned(align, node) / addSvgIcon(icon, w, align) — seat any fixed-size node left / centre / right across the content width.", "layout", "align", "v1.8"); feature("transforms", "transforms", "Layers + Transforms", "rotate / scale on every leaf builder + LayerStack with explicit z-index.", "transforms", "layers"); feature("text", "rich-text-showcase", "Rich Text", "Inline runs with bold / italic / colour / link options, markdown parsing.", "text", "rich"); feature("text", "section-presets", "Section Presets", "Pre-baked section bands, accent strips, soft panels for templates.", "text", "sections"); @@ -145,6 +146,7 @@ static String groupLabel(String category, String group) { case "features/streaming" -> "Streaming & I/O"; case "features/snapshots" -> "Snapshot Testing"; case "features/svg" -> "SVG Import"; + case "features/layout" -> "Layout & Alignment"; case "features/debug" -> "Debug & Diagnostics"; case "flagships/default" -> "Flagship Demos"; default -> capitalize(group); diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index 8f7a23bd9..71dd47b56 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -559,6 +559,42 @@ public T addSvgIcon(com.demcha.compose.document.svg.SvgIcon icon, double width) return add(icon.node(width)); } + /** + * Adds a multi-layer SVG icon at the given width, horizontally aligned + * within the available content width — the one-call way to centre or + * right-align an icon on the page. + * + * @param icon parsed SVG icon + * @param width target icon width in points + * @param align horizontal placement within the available width + * @return this builder + * @since 1.8.0 + */ + @com.demcha.compose.document.api.Beta + public T addSvgIcon(com.demcha.compose.document.svg.SvgIcon icon, double width, + com.demcha.compose.document.node.HorizontalAlign align) { + Objects.requireNonNull(icon, "icon"); + return addAligned(align, icon.node(width)); + } + + /** + * Adds a node positioned horizontally within the full available content + * width — the block-level {@code margin: auto} / {@code align(center)} for + * any fixed-size node (path, image, icon, barcode, shape container) that + * would otherwise left-align in the flow. + * + * @param align horizontal placement + * @param node the node to position + * @return this builder + * @since 1.8.0 + */ + public T addAligned(com.demcha.compose.document.node.HorizontalAlign align, + com.demcha.compose.document.node.DocumentNode node) { + Objects.requireNonNull(align, "align"); + Objects.requireNonNull(node, "node"); + return add(new com.demcha.compose.document.node.AlignNode(node, align)); + } + /** * Adds a filled circle ellipse — shortcut for * {@code addEllipse(e -> e.circle(diameter).fillColor(fillColor))}. diff --git a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java index f1a8ac475..7afe5dcc2 100644 --- a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java +++ b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java @@ -44,6 +44,7 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) { .register(new CanvasLayerDefinition()) .register(new PolygonDefinition()) .register(new PathDefinition()) + .register(new AlignDefinition()) .register(new ChartDefinition()); } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/AlignDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/AlignDefinition.java new file mode 100644 index 000000000..29cfd5a47 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/definitions/AlignDefinition.java @@ -0,0 +1,84 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.*; +import com.demcha.compose.document.layout.payloads.PreparedStackLayout; +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.HorizontalAlign; +import com.demcha.compose.document.node.LayerAlign; + +import java.util.List; + +/** + * Layout definition for {@link AlignNode}: a wrapper that fills the available + * content width and seats its single child left / centre / right. It reuses + * the stack placement machinery (one layer, one anchor) — the only difference + * from a {@code LayerStackNode} is that the box is measured to the full + * available width instead of shrink-wrapping to the child, which is exactly + * what makes horizontal alignment visible against the page. + * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public final class AlignDefinition implements NodeDefinition { + + /** + * Creates the align layout definition. + */ + public AlignDefinition() { + } + + @Override + public Class nodeType() { + return AlignNode.class; + } + + @Override + public PreparedNode prepare(AlignNode node, PrepareContext ctx, BoxConstraints constraints) { + DocumentNode child = node.child(); + double childInner = Math.max(0.0, constraints.availableWidth() - child.margin().horizontal()); + PreparedNode childPrepared = ctx.prepare(child, BoxConstraints.natural(childInner)); + double height = childPrepared.measureResult().height() + child.margin().vertical(); + // Fill the width (so the anchor has room to centre / right-align); + // height tracks the child so the wrapper adds no vertical space. + return PreparedNode.composite( + node, + new MeasureResult(constraints.availableWidth(), height), + new PreparedStackLayout( + List.of(toAnchor(node.align())), + List.of(0.0), + List.of(0.0), + List.of(0)), + new CompositeLayoutSpec(0.0, CompositeLayoutSpec.Axis.STACK)); + } + + /** + * Maps a block alignment to the top-seated stack anchor. The wrapper's box + * height equals the child's, so the vertical band of the anchor is moot; + * only the horizontal placement matters. + */ + private static LayerAlign toAnchor(HorizontalAlign align) { + return switch (align) { + case LEFT -> LayerAlign.TOP_LEFT; + case CENTER -> LayerAlign.TOP_CENTER; + case RIGHT -> LayerAlign.TOP_RIGHT; + }; + } + + @Override + public PaginationPolicy paginationPolicy(AlignNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List children(AlignNode node) { + return node.children(); + } + + @Override + public List emitFragments(PreparedNode prepared, + FragmentContext ctx, + FragmentPlacement placement) { + return List.of(); + } +} diff --git a/src/main/java/com/demcha/compose/document/node/AlignNode.java b/src/main/java/com/demcha/compose/document/node/AlignNode.java new file mode 100644 index 000000000..e7178afed --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/AlignNode.java @@ -0,0 +1,58 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentInsets; + +import java.util.List; +import java.util.Objects; + +/** + * Positions a single fixed-size child horizontally within the full available + * content width — the block-level {@code margin: auto} / {@code align(center)} + * the flow does not provide for non-width-filling nodes. The wrapper fills the + * width and its height equals the child's; the child seats {@link + * HorizontalAlign#LEFT left}, {@link HorizontalAlign#CENTER centre}, or + * {@link HorizontalAlign#RIGHT right} inside it. + * + *

Use it to centre an SVG icon, a path, an image, or a barcode on the page + * without hand-computing the content width: + * {@code flow.addAligned(HorizontalAlign.CENTER, icon.node(48))}.

+ * + * @param name node name used in snapshots and layout graph paths + * @param child the node to position + * @param align horizontal placement within the available width + * @param margin outer margin around the wrapper + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public record AlignNode(String name, DocumentNode child, HorizontalAlign align, DocumentInsets margin) + implements DocumentNode { + /** + * Validates the child and alignment; normalizes name and margin. + */ + public AlignNode { + name = name == null ? "" : name; + Objects.requireNonNull(child, "child"); + Objects.requireNonNull(align, "align"); + margin = margin == null ? DocumentInsets.zero() : margin; + } + + /** + * Creates an align wrapper with no margin. + * + * @param child the node to position + * @param align horizontal placement + */ + public AlignNode(DocumentNode child, HorizontalAlign align) { + this("", child, align, DocumentInsets.zero()); + } + + @Override + public List children() { + return List.of(child); + } + + @Override + public String nodeKind() { + return "Align"; + } +} diff --git a/src/main/java/com/demcha/compose/document/node/HorizontalAlign.java b/src/main/java/com/demcha/compose/document/node/HorizontalAlign.java new file mode 100644 index 000000000..f42f0e891 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/HorizontalAlign.java @@ -0,0 +1,19 @@ +package com.demcha.compose.document.node; + +/** + * Horizontal placement of a fixed-size block within the available content + * width of a flow — the {@code margin: auto} / {@code align(center)} analogue + * for nodes that do not fill the width on their own (paths, images, icons, + * barcodes, shape containers). + * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public enum HorizontalAlign { + /** Flush with the left edge (the flow default). */ + LEFT, + /** Centred in the available width. */ + CENTER, + /** Flush with the right edge. */ + RIGHT +} diff --git a/src/test/java/com/demcha/compose/document/dsl/BlockAlignTest.java b/src/test/java/com/demcha/compose/document/dsl/BlockAlignTest.java new file mode 100644 index 000000000..232a6a6d1 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/BlockAlignTest.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.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.HorizontalAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Block-level horizontal alignment: a fixed-size node wrapped in + * {@link com.demcha.compose.document.node.AlignNode} seats LEFT / CENTER / + * RIGHT within the full content width, where the bare flow would always + * left-align it. + */ +class BlockAlignTest { + + private static final double EPS = 0.5; + private static final double PAGE = 300; + private static final double MARGIN = 20; + private static final double CONTENT = PAGE - 2 * MARGIN; // 260 + private static final double ICON = 40; + + /** Places one 40pt square path under the given alignment, returns its x. */ + private double placedX(HorizontalAlign align) { + try (DocumentSession session = GraphCompose.document() + .pageSize(PAGE, PAGE) + .margin(DocumentInsets.of(MARGIN)) + .create()) { + session.dsl().pageFlow().name("Flow") + .addAligned(align, square()) + .build(); + LayoutGraph graph = session.layoutGraph(); + PlacedNode square = graph.nodes().stream() + .filter(n -> n.path().contains("Square")) + .findFirst().orElseThrow(); + return square.placementX(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void leftKeepsTheNodeAtTheContentLeftEdge() { + assertThat(placedX(HorizontalAlign.LEFT)).isCloseTo(MARGIN, within(EPS)); + } + + @Test + void centerPlacesTheNodeInTheMiddleOfTheContentWidth() { + double expected = MARGIN + (CONTENT - ICON) / 2.0; // 20 + 110 = 130 + assertThat(placedX(HorizontalAlign.CENTER)).isCloseTo(expected, within(EPS)); + } + + @Test + void rightPushesTheNodeToTheContentRightEdge() { + double expected = MARGIN + (CONTENT - ICON); // 20 + 220 = 240 + assertThat(placedX(HorizontalAlign.RIGHT)).isCloseTo(expected, within(EPS)); + } + + @Test + void alignedDocumentRendersToPdf() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(PAGE, PAGE) + .margin(DocumentInsets.of(MARGIN)) + .create()) { + session.dsl().pageFlow().name("Flow") + .addAligned(HorizontalAlign.CENTER, square()) + .build(); + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + + @Test + void svgIconAlignOverloadCentresTheIcon() throws Exception { + var icon = com.demcha.compose.document.svg.SvgIcon.parse( + ""); + try (DocumentSession session = GraphCompose.document() + .pageSize(PAGE, PAGE) + .margin(DocumentInsets.of(MARGIN)) + .create()) { + session.dsl().pageFlow(page -> page.addSvgIcon(icon, ICON, HorizontalAlign.CENTER)); + LayoutGraph graph = session.layoutGraph(); + PlacedNode iconNode = graph.nodes().stream() + .filter(n -> n.path().contains("SvgIcon")) + .findFirst().orElseThrow(); + assertThat(iconNode.placementX()) + .isCloseTo(MARGIN + (CONTENT - ICON) / 2.0, within(EPS)); + } + } + + private static com.demcha.compose.document.node.DocumentNode square() { + return new PathBuilder() + .name("Square") + .size(ICON, ICON) + .moveTo(0, 0).lineTo(1, 0).lineTo(1, 1).lineTo(0, 1).closePath() + .fillColor(DocumentColor.rgb(20, 80, 95)) + .stroke(DocumentStroke.of(DocumentColor.rgb(0, 0, 0), 1)) + .build(); + } +}