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 000000000..5a3ec4181 Binary files /dev/null and b/assets/readme/examples/block-align.pdf differ diff --git a/examples/README.md b/examples/README.md index 35b702b03..11f7dd1f6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Install the library artifact once from the repository root: ./mvnw -DskipTests install ``` -Then run all 54 examples in one shot: +Then run all 55 examples in one shot: ```bash ./mvnw -f examples/pom.xml exec:java \ @@ -32,7 +32,7 @@ Then run all 54 examples in one shot: Generated PDFs land in `examples/target/generated-pdfs/`. The same `mvnw.cmd` form works on Windows PowerShell with backslash paths. -`GenerateAllExamples` runs **54** example programs — 16 CV + 15 +`GenerateAllExamples` runs **55** example programs — 16 CV + 15 cover-letter presets plus invoices, proposals, a schedule, the feature demos, and the flagships. The showcase site surfaces the full generated catalogue (~53 PDFs); a curated 28-PDF subset is committed under @@ -73,6 +73,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Composed table cells](#composed-table-cells-v16) | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) | | [Canvas layer (free placement)](#canvas-layer-v16) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) | | [Transforms](#transforms) | `rotate`, `scale`, and per-layer `zIndex` swap | [PDF](../assets/readme/examples/transforms.pdf) · [Source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) | +| [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) | ### 📋 Templates recommended @@ -396,6 +397,22 @@ flow.addSvgIcon(SvgIcon.parse(readResource("/icons/apple.svg")), 50); [📄 View PDF](../assets/readme/examples/svg-icon-gallery.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java) +### Block alignment + +A fixed-size node (an SVG icon, a vector path, an image) left-aligns in +the flow by default. `addSvgIcon(icon, width, HorizontalAlign.CENTER)` and +the general `addAligned(align, node)` seat it left, centre, or right across +the content width — the `margin: auto` the flow does not give fixed nodes on +its own, with no manual width maths. + +```java +flow.addSvgIcon(icon, 44, HorizontalAlign.CENTER); +flow.addAligned(HorizontalAlign.RIGHT, anyFixedNode); +``` + +[📄 View PDF](../assets/readme/examples/block-align.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.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 19697f813..abb959b3d 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -6,6 +6,7 @@ import com.demcha.examples.features.debug.DebugOverlayExample; import com.demcha.examples.features.docx.WordExportExample; import com.demcha.examples.features.chrome.PdfChromeExample; +import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.ShapeContainerExample; import com.demcha.examples.features.shapes.VectorPathExample; @@ -132,6 +133,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + ShapeContainerExample.generate()); System.out.println("Generated: " + VectorPathExample.generate()); System.out.println("Generated: " + SvgIconGalleryExample.generate()); + System.out.println("Generated: " + BlockAlignExample.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/BlockAlignExample.java b/examples/src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java new file mode 100644 index 000000000..7e1d09ccd --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java @@ -0,0 +1,109 @@ +package com.demcha.examples.features.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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 com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.svg.SvgIcon; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for v1.8 block-level horizontal alignment: a fixed-size + * node (an SVG icon, a vector path, …) placed LEFT / CENTER / RIGHT within the + * page content width with one call — the {@code margin: auto} the flow does not + * give fixed nodes on its own. + * + *
{@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(); + } +}