bodies = new ArrayList<>();
- if (!leading.isEmpty()) {
- bodies.add(new TextDataBody(leading, style));
- }
-
- bodies.add(new TextDataBody(String.valueOf(lineToParse.charAt(i)), style)); // '-', '*', '+'
- bodies.add(new TextDataBody(" ", style));
- bodies.addAll(markDownParser.getBody(rest, style));
- return bodies;
- }
- }
-
- // Default: parse entire line as inline markdown
- return markDownParser.getBody(lineToParse, style);
- }
-
- /**
- * bulletPrefix:
- * - if contains visible chars (e.g. "-", "•", "==="), we render it ONLY on the
- * first explicit line
- * and compute indent width based on font metrics.
- * - if whitespace-only (e.g. " "), we treat it as a plain first-line indent and
- * keep the classic
- * hanging indent for explicit "\n" continuation lines.
- *
- * softWrapIndent: used when a single line wraps by width.
- * hardWrapIndent: used for explicit "\n" continuation lines (lineIndex > 0).
- */
- private record BulletSpec(String prefix, String softWrapIndent, String hardWrapIndent) {
-
- static BulletSpec from(String bulletOffset, TextMeasurementSystem measurementSystem, TextStyle style) {
- String raw = bulletOffset == null ? "" : bulletOffset;
-
- boolean hasVisibleChars = raw.chars().anyMatch(ch -> !Character.isWhitespace(ch));
-
- if (hasVisibleChars) {
- String prefix = normalizeBulletPrefix(raw);
- String indent = computeIndentFromPrefix(measurementSystem, style, prefix);
-
- // For visible bullets, both soft-wrap and explicit "\n" continuation should
- // align under the content.
- return new BulletSpec(prefix, indent, indent);
- }
-
- // Whitespace-only: treat as a FIXED left indent for all lines.
- // This is what you want for paragraphs like "Projects" where there is no
- // visible bullet.
- // (If you want a true hanging indent, pass a visible bullet prefix like "- ",
- // "• ", "=== ", etc.)
- String prefix = raw;
- return new BulletSpec(prefix, prefix, prefix);
- }
- }
-}
-
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/BlockTextBuilderTest.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/BlockTextBuilderTest.java
deleted file mode 100644
index 3a2d1ccb2..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/BlockTextBuilderTest.java
+++ /dev/null
@@ -1,209 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.font.FontName;
-import com.demcha.compose.engine.components.content.text.BlockTextData;
-import com.demcha.compose.engine.components.content.text.BlockTextLineMetrics;
-import com.demcha.compose.engine.components.content.text.LineTextData;
-import com.demcha.compose.engine.components.content.text.TextDecoration;
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.components.layout.Anchor;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.components.style.Margin;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.engine.render.pdf.PdfFont;
-import com.demcha.compose.engine.measurement.TextMeasurementSystem;
-import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory;
-import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class BlockTextBuilderTest {
-
- private EntityManager entityManager;
-
- @BeforeEach
- void setUp() {
- entityManager = new EntityManager(PdfFontLibraryFactory.standardLibrary(), false);
- entityManager.getSystems().registerTextMeasurement(new FontLibraryTextMeasurementSystem(entityManager.getFonts(), PdfFont.class));
- entityManager.setMarkdown(true);
- }
-
- @Test
- void testComplexMarkdownText() {
- String text = "**Lead Engineer**, Nikoplast, Odessa, Ukraine | *Sep 2016 – Feb 2022*\n" +
- "- Developed strong leadership and mentoring skills, guiding a team of 8 and acting as a key point of contact for management, effectively **communicating with senior non-technical staff**.\n"
- +
- "- Championed process improvements and maintained rigorous quality protocols, demonstrating a pragmatic approach to problem-solving and delivery excellence.";
-
- TextStyle textStyle = TextStyle.builder()
- .size(10)
- .color(ComponentColor.BLACK)
- .fontName(FontName.HELVETICA)
- .decoration(TextDecoration.DEFAULT)
- .build();
-
- BlockTextBuilder builder = new BlockTextBuilder(entityManager, Align.left(1.0), textStyle);
- builder.size(new ContentSize(500, 100)); // Set a reasonable width
- builder.padding(Padding.zero());
- builder.margin(Margin.zero());
- builder.anchor(Anchor.topLeft());
-
- // Use breakLinesFromList directly or via text() method if available for list
- // Since the input is a single string with newlines, we can split it or pass as
- // list
- List textList = List.of(text);
-
- builder.text(textList, textStyle, Padding.zero(), Margin.zero());
- Entity entity = builder.build();
-
- assertTrue(entity.has(BlockTextData.class));
- BlockTextData blockTextData = entity.getComponent(BlockTextData.class).orElseThrow();
-
- assertFalse(blockTextData.lines().isEmpty(), "BlockTextData should contain lines");
-
- // Basic validation of content structure
- // We expect multiple lines due to wrapping and newlines in source text
- assertTrue(blockTextData.lines().size() > 1, "Should have multiple lines");
-
- // Verify that bold/italic parsing happened (checking if we have different
- // styles in the first line)
- // The first line starts with "**Lead Engineer**", so the first segment should
- // be bold.
- var firstLine = blockTextData.lines().get(0);
- assertFalse(firstLine.bodies().isEmpty());
-
- // Note: Exact verification depends on how MarkDownParser splits tokens and how
- // line breaking works with the font.
- // But we can check if we have at least some bold text.
- boolean hasBold = blockTextData.lines().stream()
- .flatMap(line -> line.bodies().stream())
- .anyMatch(body -> body.textStyle().decoration() == TextDecoration.BOLD
- || body.textStyle().decoration() == TextDecoration.BOLD_ITALIC);
-
- assertTrue(hasBold, "Should contain bold text segments");
-
- boolean hasItalic = blockTextData.lines().stream()
- .flatMap(line -> line.bodies().stream())
- .anyMatch(body -> body.textStyle().decoration() == TextDecoration.ITALIC
- || body.textStyle().decoration() == TextDecoration.BOLD_ITALIC);
-
- assertTrue(hasItalic, "Should contain italic text segments");
- }
-
- @Test
- void shouldIncreasePrecomputedHeightWhenMarkdownContainsHeadingLine() {
- TextStyle textStyle = TextStyle.builder()
- .size(10)
- .color(ComponentColor.BLACK)
- .fontName(FontName.HELVETICA)
- .decoration(TextDecoration.DEFAULT)
- .build();
-
- BlockTextBuilder plainBuilder = new BlockTextBuilder(entityManager, Align.left(2.0), textStyle);
- plainBuilder.size(new ContentSize(320, 100));
- plainBuilder.padding(Padding.zero());
- plainBuilder.margin(Margin.zero());
- plainBuilder.anchor(Anchor.topLeft());
- plainBuilder.text(List.of("First line\nSecond line\nThird line"), textStyle, Padding.zero(), Margin.zero());
- Entity plainEntity = plainBuilder.build();
-
- BlockTextBuilder headingBuilder = new BlockTextBuilder(entityManager, Align.left(2.0), textStyle);
- headingBuilder.size(new ContentSize(320, 100));
- headingBuilder.padding(Padding.zero());
- headingBuilder.margin(Margin.zero());
- headingBuilder.anchor(Anchor.topLeft());
- headingBuilder.text(List.of("First line\n# Section heading\nThird line"), textStyle, Padding.zero(), Margin.zero());
- Entity headingEntity = headingBuilder.build();
-
- double plainHeight = plainEntity.getComponent(ContentSize.class).orElseThrow().height();
- double headingHeight = headingEntity.getComponent(ContentSize.class).orElseThrow().height();
- BlockTextData headingBlockTextData = headingEntity.getComponent(BlockTextData.class).orElseThrow();
-
- assertTrue(headingHeight > plainHeight,
- "Heading line should enlarge ContentSize before rendering so containers reserve more height");
- assertCachedMeasurementsPresent(headingBlockTextData);
- assertTrue(headingBlockTextData.lines().get(1).lineMetrics().lineHeight()
- > headingBlockTextData.lines().get(0).lineMetrics().lineHeight(),
- "Heading line should carry taller cached metrics than the body lines");
- assertContentSizeMatchesCachedMeasurements(headingEntity, headingBlockTextData);
- }
-
- @Test
- void shouldBuildBlockTextWithoutLayoutSystemWhenMeasurementSystemIsRegistered() {
- TextStyle textStyle = TextStyle.builder()
- .size(10)
- .color(ComponentColor.BLACK)
- .fontName(FontName.HELVETICA)
- .decoration(TextDecoration.DEFAULT)
- .build();
-
- Entity entity = new BlockTextBuilder(entityManager, Align.left(2.0), textStyle)
- .size(new ContentSize(320, 100))
- .padding(Padding.zero())
- .margin(Margin.zero())
- .anchor(Anchor.topLeft())
- .text(List.of("Line one\n# Heading\nLine three"), textStyle, Padding.zero(), Margin.zero())
- .build();
-
- assertTrue(entity.getComponent(BlockTextData.class).isPresent());
- assertTrue(entity.getComponent(ContentSize.class).isPresent());
-
- BlockTextData blockTextData = entity.getComponent(BlockTextData.class).orElseThrow();
- assertCachedMeasurementsPresent(blockTextData);
- assertContentSizeMatchesCachedMeasurements(entity, blockTextData);
- }
-
- private void assertCachedMeasurementsPresent(BlockTextData blockTextData) {
- assertFalse(blockTextData.lines().isEmpty(), "Expected cached lines to be present");
-
- for (LineTextData line : blockTextData.lines()) {
- assertTrue(line.hasCachedLineWidth(), "Line width cache should be populated");
- assertTrue(line.hasCachedLineMetrics(), "Line metrics cache should be populated");
- assertTrue(line.hasCachedBaselineOffset(), "Baseline cache should be populated");
- assertEquals(line.lineMetrics().baselineOffsetFromBottom(), line.baselineOffset(), 0.0001,
- "Cached baseline should match the cached line metrics");
- }
- }
-
- private void assertContentSizeMatchesCachedMeasurements(Entity entity, BlockTextData blockTextData) {
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
- Padding padding = entity.getComponent(Padding.class).orElse(Padding.zero());
- Align align = entity.getComponent(Align.class).orElse(Align.defaultAlign(0.0));
- TextStyle style = entity.getComponent(TextStyle.class).orElse(TextStyle.DEFAULT_STYLE);
-
- double expectedWidth = blockTextData.lines().stream()
- .mapToDouble(LineTextData::lineWidth)
- .max()
- .orElse(0.0) + padding.horizontal();
-
- TextMeasurementSystem.LineMetrics baseMetrics =
- BlockTextLineMetrics.resolveStyleMetrics(entityManager, style);
-
- double expectedHeight = padding.vertical();
- List lines = blockTextData.lines();
- for (int i = 0; i < lines.size(); i++) {
- TextMeasurementSystem.LineMetrics metrics = lines.get(i).lineMetrics();
- expectedHeight += metrics.lineHeight();
- if (i < lines.size() - 1) {
- expectedHeight += BlockTextLineMetrics.interLineGap(
- metrics,
- lines.get(i + 1).lineMetrics(),
- baseMetrics,
- align.spacing());
- }
- }
-
- assertEquals(expectedWidth, contentSize.width(), 0.001,
- "Content width should be derived from cached line widths");
- assertEquals(expectedHeight, contentSize.height(), 0.001,
- "Content height should be derived from cached line metrics");
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/CircleBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/CircleBuilder.java
deleted file mode 100644
index 7a47b85e0..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/CircleBuilder.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.shape.FillColor;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.renderable.Circle;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.core.EntityManager;
-
-import java.awt.Color;
-
-public class CircleBuilder extends EmptyBox {
- CircleBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- public CircleBuilder circle(Circle circle) {
- return addComponent(circle);
- }
-
- public CircleBuilder fillColor(FillColor fillColor) {
- return addComponent(fillColor);
- }
-
- public CircleBuilder fillColor(Color fillColor) {
- return fillColor(new FillColor(fillColor));
- }
-
- public CircleBuilder fillColor(ComponentColor fillColor) {
- return fillColor(new FillColor(fillColor));
- }
-
- public CircleBuilder fillColor(int r, int g, int b) {
- return fillColor(new FillColor(new Color(r, g, b)));
- }
-
- public CircleBuilder stroke(Stroke stroke) {
- return addComponent(stroke);
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new Circle());
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ComponentBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ComponentBuilder.java
deleted file mode 100644
index a650839bb..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ComponentBuilder.java
+++ /dev/null
@@ -1,232 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.BuildEntity;
-import com.demcha.compose.engine.components.content.text.TextIndentStrategy;
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.geometry.InnerBoxSize;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.core.Canvas;
-import com.demcha.compose.engine.core.EntityManager;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-/**
- * Root builder facade for creating document entities.
- *
- * {@code ComponentBuilder} is the public entry to the engine-level builder
- * layer. Each factory method returns a concrete builder for one entity family,
- * such as text, containers, images, lines, or tables. The concrete builder
- * attaches components to a new {@code Entity}; calling {@code build()} then
- * registers that entity in the shared {@code EntityManager}.
- *
- *
- * Use this type when you want direct control over the engine rather than the
- * higher-level template layer.
- */
-public class ComponentBuilder {
- private final List builders = new ArrayList<>();
- private final EntityManager entityManager;
-
- private ComponentBuilder(EntityManager entityManager) {
- this.entityManager = Objects.requireNonNull(entityManager, "entityManager");
- }
-
- private T register(T builder) {
- builders.add(builder);
- return builder;
- }
-
- /**
- * Creates a builder for multi-line block text with wrapping and optional
- * markdown-aware tokenization.
- *
- * The resulting entity is a breakable text leaf whose content is later
- * processed by layout and pagination systems.
- */
- public BlockTextBuilder blockText(Align align, TextStyle textStyle) {
- return register(new BlockTextBuilder(entityManager, align, textStyle));
- }
-
- /**
- * Creates a block-text builder preconfigured for paragraph-like indentation.
- *
- * This convenience method applies a bullet offset and first-line indent
- * strategy before returning the builder.
- */
- public BlockTextBuilder blockTextParagraph(Align align, TextStyle textStyle, String bulletOffset) {
- BlockTextBuilder blockTextBuilder = register(new BlockTextBuilder(entityManager, align, textStyle));
- blockTextBuilder.bulletOffset(bulletOffset)
- .strategy(TextIndentStrategy.FIRST_LINE);
- return blockTextBuilder;
- }
-
- /**
- * Creates a builder for a circle leaf entity.
- */
- public CircleBuilder circle() {
- return register(new CircleBuilder(entityManager));
- }
-
- /**
- * Creates a text builder specialized for user-facing URL display text.
- */
- public DisplayUrlTextBuilder displayUrlText() {
- return register(new DisplayUrlTextBuilder(entityManager));
- }
-
- /**
- * Creates a builder for a clickable link entity.
- */
- public LinkBuilder link() {
- return register(new LinkBuilder(entityManager));
- }
-
- /**
- * Creates a builder for a fixed-size line primitive.
- */
- public LineBuilder line() {
- return register(new LineBuilder(entityManager));
- }
-
- /**
- * Creates a semantic section module aligned within its parent according to
- * the supplied {@link Align}.
- *
- * Modules resolve to the full available width of their parent minus their
- * own horizontal margins, stack children vertically, and grow primarily in
- * height as content is added.
- */
- public ModuleBuilder moduleBuilder(Align align) {
- return register(new ModuleBuilder(entityManager, align));
- }
-
- /**
- * Creates a semantic section module with a root-width seed based on a canvas
- * reference area.
- */
- public ModuleBuilder moduleBuilder(Align align, Canvas canvas) {
- return register(new ModuleBuilder(entityManager, align, canvas));
- }
-
- /**
- * Creates a semantic section module with an explicit root-width seed.
- */
- public ModuleBuilder moduleBuilder(Align align, ContentSize contentSize) {
- return register(new ModuleBuilder(entityManager, align, contentSize));
- }
-
- /**
- * Creates a semantic section module with a root-width seed based on a
- * precomputed inner box.
- */
- public ModuleBuilder moduleBuilder(Align align, InnerBoxSize innerBoxSize) {
- return register(new ModuleBuilder(entityManager, align, innerBoxSize));
- }
-
- /**
- * Creates a builder for a rectangle leaf entity.
- */
- public RectangleBuilder rectangle() {
- return register(new RectangleBuilder(entityManager));
- }
-
- /**
- * Creates a builder for a barcode or QR-code leaf entity.
- *
- * The resulting entity renders a barcode image at draw time using
- * ZXing. Like images, barcodes are fixed-size leaves positioned by
- * anchor and sized explicitly.
- */
- public BarcodeBuilder barcode() {
- return register(new BarcodeBuilder(entityManager));
- }
-
- /**
- * Creates a builder for an image leaf entity.
- */
- public ImageBuilder image() {
- return register(new ImageBuilder(entityManager));
- }
-
- /**
- * Creates a builder for single-line text or fixed text leaves.
- */
- public TextBuilder text() {
- return register(new TextBuilder(entityManager));
- }
-
- /**
- * Creates a container builder that arranges children as a row-like group.
- */
- public RowBuilder row(Align align) {
- return register(new RowBuilder(entityManager, align));
- }
-
- /**
- * Creates a horizontal container builder.
- */
- public HContainerBuilder hContainer(Align align) {
- return register(new HContainerBuilder(entityManager, align));
- }
-
- /**
- * Creates a vertical container builder.
- */
- public VContainerBuilder vContainer(Align align) {
- return register(new VContainerBuilder(entityManager, align));
- }
-
- /**
- * Creates the engine-level table builder.
- *
- * Tables materialize as a breakable vertical root with row-atomic leaf
- * entities, which keeps pagination behavior consistent with the rest of the engine.
- */
- public TableBuilder table() {
- return register(new TableBuilder(entityManager));
- }
-
- /**
- * Creates a generic element builder for low-level custom composition.
- */
- public ElementBuilder element() {
- return register(new ElementBuilder(entityManager));
- }
-
- /**
- * Creates a forced page-break entity.
- *
- * When this entity is encountered during pagination, the layout system
- * advances to the next page.
- */
- public PageBreakBuilder pageBreak() {
- return register(new PageBreakBuilder(entityManager));
- }
-
- /**
- * Creates a horizontal divider line with sensible defaults.
- *
- * This is a convenience wrapper around the line primitive. Use
- * {@code divider().width(520).thickness(1)} to customize appearance.
- */
- public DividerBuilder divider() {
- return register(new DividerBuilder(entityManager));
- }
-
- /**
- * Builds every registered builder that has not yet been materialized.
- *
- * This is primarily an orchestration helper used by composer internals to
- * ensure deferred builders are registered before layout begins.
- */
- public void buildsComponents() {
- if (builders.isEmpty())
- return;
- builders.stream()
- .filter(builder -> !builder.built())
- .forEach(BuildEntity::build);
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DisplayUrlTextBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DisplayUrlTextBuilder.java
deleted file mode 100644
index 6ba0b1066..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DisplayUrlTextBuilder.java
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.engine.components.content.link.DisplayText;
-import com.demcha.compose.engine.core.EntityManager;
-
-public class DisplayUrlTextBuilder extends TextBuilder{
-
- DisplayUrlTextBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new DisplayText());
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DividerBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DividerBuilder.java
deleted file mode 100644
index 3d18e19bc..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/DividerBuilder.java
+++ /dev/null
@@ -1,113 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.shape.LinePath;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.content.shape.StrokeColor;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.renderable.Line;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-import java.awt.Color;
-
-/**
- * Convenience builder for a horizontal divider line.
- *
- * A divider is a thin horizontal {@link Line} entity with sensible
- * defaults for color, thickness, and vertical spacing. It is a convenience
- * wrapper rather than a new entity type.
- *
- * Usage
- *
- * cb.divider()
- * .width(520)
- * .thickness(1)
- * .color(ComponentColor.LIGHT_GRAY)
- * .verticalSpacing(12)
- * .build();
- *
- *
- * @author Artem Demchyshyn
- */
-@Slf4j
-public class DividerBuilder extends EmptyBox {
-
- private double width = 0;
- private double thickness = 1;
- private Color color = ComponentColor.LIGHT_GRAY;
- private double verticalSpacing = 8;
-
- DividerBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- /**
- * Sets the width of the divider line in points.
- *
- * @param width line width
- * @return this builder
- */
- public DividerBuilder width(double width) {
- this.width = width;
- return this;
- }
-
- /**
- * Sets the line thickness (stroke width) in points.
- *
- * @param thickness stroke width
- * @return this builder
- */
- public DividerBuilder thickness(double thickness) {
- this.thickness = thickness;
- return this;
- }
-
- /**
- * Sets the divider color.
- *
- * @param color the line color
- * @return this builder
- */
- public DividerBuilder color(Color color) {
- this.color = color;
- return this;
- }
-
- /**
- * Sets the divider color from a {@link ComponentColor}.
- */
- public DividerBuilder color(ComponentColor color) {
- return color(color.color());
- }
-
- /**
- * Sets vertical spacing (padding above and below the divider line).
- *
- * @param spacing spacing in points
- * @return this builder
- */
- public DividerBuilder verticalSpacing(double spacing) {
- this.verticalSpacing = spacing;
- return this;
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new Line());
- entity.addComponent(LinePath.horizontal());
- }
-
- @Override
- public Entity build() {
- double totalHeight = thickness + verticalSpacing * 2;
- entity.addComponent(new ContentSize(width, totalHeight));
- entity.addComponent(new Stroke(new StrokeColor(color), thickness));
- entity.addComponent(new Padding(verticalSpacing, 0, verticalSpacing, 0));
- return registerBuiltEntity();
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ElementBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ElementBuilder.java
deleted file mode 100644
index 0f6725f14..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ElementBuilder.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.core.Component;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.layout.coordinator.Position;
-import com.demcha.compose.engine.components.renderable.Element;
-import com.demcha.compose.engine.core.Canvas;
-import com.demcha.compose.engine.core.EntityManager;
-
-import java.util.Optional;
-
-
-public class ElementBuilder extends EmptyBox {
- ElementBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- @Override
- public void initialize() {
- entity().addComponent(new Element());
- }
-
-
- public ElementBuilder fillPageSize(Canvas canvas) {
- return fillPageSize(canvas.width(), canvas.height());
- }
-
- public ElementBuilder fillPageSize(double width, double height) {
- float w = (float) width;
- float h = (float) height;
- // Store logical (CSS-like) top-left coordinates
- addComponent(new ContentSize(w, h));
- addComponent(new Position(0, 0)); // top-left origin for your layout system
- return this;
- }
-
- public ElementBuilder fillHorizontal(Canvas canvas, double height) {
- return fillHorizontal(canvas.width(), height);
- }
-
- public ElementBuilder fillHorizontal(double width, double height) {
- addComponent(new ContentSize(width, height));
- return this;
- }
-
- public Optional getComponent(Class clazz) {
- return entity.getComponent(clazz);
- }
-
-
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/HContainerBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/HContainerBuilder.java
deleted file mode 100644
index 88c32c20a..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/HContainerBuilder.java
+++ /dev/null
@@ -1,46 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.ContainerBuilder;
-import com.demcha.compose.engine.components.layout.StackAxis;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.components.renderable.HContainer;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Builder class for creating horizontal containers ({@link HContainer}).
- * This builder extends {@link ContainerBuilder} and specializes in arranging
- * child entities horizontally, managing their positions and calculating the
- * container's overall dimensions based on its children.
- *
- * It uses Lombok's {@code @Slf4j} for logging.
- *
- */
-@Slf4j
-public class HContainerBuilder extends ContainerBuilder {
-
- /**
- * Constructs a new {@code HContainerBuilder} associated with a specific Entity Manager.
- *
- * @param entityManager The {@link EntityManager} to which the container and its entities will belong.
- */
- HContainerBuilder(EntityManager entityManager, Align align) {
- super(entityManager, align);
- }
-
-
- /**
- * Initializes the builder for creating a new horizontal container.
- * This method calls the common creation logic from the superclass and then
- * adds the specific {@link HContainer} component to the entity being built.
- *
- * @param align The alignment strategy to be used for arranging children within the container.
- * @return This builder instance, allowing for method chaining.
- */
-
- @Override
- public void initialize() {
- entity.addComponent(new HContainer());
- entity.addComponent(StackAxis.HORIZONTAL);
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilder.java
deleted file mode 100644
index 852a1cdae..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilder.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.ImageData;
-import com.demcha.compose.engine.components.content.ImageIntrinsicSize;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.renderable.ImageComponent;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.Canvas;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-import java.nio.file.Path;
-
-@Slf4j
-public class ImageBuilder extends EmptyBox {
- private Double scaleX;
- private Double scaleY;
- private Double fitWidth;
- private Double fitHeight;
-
- ImageBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- public ImageBuilder image(ImageData imageData) {
- return addComponent(imageData);
- }
-
- public ImageBuilder image(Path path) {
- return image(ImageData.create(path));
- }
-
- public ImageBuilder image(String path) {
- return image(ImageData.create(path));
- }
-
- public ImageBuilder image(byte[] bytes) {
- return image(ImageData.create(bytes));
- }
-
- public ImageBuilder scale(double factor) {
- validatePositive("scale", factor);
- this.scaleX = factor;
- this.scaleY = factor;
- return this;
- }
-
- public ImageBuilder scaleX(double factor) {
- validatePositive("scaleX", factor);
- this.scaleX = factor;
- return this;
- }
-
- public ImageBuilder scaleY(double factor) {
- validatePositive("scaleY", factor);
- this.scaleY = factor;
- return this;
- }
-
- public ImageBuilder fitToBounds(double maxWidth, double maxHeight) {
- validatePositive("fit width", maxWidth);
- validatePositive("fit height", maxHeight);
- this.fitWidth = maxWidth;
- this.fitHeight = maxHeight;
- return this;
- }
-
- public ImageBuilder fitToPage(Canvas canvas) {
- return fitToBounds(canvas.innerWidth(), canvas.innerHeigh());
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new ImageComponent());
- }
-
- @Override
- public Entity build() {
- ImageData imageData = entity.getComponent(ImageData.class).orElseThrow(() ->
- new IllegalStateException("ImageBuilder requires ImageData before build()"));
-
- ImageIntrinsicSize intrinsicSize = imageData.getMetadata().intrinsicSize();
- entity.addComponent(intrinsicSize);
-
- if (!entity.has(ContentSize.class)) {
- Padding padding = entity.getComponent(Padding.class).orElse(Padding.zero());
- entity.addComponent(resolveContentSize(intrinsicSize, padding));
- }
-
- return registerBuiltEntity();
- }
-
- private ContentSize resolveContentSize(ImageIntrinsicSize intrinsicSize, Padding padding) {
- double drawableWidth = intrinsicSize.width();
- double drawableHeight = intrinsicSize.height();
-
- if (fitWidth != null && fitHeight != null) {
- double availableWidth = Math.max(0.0, fitWidth - padding.horizontal());
- double availableHeight = Math.max(0.0, fitHeight - padding.vertical());
-
- if (availableWidth == 0.0 || availableHeight == 0.0) {
- drawableWidth = 0.0;
- drawableHeight = 0.0;
- } else {
- double scale = Math.min(availableWidth / intrinsicSize.width(), availableHeight / intrinsicSize.height());
- drawableWidth = intrinsicSize.width() * scale;
- drawableHeight = intrinsicSize.height() * scale;
- }
- } else if (scaleX != null || scaleY != null) {
- double resolvedScaleX = scaleX != null ? scaleX : 1.0;
- double resolvedScaleY = scaleY != null ? scaleY : 1.0;
- drawableWidth = intrinsicSize.width() * resolvedScaleX;
- drawableHeight = intrinsicSize.height() * resolvedScaleY;
- }
-
- return new ContentSize(drawableWidth + padding.horizontal(), drawableHeight + padding.vertical());
- }
-
- private void validatePositive(String name, double value) {
- if (value <= 0.0 || Double.isNaN(value) || Double.isInfinite(value)) {
- throw new IllegalArgumentException(name + " must be a finite positive number: " + value);
- }
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilderTest.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilderTest.java
deleted file mode 100644
index 474ba4fec..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ImageBuilderTest.java
+++ /dev/null
@@ -1,160 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.engine.components.content.ImageIntrinsicSize;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.style.Margin;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.engine.layout.LayoutSystem;
-import com.demcha.compose.engine.render.pdf.ecs.PdfCanvas;
-import com.demcha.compose.engine.render.pdf.ecs.PdfRenderingSystemECS;
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.common.PDRectangle;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import javax.imageio.ImageIO;
-import java.awt.*;
-import java.awt.image.BufferedImage;
-import java.io.ByteArrayOutputStream;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.within;
-
-class ImageBuilderTest {
-
- private EntityManager entityManager;
-
- @BeforeEach
- void setUp() {
- entityManager = new EntityManager();
- PDDocument doc = new PDDocument();
- PdfCanvas canvas = new PdfCanvas(PDRectangle.A4, 0.0f);
- PdfRenderingSystemECS renderingSystemECS = new PdfRenderingSystemECS(doc, canvas);
- entityManager.getSystems().addSystem(new LayoutSystem(canvas, renderingSystemECS));
- entityManager.getSystems().addSystem(renderingSystemECS);
- }
-
- @Test
- void shouldUseIntrinsicSizeByDefault() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .build();
-
- ImageIntrinsicSize intrinsicSize = entity.getComponent(ImageIntrinsicSize.class).orElseThrow();
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(intrinsicSize.width()).isEqualTo(200);
- assertThat(intrinsicSize.height()).isEqualTo(100);
- assertThat(contentSize.width()).isEqualTo(200);
- assertThat(contentSize.height()).isEqualTo(100);
- }
-
- @Test
- void shouldApplyUniformScalePreservingAspectRatio() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .scale(0.5)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isCloseTo(100, within(0.001));
- assertThat(contentSize.height()).isCloseTo(50, within(0.001));
- }
-
- @Test
- void shouldScaleOnlyWidthAxis() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .scaleX(1.5)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isCloseTo(300, within(0.001));
- assertThat(contentSize.height()).isCloseTo(100, within(0.001));
- }
-
- @Test
- void shouldScaleOnlyHeightAxis() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .scaleY(2.0)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isCloseTo(200, within(0.001));
- assertThat(contentSize.height()).isCloseTo(200, within(0.001));
- }
-
- @Test
- void shouldFitWithinBoundsPreservingAspectRatio() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .fitToBounds(100, 100)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isCloseTo(100, within(0.001));
- assertThat(contentSize.height()).isCloseTo(50, within(0.001));
- }
-
- @Test
- void shouldFitToPageAndRespectPadding() {
- PdfCanvas canvas = new PdfCanvas(200, 120, 0, 0);
- canvas.addMargin(Margin.of(10));
-
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(300, 150))
- .padding(Padding.of(5))
- .fitToPage(canvas)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isCloseTo(180, within(0.001));
- assertThat(contentSize.height()).isCloseTo(95, within(0.001));
- }
-
- @Test
- void shouldPreferExplicitSizeOverFitAndScale() {
- Entity entity = new ImageBuilder(entityManager)
- .image(createPngBytes(200, 100))
- .scale(0.5)
- .fitToBounds(50, 50)
- .size(70, 40)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
-
- assertThat(contentSize.width()).isEqualTo(70);
- assertThat(contentSize.height()).isEqualTo(40);
- }
-
- private byte[] createPngBytes(int width, int height) {
- try {
- BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
- Graphics2D graphics = image.createGraphics();
- try {
- graphics.setColor(new Color(32, 40, 88));
- graphics.fillRect(0, 0, width, height);
- graphics.setColor(new Color(255, 214, 10));
- graphics.fillRect(0, 0, width / 2, height / 2);
- graphics.setColor(Color.WHITE);
- graphics.drawRect(0, 0, width - 1, height - 1);
- } finally {
- graphics.dispose();
- }
-
- ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
- ImageIO.write(image, "png", outputStream);
- return outputStream.toByteArray();
- } catch (Exception e) {
- throw new IllegalStateException("Failed to generate test PNG", e);
- }
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilder.java
deleted file mode 100644
index 6925c8aa9..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilder.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.shape.LinePath;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.renderable.Line;
-import com.demcha.compose.engine.core.EntityManager;
-
-public class LineBuilder extends EmptyBox {
- LineBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- public LineBuilder line(Line line) {
- return addComponent(line);
- }
-
- public LineBuilder stroke(Stroke stroke) {
- return addComponent(stroke);
- }
-
- public LineBuilder path(LinePath linePath) {
- return addComponent(linePath);
- }
-
- public LineBuilder path(double startX, double startY, double endX, double endY) {
- return path(new LinePath(startX, startY, endX, endY));
- }
-
- public LineBuilder horizontal() {
- return path(LinePath.horizontal());
- }
-
- public LineBuilder vertical() {
- return path(LinePath.vertical());
- }
-
- public LineBuilder diagonalAscending() {
- return path(LinePath.diagonalAscending());
- }
-
- public LineBuilder diagonalDescending() {
- return path(LinePath.diagonalDescending());
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new Line());
- entity.addComponent(LinePath.horizontal());
- entity.addComponent(new Stroke());
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilderTest.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilderTest.java
deleted file mode 100644
index d2483e0a2..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LineBuilderTest.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.engine.components.content.shape.LinePath;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.core.EntityManager;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-class LineBuilderTest {
-
- @Test
- void shouldProvideDefaultHorizontalPathAndStroke() {
- var entity = new LineBuilder(new EntityManager())
- .size(140, 10)
- .build();
-
- assertThat(entity.getComponent(LinePath.class)).hasValue(LinePath.horizontal());
- assertThat(entity.getComponent(Stroke.class))
- .hasValueSatisfying(stroke -> {
- assertThat(stroke.width()).isEqualTo(Stroke.DEFAULT_WIDTH);
- assertThat(stroke.strokeColor().color()).isEqualTo(ComponentColor.BLACK);
- });
- }
-
- @Test
- void shouldAllowOverridingPathAndStroke() {
- LinePath customPath = new LinePath(0.25, 1.0, 0.75, 0.0);
- Stroke customStroke = new Stroke(ComponentColor.ROYAL_BLUE, 4.0);
-
- var entity = new LineBuilder(new EntityManager())
- .size(120, 40)
- .vertical()
- .path(customPath)
- .stroke(customStroke)
- .build();
-
- assertThat(entity.getComponent(LinePath.class)).hasValue(customPath);
- assertThat(entity.getComponent(Stroke.class)).hasValue(customStroke);
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LinkBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LinkBuilder.java
deleted file mode 100644
index 8f4419889..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/LinkBuilder.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.link.LinkUrl;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.geometry.OuterBoxSize;
-import com.demcha.compose.engine.components.renderable.Link;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-
-public class LinkBuilder extends EmptyBox {
- LinkBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- public LinkBuilder displayText(T text) {
- Entity built = text.build();
- var contentSize = OuterBoxSize.from(built).orElseThrow();
- Padding padding = entity().getComponent(Padding.class).orElse(Padding.zero());
- size(new ContentSize(contentSize.width() + padding.horizontal(), contentSize.height() + padding.vertical()));
- addChild(built);
- return self();
- }
-
- public LinkBuilder linkUrl(T linkUrl) {
- entity().addComponent(linkUrl);
- return self();
- }
-
-
- @Override
- public void initialize() {
- entity().addComponent(new Link());
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ModuleBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ModuleBuilder.java
deleted file mode 100644
index 6058a953f..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/ModuleBuilder.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.ContainerBuilder;
-import com.demcha.compose.engine.components.layout.StackAxis;
-import com.demcha.compose.engine.components.content.bookmark.BookmarkEntry;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.geometry.InnerBoxSize;
-import com.demcha.compose.engine.components.geometry.ModuleWidthSeed;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.components.renderable.Module;
-import com.demcha.compose.engine.components.renderable.VContainer;
-import com.demcha.compose.engine.core.Canvas;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class ModuleBuilder extends ContainerBuilder {
- /**
- * Constructs a new {@code ModuleBuilder} with layout-resolved full-width
- * semantics and no explicit root-width seed.
- */
- ModuleBuilder(EntityManager entityManager, Align align) {
- super(entityManager, align);
- }
-
- /**
- * Constructs a new {@code ModuleBuilder} with a root-width seed derived from
- * the supplied content size.
- */
- ModuleBuilder(EntityManager entityManager, Align align, ContentSize contentSize) {
- this(entityManager, align);
- if (contentSize != null) {
- seedWidth(contentSize.width());
- entity.addComponent(contentSize);
- }
- }
-
- /**
- * Constructs a new {@code ModuleBuilder} with a root-width seed based on the
- * canvas inner width.
- */
- ModuleBuilder(EntityManager entityManager, Align align, Canvas canvas) {
- this(entityManager, align);
- if (canvas != null) {
- double seedWidth = canvas.innerWidth();
- seedWidth(seedWidth);
- entity.addComponent(new ContentSize(seedWidth, 0));
- }
- }
-
- /**
- * Constructs a new {@code ModuleBuilder} with a root-width seed based on a
- * precomputed inner box.
- */
- ModuleBuilder(EntityManager entityManager, Align align, InnerBoxSize innerBoxSize) {
- this(entityManager, align,
- innerBoxSize == null ? null : new ContentSize(innerBoxSize.width(), innerBoxSize.height()));
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new Module());
- entity.addComponent(StackAxis.VERTICAL);
- entity.addComponentIfAbsent(new VContainer());
- entity.addComponentIfAbsent(new ContentSize(0, 0));
- }
-
- private void seedWidth(double width) {
- entity.addComponent(new ModuleWidthSeed(Math.max(0, width)));
- }
-
- /**
- * Marks this module as a PDF bookmark entry.
- *
- * The bookmark title will appear in the PDF reader's outline panel,
- * pointing to the page and Y-position of this module.
- *
- * @param title the bookmark display title
- * @return this builder
- */
- public ModuleBuilder bookmark(String title) {
- entity.addComponent(new BookmarkEntry(title));
- return this;
- }
-
- /**
- * Marks this module as a nested PDF bookmark entry.
- *
- * @param title the bookmark display title
- * @param level nesting level (0 = root, 1 = child, etc.)
- * @return this builder
- */
- public ModuleBuilder bookmark(String title, int level) {
- entity.addComponent(new BookmarkEntry(title, level));
- return this;
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/PageBreakBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/PageBreakBuilder.java
deleted file mode 100644
index d3a3c692e..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/PageBreakBuilder.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.renderable.PageBreakComponent;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Builder for a forced page-break entity.
- *
- * The page-break entity is a zero-width, zero-height entity with a marker
- * component. The layout system should recognize this marker and advance to
- * the next page. If the page breaker does not recognize the marker, a small
- * height is used as a fallback.
- *
- * Usage
- *
- * cb.pageBreak().build();
- *
- *
- * @author Artem Demchyshyn
- */
-@Slf4j
-public class PageBreakBuilder extends EmptyBox {
-
- PageBreakBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new PageBreakComponent());
- // Use a small symbolic size — the page break is not rendered visually.
- // The actual page advance happens via the rendering pipeline which
- // recognizes the PageBreakComponent marker.
- entity.addComponent(new ContentSize(0, 1));
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RectangleBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RectangleBuilder.java
deleted file mode 100644
index 1441dd262..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RectangleBuilder.java
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.ShapeBuilderBase;
-import com.demcha.compose.engine.components.renderable.Rectangle;
-import com.demcha.compose.engine.core.EntityManager;
-
-public class RectangleBuilder extends ShapeBuilderBase {
- RectangleBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
-
- public RectangleBuilder rectangle(Rectangle rectangle) {
- return addComponent(rectangle);
- }
-
-
- @Override
- public void initialize() {
- entity.addComponent(new Rectangle());
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RowBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RowBuilder.java
deleted file mode 100644
index 273f750ae..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/RowBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.ContainerBuilder;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.components.renderable.HContainer;
-import com.demcha.compose.engine.core.EntityManager;
-
-// horizontall aligne
-public class RowBuilder extends ContainerBuilder {
-
- /**
- * Constructs a new {@code HContainerBuilder} associated with a specific Entity Manager.
- *
- * @param entityManager The {@link EntityManager} to which the container and its entities will belong.
- */
- RowBuilder(EntityManager entityManager, Align align) {
- super(entityManager,align);
- }
-
-
- /**
- * Initializes the builder for creating a new horizontal container.
- * This method calls the common creation logic from the superclass and then
- * adds the specific {@link HContainer} component to the entity being built.
- *
- * @return This builder instance, allowing for method chaining.
- */
-
-
- @Override
- public void initialize() {
- entity.addComponent(new HContainer());
- }
-
-
-}
-
-
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilder.java
deleted file mode 100644
index f6cc8e284..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilder.java
+++ /dev/null
@@ -1,557 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.ContainerBuilder;
-import com.demcha.compose.engine.components.layout.StackAxis;
-import com.demcha.compose.engine.components.content.shape.Side;
-import com.demcha.compose.engine.components.content.table.TableCellContent;
-import com.demcha.compose.engine.components.content.table.TableCellLayoutStyle;
-import com.demcha.compose.engine.components.content.table.TableColumnLayout;
-import com.demcha.compose.engine.components.content.table.TableLayoutData;
-import com.demcha.compose.engine.components.content.table.TableResolvedCell;
-import com.demcha.compose.engine.components.content.table.TableRowData;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.core.EntityName;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.components.layout.Anchor;
-import com.demcha.compose.engine.components.layout.HAnchor;
-import com.demcha.compose.engine.components.layout.VAnchor;
-import com.demcha.compose.engine.components.renderable.TableRow;
-import com.demcha.compose.engine.components.renderable.VContainer;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.engine.measurement.TextMeasurementSystem;
-
-import java.util.ArrayList;
-import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-
-/**
- * Engine-level builder for v1 tables.
- *
- * The table implementation is intentionally hybrid. The table root is a
- * breakable vertical container, while each row is materialized as an atomic
- * leaf entity carrying precomputed cell payload. That design lets the engine
- * negotiate widths once at the table level while keeping pagination row-safe.
- *
- *
- * Use this builder when you need column width negotiation, scoped row/column
- * styling, and table pagination that behaves consistently with the rest of the
- * layout engine.
- */
-public class TableBuilder extends ContainerBuilder {
- private static final double EPS = 1e-6;
-
- private final List columnSpecs = new ArrayList<>();
- private final List> rows = new ArrayList<>();
- private final Map rowStyles = new HashMap<>();
- private final Map columnStyles = new HashMap<>();
-
- private TableCellLayoutStyle defaultCellStyle;
- private Double requestedWidth;
- private boolean materialized;
-
- TableBuilder(EntityManager entityManager) {
- super(entityManager, Align.defaultAlign(0));
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new VContainer());
- entity.addComponent(StackAxis.VERTICAL);
- }
-
- public TableBuilder columns(TableColumnLayout... specs) {
- ensureMutable();
- Objects.requireNonNull(specs, "specs");
- columnSpecs.clear();
- columnSpecs.addAll(List.of(specs));
- return self();
- }
-
- public TableBuilder width(double width) {
- ensureMutable();
- if (width <= 0) {
- throw new IllegalArgumentException("Table width must be greater than 0.");
- }
- this.requestedWidth = width;
- return self();
- }
-
- public TableBuilder defaultCellStyle(TableCellLayoutStyle style) {
- ensureMutable();
- this.defaultCellStyle = Objects.requireNonNull(style, "style");
- return self();
- }
-
- public TableBuilder rowStyle(int rowIndex, TableCellLayoutStyle style) {
- ensureMutable();
- validateNonNegativeIndex(rowIndex, "Row");
- rowStyles.put(rowIndex, Objects.requireNonNull(style, "style"));
- return self();
- }
-
- public TableBuilder columnStyle(int columnIndex, TableCellLayoutStyle style) {
- ensureMutable();
- validateNonNegativeIndex(columnIndex, "Column");
- columnStyles.put(columnIndex, Objects.requireNonNull(style, "style"));
- return self();
- }
-
- public TableBuilder row(TableCellContent... cells) {
- ensureMutable();
- Objects.requireNonNull(cells, "cells");
-
- List values = new ArrayList<>(cells.length);
- for (TableCellContent cell : cells) {
- values.add(cell == null ? TableCellContent.text("") : cell);
- }
- rows.add(List.copyOf(values));
- return self();
- }
-
- public TableBuilder row(String... cells) {
- Objects.requireNonNull(cells, "cells");
-
- TableCellContent[] values = new TableCellContent[cells.length];
- for (int i = 0; i < cells.length; i++) {
- values[i] = TableCellContent.text(cells[i]);
- }
- return row(values);
- }
-
- @Override
- public Entity build() {
- if (!materialized) {
- materializeRows();
- materialized = true;
- }
- return super.build();
- }
-
- private void materializeRows() {
- validateRowsExist();
-
- int columnCount = resolveColumnCount();
- validateRowSpans(columnCount);
-
- List> logicalRows = buildLogicalRows();
- List normalizedSpecs = normalizeSpecs(columnCount);
- TableCellLayoutStyle[][] stylesGrid = buildStylesGrid(logicalRows, columnCount);
- double[] naturalWidths = resolveNaturalColumnWidths(normalizedSpecs, logicalRows, stylesGrid, columnCount);
- double naturalWidth = sum(naturalWidths);
- double[] finalWidths = resolveFinalColumnWidths(normalizedSpecs, naturalWidths, naturalWidth);
- double finalWidth = sum(finalWidths);
-
- List rowEntities = new ArrayList<>(logicalRows.size());
-
- for (int rowIndex = 0; rowIndex < logicalRows.size(); rowIndex++) {
- Entity rowEntity = buildRowEntity(rowIndex, logicalRows.get(rowIndex), stylesGrid, finalWidths);
- addChild(rowEntity);
- entityManager.putEntity(rowEntity);
- rowEntities.add(rowEntity);
- }
-
- entity.addComponent(new TableLayoutData(
- toList(finalWidths),
- naturalWidth,
- finalWidth,
- logicalRows.size(),
- columnCount,
- List.copyOf(rowEntities)));
- }
-
- private Entity buildRowEntity(int rowIndex,
- List rowCells,
- TableCellLayoutStyle[][] stylesGrid,
- double[] columnWidths) {
- double rowHeight = 0.0;
- for (LogicalCell logical : rowCells) {
- TableCellLayoutStyle style = stylesGrid[rowIndex][logical.startColumn];
- rowHeight = Math.max(rowHeight, cellNaturalHeight(logical.content, style));
- }
-
- List cells = new ArrayList<>(rowCells.size());
- double rowWidth = 0.0;
- for (LogicalCell logical : rowCells) {
- TableCellLayoutStyle style = stylesGrid[rowIndex][logical.startColumn];
- double x = sumRange(columnWidths, 0, logical.startColumn);
- double width = sumRange(columnWidths, logical.startColumn, logical.startColumn + logical.colSpan);
- cells.add(new TableResolvedCell(
- cellName(rowIndex, logical.startColumn),
- x,
- width,
- rowHeight,
- sanitizeCellLines(logical.content),
- style,
- fillInsets(stylesGrid, rowIndex, logical.startColumn, logical.colSpan),
- borderSides(stylesGrid, rowIndex, logical.startColumn, logical.colSpan)
- ));
- rowWidth = Math.max(rowWidth, x + width);
- }
-
- Entity rowEntity = new Entity();
- rowEntity.addComponent(new EntityName(rowName(rowIndex)));
- rowEntity.addComponent(new Anchor(HAnchor.LEFT, VAnchor.DEFAULT));
- rowEntity.addComponent(new ContentSize(rowWidth, rowHeight));
- rowEntity.addComponent(new TableRowData(cells));
- rowEntity.addComponent(new TableRow());
- return rowEntity;
- }
-
- private List> buildLogicalRows() {
- List> result = new ArrayList<>(rows.size());
- for (List row : rows) {
- List logical = new ArrayList<>(row.size());
- int col = 0;
- for (TableCellContent cell : row) {
- logical.add(new LogicalCell(col, cell.colSpan(), cell));
- col += cell.colSpan();
- }
- result.add(List.copyOf(logical));
- }
- return List.copyOf(result);
- }
-
- private TableCellLayoutStyle[][] buildStylesGrid(List> logicalRows, int columnCount) {
- TableCellLayoutStyle tableDefault = TableCellLayoutStyle.merge(TableCellLayoutStyle.DEFAULT, defaultCellStyle);
- TableCellLayoutStyle[][] grid = new TableCellLayoutStyle[logicalRows.size()][columnCount];
-
- for (int rowIndex = 0; rowIndex < logicalRows.size(); rowIndex++) {
- TableCellLayoutStyle rowOverride = rowStyles.get(rowIndex);
- for (LogicalCell logical : logicalRows.get(rowIndex)) {
- TableCellLayoutStyle resolved = TableCellLayoutStyle.merge(tableDefault, columnStyles.get(logical.startColumn));
- resolved = TableCellLayoutStyle.merge(resolved, rowOverride);
- resolved = TableCellLayoutStyle.merge(resolved, logical.content.styleOverride());
- for (int col = logical.startColumn; col < logical.startColumn + logical.colSpan; col++) {
- grid[rowIndex][col] = resolved;
- }
- }
- }
-
- return grid;
- }
-
- private double[] resolveNaturalColumnWidths(List normalizedSpecs,
- List> logicalRows,
- TableCellLayoutStyle[][] stylesGrid,
- int columnCount) {
- double[] widths = new double[columnCount];
- double[] singleCellRequired = new double[columnCount];
-
- for (int rowIndex = 0; rowIndex < logicalRows.size(); rowIndex++) {
- for (LogicalCell logical : logicalRows.get(rowIndex)) {
- if (logical.colSpan != 1) {
- continue;
- }
- int col = logical.startColumn;
- singleCellRequired[col] = Math.max(singleCellRequired[col],
- cellNaturalWidth(logical.content, stylesGrid[rowIndex][col]));
- }
- }
-
- for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
- TableColumnLayout spec = normalizedSpecs.get(columnIndex);
- if (spec.isFixed()) {
- if (spec.requiredFixedWidth() + EPS < singleCellRequired[columnIndex]) {
- throw new IllegalStateException("Fixed column %d width %.2f is smaller than required natural width %.2f."
- .formatted(columnIndex, spec.requiredFixedWidth(), singleCellRequired[columnIndex]));
- }
- widths[columnIndex] = spec.requiredFixedWidth();
- } else {
- widths[columnIndex] = singleCellRequired[columnIndex];
- }
- }
-
- for (int rowIndex = 0; rowIndex < logicalRows.size(); rowIndex++) {
- for (LogicalCell logical : logicalRows.get(rowIndex)) {
- if (logical.colSpan == 1) {
- continue;
- }
- int startCol = logical.startColumn;
- int endCol = startCol + logical.colSpan;
- double need = cellNaturalWidth(logical.content, stylesGrid[rowIndex][startCol]);
- double have = sumRange(widths, startCol, endCol);
- if (need <= have + EPS) {
- continue;
- }
- distributeDeficit(widths, normalizedSpecs, startCol, endCol, need - have, rowIndex, logical.colSpan);
- }
- }
-
- return widths;
- }
-
- private void distributeDeficit(double[] widths,
- List normalizedSpecs,
- int startCol,
- int endCol,
- double deficit,
- int rowIndex,
- int colSpan) {
- List autoColumns = new ArrayList<>();
- for (int col = startCol; col < endCol; col++) {
- if (normalizedSpecs.get(col).isAuto()) {
- autoColumns.add(col);
- }
- }
- if (autoColumns.isEmpty()) {
- throw new IllegalStateException("Spanned cell at row %d over %d fixed columns starting at %d requires extra width %.2f but all spanned columns are fixed."
- .formatted(rowIndex, colSpan, startCol, deficit));
- }
- double share = deficit / autoColumns.size();
- for (int col : autoColumns) {
- widths[col] += share;
- }
- }
-
- private double[] resolveFinalColumnWidths(List normalizedSpecs,
- double[] naturalWidths,
- double naturalWidth) {
- double[] finalWidths = naturalWidths.clone();
-
- if (requestedWidth == null) {
- return finalWidths;
- }
-
- if (requestedWidth + EPS < naturalWidth) {
- throw new IllegalStateException("Requested table width %.2f is smaller than natural width %.2f."
- .formatted(requestedWidth, naturalWidth));
- }
-
- double extra = requestedWidth - naturalWidth;
- if (extra <= EPS) {
- return finalWidths;
- }
-
- List autoColumns = new ArrayList<>();
- for (int i = 0; i < normalizedSpecs.size(); i++) {
- if (normalizedSpecs.get(i).isAuto()) {
- autoColumns.add(i);
- }
- }
-
- if (autoColumns.isEmpty()) {
- finalWidths[finalWidths.length - 1] += extra;
- return finalWidths;
- }
-
- double share = extra / autoColumns.size();
- for (Integer autoColumn : autoColumns) {
- finalWidths[autoColumn] += share;
- }
-
- return finalWidths;
- }
-
- private List normalizeSpecs(int columnCount) {
- List normalized = new ArrayList<>(columnCount);
- for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) {
- normalized.add(columnIndex < columnSpecs.size() ? columnSpecs.get(columnIndex) : TableColumnLayout.auto());
- }
- return normalized;
- }
-
- private int resolveColumnCount() {
- int maxRowSpanSum = 0;
- for (List row : rows) {
- int spanSum = 0;
- for (TableCellContent cell : row) {
- spanSum += cell.colSpan();
- }
- maxRowSpanSum = Math.max(maxRowSpanSum, spanSum);
- }
- return Math.max(columnSpecs.size(), maxRowSpanSum);
- }
-
- private double cellNaturalWidth(TableCellContent cell, TableCellLayoutStyle style) {
- Padding padding = style.padding() == null ? Padding.zero() : style.padding();
- double maxWidth = 0.0;
- for (String line : sanitizeCellLines(cell)) {
- maxWidth = Math.max(maxWidth, measureText(line, style).width());
- }
- return maxWidth + padding.horizontal();
- }
-
- private double cellNaturalHeight(TableCellContent cell, TableCellLayoutStyle style) {
- Padding padding = style.padding() == null ? Padding.zero() : style.padding();
- int lineCount = Math.max(1, sanitizeCellLines(cell).size());
- return (lineCount * measureLineHeight(style))
- + ((lineCount - 1) * lineSpacing(style))
- + padding.vertical();
- }
-
- private double measureLineHeight(TableCellLayoutStyle style) {
- return textMeasurementSystem().lineHeight(style.textStyle());
- }
-
- private double lineSpacing(TableCellLayoutStyle style) {
- return style.lineSpacing() == null ? 0.0 : style.lineSpacing();
- }
-
- private ContentSize measureText(String text, TableCellLayoutStyle style) {
- return textMeasurementSystem().measure(style.textStyle(), text);
- }
-
- private TextMeasurementSystem textMeasurementSystem() {
- return entityManager.getSystems()
- .textMeasurement()
- .orElseThrow(() -> new IllegalStateException("TextMeasurementSystem is required to measure table text."));
- }
-
- private EnumSet borderSides(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol, int colSpan) {
- EnumSet sides = EnumSet.of(Side.BOTTOM, Side.RIGHT);
- if (ownsTopBoundary(stylesGrid, rowIndex, startCol, colSpan)) {
- sides.add(Side.TOP);
- }
- if (ownsLeftBoundary(stylesGrid, rowIndex, startCol)) {
- sides.add(Side.LEFT);
- }
- return sides;
- }
-
- private Padding fillInsets(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol, int colSpan) {
- TableCellLayoutStyle ownStyle = stylesGrid[rowIndex][startCol];
- double ownStroke = strokeWidth(ownStyle);
- double topInset = topBoundaryStrokeWidth(stylesGrid, rowIndex, startCol, colSpan) / 2.0;
- double rightInset = ownStroke / 2.0;
- double bottomInset = ownStroke / 2.0;
- double leftInset = leftBoundaryStrokeWidth(stylesGrid, rowIndex, startCol) / 2.0;
- return new Padding(topInset, rightInset, bottomInset, leftInset);
- }
-
- private double topBoundaryStrokeWidth(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol, int colSpan) {
- if (ownsTopBoundary(stylesGrid, rowIndex, startCol, colSpan)) {
- return strokeWidth(stylesGrid[rowIndex][startCol]);
- }
- double maxAbove = 0.0;
- for (int col = startCol; col < startCol + colSpan; col++) {
- maxAbove = Math.max(maxAbove, strokeWidth(stylesGrid[rowIndex - 1][col]));
- }
- return maxAbove;
- }
-
- private double leftBoundaryStrokeWidth(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol) {
- if (ownsLeftBoundary(stylesGrid, rowIndex, startCol)) {
- return strokeWidth(stylesGrid[rowIndex][startCol]);
- }
- return strokeWidth(stylesGrid[rowIndex][startCol - 1]);
- }
-
- private boolean ownsTopBoundary(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol, int colSpan) {
- if (rowIndex == 0) {
- return true;
- }
- for (int col = startCol; col < startCol + colSpan; col++) {
- if (hasVisibleStroke(stylesGrid[rowIndex - 1][col])) {
- return false;
- }
- }
- return true;
- }
-
- private boolean ownsLeftBoundary(TableCellLayoutStyle[][] stylesGrid, int rowIndex, int startCol) {
- return startCol == 0 || !hasVisibleStroke(stylesGrid[rowIndex][startCol - 1]);
- }
-
- private double strokeWidth(TableCellLayoutStyle style) {
- if (style == null || style.stroke() == null) {
- return 0.0;
- }
- return style.stroke().width();
- }
-
- private boolean hasVisibleStroke(TableCellLayoutStyle style) {
- return strokeWidth(style) > EPS;
- }
-
- private List toList(double[] widths) {
- List result = new ArrayList<>(widths.length);
- for (double width : widths) {
- result.add(width);
- }
- return List.copyOf(result);
- }
-
- private double sum(double[] widths) {
- double total = 0.0;
- for (double width : widths) {
- total += width;
- }
- return total;
- }
-
- private double sumRange(double[] values, int fromInclusive, int toExclusive) {
- double total = 0.0;
- for (int i = fromInclusive; i < toExclusive; i++) {
- total += values[i];
- }
- return total;
- }
-
- private List sanitizeCellLines(TableCellContent cell) {
- List sanitized = new ArrayList<>(cell.lines().size());
- for (String line : cell.lines()) {
- sanitized.add(sanitizeCellLine(line));
- }
- return List.copyOf(sanitized);
- }
-
- private String sanitizeCellLine(String value) {
- if (value == null) {
- return "";
- }
- return value.replace('\r', ' ').replace('\n', ' ');
- }
-
- private String rowName(int rowIndex) {
- return tableName() + "__row_" + rowIndex;
- }
-
- private String cellName(int rowIndex, int columnIndex) {
- return rowName(rowIndex) + "__cell_" + columnIndex;
- }
-
- private String tableName() {
- return entity.getComponent(EntityName.class)
- .map(EntityName::value)
- .orElse("Table");
- }
-
- private void validateRowsExist() {
- if (rows.isEmpty()) {
- throw new IllegalStateException("Table must contain at least one row.");
- }
- }
-
- private void validateRowSpans(int columnCount) {
- for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) {
- int spanSum = 0;
- for (TableCellContent cell : rows.get(rowIndex)) {
- spanSum += cell.colSpan();
- }
- if (spanSum != columnCount) {
- throw new IllegalStateException("Row %d has cells with colSpan sum %d but table requires %d columns."
- .formatted(rowIndex, spanSum, columnCount));
- }
- }
- }
-
- private void validateNonNegativeIndex(int index, String label) {
- if (index < 0) {
- throw new IllegalArgumentException(label + " index cannot be negative.");
- }
- }
-
- private void ensureMutable() {
- if (materialized) {
- throw new IllegalStateException("Table has already been built and cannot be modified.");
- }
- }
-
- private record LogicalCell(int startColumn, int colSpan, TableCellContent content) {
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilderTest.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilderTest.java
deleted file mode 100644
index e769d3d98..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TableBuilderTest.java
+++ /dev/null
@@ -1,466 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.GraphCompose;
-import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory;
-import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem;
-import com.demcha.compose.engine.components.content.table.TableLayoutData;
-import com.demcha.compose.engine.components.content.table.TableResolvedCell;
-import com.demcha.compose.engine.components.content.table.TableRowData;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.content.table.TableCellContent;
-import com.demcha.compose.engine.components.content.table.TableCellLayoutStyle;
-import com.demcha.compose.engine.components.content.table.TableColumnLayout;
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.core.EntityName;
-import com.demcha.compose.engine.components.content.shape.Side;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.testsupport.EngineComposerHarness;
-import com.demcha.compose.engine.render.pdf.PdfFont;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-class TableBuilderTest {
-
- @Test
- void shouldComputeFixedAndAutoColumnWidths() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("MetricsTable")
- .columns(TableColumnLayout.fixed(120), TableColumnLayout.auto())
- .row("ID", "Longest visible metric label")
- .row("Code", "Label")
- .build();
-
- TableLayoutData layoutData = table.getComponent(TableLayoutData.class).orElseThrow();
- Entity firstRow = child(table, 0);
- TableRowData rowData = firstRow.getComponent(TableRowData.class).orElseThrow();
-
- assertThat(layoutData.columnWidths()).hasSize(2);
- assertThat(layoutData.columnWidths().get(0)).isEqualTo(120.0);
- assertThat(rowData.cells().get(0).width()).isEqualTo(120.0);
- assertThat(rowData.cells().get(1).width()).isEqualTo(layoutData.columnWidths().get(1));
- assertThat(layoutData.finalWidth()).isEqualTo(layoutData.columnWidths().stream().mapToDouble(Double::doubleValue).sum());
- }
- }
-
- @Test
- void shouldUseNaturalWidthWhenExplicitWidthIsNotProvided() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("NaturalWidthTable")
- .columns(TableColumnLayout.auto(), TableColumnLayout.auto())
- .row("First", "Second")
- .row("Much wider first column", "Value")
- .build();
-
- TableLayoutData layoutData = table.getComponent(TableLayoutData.class).orElseThrow();
-
- assertThat(layoutData.finalWidth()).isEqualTo(layoutData.naturalWidth());
- assertThat(layoutData.finalWidth()).isEqualTo(layoutData.columnWidths().stream().mapToDouble(Double::doubleValue).sum());
- }
- }
-
- @Test
- void shouldFailWhenRequestedWidthIsSmallerThanNaturalWidth() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- assertThatThrownBy(() -> composer.componentBuilder()
- .table()
- .columns(TableColumnLayout.auto(), TableColumnLayout.auto())
- .width(60)
- .row("Very long text", "Another long text")
- .build())
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("Requested table width");
- }
- }
-
- @Test
- void shouldApplyStylePrecedenceDefaultThenColumnThenRow() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- TableCellLayoutStyle tableDefault = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.LIGHT_GRAY)
- .padding(Padding.of(2))
- .lineSpacing(1.0)
- .build();
-
- TableCellLayoutStyle columnOverride = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.BLUE)
- .lineSpacing(2.0)
- .textStyle(TextStyle.builder()
- .fontName(TextStyle.DEFAULT_STYLE.fontName())
- .size(18)
- .decoration(TextStyle.DEFAULT_STYLE.decoration())
- .color(TextStyle.DEFAULT_STYLE.color())
- .build())
- .build();
-
- TableCellLayoutStyle rowOverride = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.RED)
- .padding(Padding.of(8))
- .build();
-
- Entity table = composer.componentBuilder()
- .table()
- .entityName("StyledTable")
- .defaultCellStyle(tableDefault)
- .columnStyle(0, columnOverride)
- .rowStyle(0, rowOverride)
- .row("A", "B")
- .row("C", "D")
- .build();
-
- TableResolvedCell firstRowFirstCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- TableResolvedCell secondRowFirstCell = child(table, 1)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- assertThat(firstRowFirstCell.style().fillColor()).isEqualTo(ComponentColor.RED);
- assertThat(firstRowFirstCell.style().padding()).isEqualTo(Padding.of(8));
- assertThat(firstRowFirstCell.style().textStyle().size()).isEqualTo(18);
- assertThat(firstRowFirstCell.style().lineSpacing()).isEqualTo(2.0);
-
- assertThat(secondRowFirstCell.style().fillColor()).isEqualTo(ComponentColor.BLUE);
- assertThat(secondRowFirstCell.style().padding()).isEqualTo(Padding.of(2));
- assertThat(secondRowFirstCell.style().textStyle().size()).isEqualTo(18);
- assertThat(secondRowFirstCell.style().lineSpacing()).isEqualTo(2.0);
- }
- }
-
- @Test
- void shouldApplyCellOverrideAfterDefaultColumnAndRowStyles() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- TableCellLayoutStyle tableDefault = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.LIGHT_GRAY)
- .padding(Padding.of(2))
- .build();
-
- TableCellLayoutStyle columnOverride = TableCellLayoutStyle.builder()
- .textStyle(TextStyle.builder()
- .fontName(TextStyle.DEFAULT_STYLE.fontName())
- .size(18)
- .decoration(TextStyle.DEFAULT_STYLE.decoration())
- .color(TextStyle.DEFAULT_STYLE.color())
- .build())
- .build();
-
- TableCellLayoutStyle rowOverride = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.RED)
- .padding(Padding.of(8))
- .build();
-
- TableCellLayoutStyle cellOverride = TableCellLayoutStyle.builder()
- .fillColor(ComponentColor.GREEN)
- .textAnchor(com.demcha.compose.engine.components.layout.Anchor.centerRight())
- .build();
-
- Entity table = composer.componentBuilder()
- .table()
- .entityName("StyledTableWithCellOverride")
- .defaultCellStyle(tableDefault)
- .columnStyle(0, columnOverride)
- .rowStyle(0, rowOverride)
- .row(
- TableCellContent.text("A").withStyle(cellOverride),
- TableCellContent.text("B"))
- .build();
-
- TableResolvedCell firstCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- assertThat(firstCell.style().fillColor()).isEqualTo(ComponentColor.GREEN);
- assertThat(firstCell.style().padding()).isEqualTo(Padding.of(8));
- assertThat(firstCell.style().textStyle().size()).isEqualTo(18);
- assertThat(firstCell.style().textAnchor()).isEqualTo(com.demcha.compose.engine.components.layout.Anchor.centerRight());
- }
- }
-
- @Test
- void shouldPreserveStringRowApiAsSingleLineCellSpec() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("StringApiTable")
- .row("Alpha", "Beta")
- .build();
-
- TableResolvedCell firstCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- assertThat(firstCell.lines()).containsExactly("Alpha");
- }
- }
-
- @Test
- void shouldMeasureMultilineCellsUsingLongestLineAndLineCount() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity multilineTable = composer.componentBuilder()
- .table()
- .entityName("MultilineTable")
- .row(TableCellContent.lines("Short", "Longest visible metric label"))
- .build();
-
- Entity longestLineTable = composer.componentBuilder()
- .table()
- .entityName("LongestLineTable")
- .row("Longest visible metric label")
- .build();
-
- TableResolvedCell multilineCell = child(multilineTable, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- TableResolvedCell singleLineCell = child(longestLineTable, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- double paddingVertical = TableCellLayoutStyle.DEFAULT.padding().vertical();
- double expectedMultilineHeight = (2 * (singleLineCell.height() - paddingVertical)) + paddingVertical;
-
- assertThat(multilineCell.width()).isEqualTo(singleLineCell.width());
- assertThat(multilineCell.height()).isEqualTo(expectedMultilineHeight);
- assertThat(multilineCell.lines()).containsExactly("Short", "Longest visible metric label");
- }
- }
-
- @Test
- void shouldIncludeCellLineSpacingInMultilineCellHeight() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- TableCellLayoutStyle style = TableCellLayoutStyle.builder()
- .padding(Padding.zero())
- .lineSpacing(2.5)
- .build();
-
- Entity multilineTable = composer.componentBuilder()
- .table()
- .entityName("MultilineLineSpacingTable")
- .defaultCellStyle(style)
- .row(TableCellContent.lines("Short", "Longer line"))
- .build();
-
- Entity singleLineTable = composer.componentBuilder()
- .table()
- .entityName("SingleLineSpacingTable")
- .defaultCellStyle(style)
- .row("Short")
- .build();
-
- TableResolvedCell multilineCell = child(multilineTable, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
- TableResolvedCell singleLineCell = child(singleLineTable, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- assertThat(multilineCell.height()).isEqualTo((2 * singleLineCell.height()) + 2.5);
- }
- }
-
- @Test
- void shouldBuildWithoutLayoutSystemWhenTextMeasurementSystemIsRegistered() {
- EntityManager entityManager = new EntityManager(PdfFontLibraryFactory.standardLibrary(), false);
- entityManager.getSystems().registerTextMeasurement(new FontLibraryTextMeasurementSystem(entityManager.getFonts(), PdfFont.class));
-
- Entity table = new TableBuilder(entityManager)
- .entityName("MeasurementOnlyTable")
- .row("Alpha", "Beta")
- .build();
-
- TableLayoutData layoutData = table.getComponent(TableLayoutData.class).orElseThrow();
- assertThat(layoutData.columnWidths()).hasSize(2);
- assertThat(layoutData.finalWidth()).isGreaterThan(0.0);
- }
-
- @Test
- void shouldRejectEmptyTable() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- assertThatThrownBy(() -> composer.componentBuilder()
- .table()
- .entityName("EmptyTable")
- .build())
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("at least one row");
- }
- }
-
- @Test
- void shouldRejectRowsThatDoNotMatchResolvedColumnCount() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- assertThatThrownBy(() -> composer.componentBuilder()
- .table()
- .columns(TableColumnLayout.fixed(120), TableColumnLayout.auto())
- .row("Only one cell")
- .build())
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("requires 2 columns");
- }
- }
-
- @Test
- void shouldCreateStableRowAndCellNamesFromTableName() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("Orders")
- .row("A", "B")
- .build();
-
- Entity row = child(table, 0);
- TableResolvedCell cell = row.getComponent(TableRowData.class).orElseThrow().cells().get(0);
-
- assertThat(row.getComponent(EntityName.class)).hasValue(new EntityName("Orders__row_0"));
- assertThat(cell.name()).isEqualTo("Orders__row_0__cell_0");
- }
- }
-
- @Test
- void shouldAssignBordersToCurrentCellsSoTrailingPageLinesStayVisible() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("BorderOwnership")
- .row("A", "B")
- .row("C", "D")
- .build();
-
- TableResolvedCell firstRowFirstCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- TableResolvedCell firstRowSecondCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(1);
-
- TableResolvedCell secondRowSecondCell = child(table, 1)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(1);
-
- assertThat(firstRowFirstCell.borderSides()).containsExactlyInAnyOrder(Side.TOP, Side.LEFT, Side.RIGHT, Side.BOTTOM);
- assertThat(firstRowSecondCell.borderSides()).containsExactlyInAnyOrder(Side.TOP, Side.RIGHT, Side.BOTTOM);
- assertThat(secondRowSecondCell.borderSides()).containsExactlyInAnyOrder(Side.RIGHT, Side.BOTTOM);
- }
- }
-
- @Test
- void shouldLetCellOwnLeftBoundaryWhenInvisibleSpacerPrecedesIt() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("InvisibleSpacerBoundary")
- .defaultCellStyle(TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.BLACK, 2.0))
- .build())
- .columnStyle(1, TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.WHITE, 0.0))
- .build())
- .row("Notes", "", "Total")
- .build();
-
- TableResolvedCell summaryCell = child(table, 0)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(2);
-
- assertThat(summaryCell.borderSides()).containsExactlyInAnyOrder(Side.TOP, Side.LEFT, Side.RIGHT, Side.BOTTOM);
- assertThat(summaryCell.fillInsets().left()).isEqualTo(1.0);
- }
- }
-
- @Test
- void shouldLetCellOwnTopBoundaryWhenPreviousRowStrokeIsInvisible() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("InvisiblePreviousRowBoundary")
- .defaultCellStyle(TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.BLACK, 2.0))
- .build())
- .rowStyle(0, TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.WHITE, 0.0))
- .build())
- .row("Notes", "Total")
- .row("Terms", "Paid")
- .build();
-
- TableResolvedCell secondRowCell = child(table, 1)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(0);
-
- assertThat(secondRowCell.borderSides()).containsExactlyInAnyOrder(Side.TOP, Side.LEFT, Side.RIGHT, Side.BOTTOM);
- assertThat(secondRowCell.fillInsets().top()).isEqualTo(1.0);
- }
- }
-
- @Test
- void shouldInsetFillUsingOwningNeighborStrokeWidths() throws Exception {
- try (EngineComposerHarness composer = com.demcha.compose.testsupport.EngineComposerHarness.pdf().create()) {
- Entity table = composer.componentBuilder()
- .table()
- .entityName("FillInsets")
- .defaultCellStyle(TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.BLACK, 1.0))
- .build())
- .rowStyle(0, TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.BLACK, 2.0))
- .build())
- .columnStyle(0, TableCellLayoutStyle.builder()
- .stroke(new Stroke(ComponentColor.BLACK, 4.0))
- .build())
- .row("A", "B")
- .row("C", "D")
- .build();
-
- TableResolvedCell secondRowSecondCell = child(table, 1)
- .getComponent(TableRowData.class)
- .orElseThrow()
- .cells()
- .get(1);
-
- assertThat(secondRowSecondCell.fillInsets().top()).isEqualTo(1.0);
- assertThat(secondRowSecondCell.fillInsets().left()).isEqualTo(2.0);
- assertThat(secondRowSecondCell.fillInsets().right()).isEqualTo(0.5);
- assertThat(secondRowSecondCell.fillInsets().bottom()).isEqualTo(0.5);
- }
- }
-
- private Entity child(Entity parent, int index) {
- TableLayoutData layoutData = parent.getComponent(TableLayoutData.class).orElseThrow();
- return layoutData.rowEntities().get(index);
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilder.java
deleted file mode 100644
index 32ec54d69..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilder.java
+++ /dev/null
@@ -1,111 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.testsupport.engine.assembly.container.EmptyBox;
-import com.demcha.compose.engine.components.content.text.Text;
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.renderable.TextComponent;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.engine.exceptions.TextComponentException;
-import lombok.SneakyThrows;
-import lombok.experimental.Accessors;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Builder for single text entities.
- *
- * {@code TextBuilder} produces a leaf entity rendered by {@code TextComponent}.
- * It is the simplest text path in the engine and is a good fit for labels,
- * short headings, metadata fields, and any text that should stay as one entity.
- *
- *
- * If auto-size is enabled, the builder measures the text before registration
- * and writes a {@code ContentSize} component that later drives layout.
- */
-@Slf4j
-@Accessors(fluent = true)
-public class TextBuilder extends EmptyBox {
- private boolean autosize;
-
- TextBuilder(EntityManager entityManager) {
- super(entityManager);
- }
-
- /**
- * Sets the text payload explicitly.
- */
- public TextBuilder text(Text textComponent) {
- return addComponent(textComponent);
- }
-
- /**
- * Sets the text payload from a raw string.
- */
- public TextBuilder text(String text) {
- return addComponent(new Text(text));
- }
-
- /**
- * Sets the text style consumed later by measurement and rendering.
- */
- public TextBuilder textStyle(TextStyle textStyle) {
- return addComponent(textStyle);
- }
-
-
- /**
- * Sets the text payload and asks the builder to measure the final content size.
- */
- public TextBuilder textWithAutoSize(Text textComponent) {
- autosize = true;
- return addComponent(textComponent);
-
- }
-
- /**
- * Sets the text payload from a string and asks the builder to measure the
- * final content size.
- */
- public TextBuilder textWithAutoSize(String text) {
- autosize = true;
- return addComponent(new Text(text));
-
- }
-
- @Override
- public void initialize() {
- entity.addComponent(new TextComponent());
- }
-
- /**
- * Finalizes the entity and optionally measures text before registration.
- *
- * After this call the entity is ready for layout. The layout system will
- * later compute placement from the measured size, margin, padding, anchor,
- * and parent context.
- */
- @SneakyThrows
- @Override
- @SuppressWarnings("unchecked")
- public Entity build() {
- if (entity.hasAssignable(TextComponent.class)) {
- if (autosize) {
- Padding padding = entity.getComponent(Padding.class).orElse(Padding.zero());
- ContentSize measuredText = TextComponent.autoMeasureText(entity, entityManager);
- double textHeight = measuredText.height() + padding.vertical();
- double textWidth = measuredText.width() + padding.horizontal();
-
- log.debug("Autosized single-line text entity {} -> measuredText={} padding={} finalSize=({}, {})",
- entity, measuredText, padding, textWidth, textHeight);
-
- entity.addComponent(new ContentSize(textWidth, textHeight));
- }
- return registerBuiltEntity();
- } else {
- log.error("TextComponent Component has not been initialized");
- throw new TextComponentException(TextComponent.class + " Component has not been initialized");
- }
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilderTest.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilderTest.java
deleted file mode 100644
index 47f2d6816..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/TextBuilderTest.java
+++ /dev/null
@@ -1,95 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-
-import com.demcha.compose.document.backend.fixed.pdf.PdfFontLibraryFactory;
-import com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem;
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.renderable.TextComponent;
-import com.demcha.compose.engine.components.style.Padding;
-import com.demcha.compose.engine.core.EntityManager;
-import com.demcha.compose.engine.render.pdf.PdfFont;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.within;
-
-class TextBuilderTest {
-
- private EntityManager entityManager;
-
- @BeforeEach
- void setUp() {
- entityManager = new EntityManager(PdfFontLibraryFactory.standardLibrary(), false);
- entityManager.getSystems().registerTextMeasurement(new FontLibraryTextMeasurementSystem(entityManager.getFonts(), PdfFont.class));
- }
-
- @Test
- void shouldUseLineHeightForAutosizedSingleLineHeight() {
- TextStyle style = TextStyle.DEFAULT_STYLE;
- Padding padding = Padding.of(5);
- String text = "In-memory PDF";
-
- Entity entity = new TextBuilder(entityManager)
- .textWithAutoSize(text)
- .textStyle(style)
- .padding(padding)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
- PdfFont font = (PdfFont) entityManager.getFonts().getFont(style.fontName(), PdfFont.class).orElseThrow();
-
- assertThat(contentSize.width())
- .isCloseTo(font.getTextWidth(style, text) + padding.horizontal(), within(0.001));
- assertThat(contentSize.height())
- .isCloseTo(font.getLineHeight(style) + padding.vertical(), within(0.001));
- }
-
- @Test
- void shouldMatchAutosizedHeightWithSingleLineMeasurementMetrics() throws Exception {
- TextStyle style = TextStyle.DEFAULT_STYLE;
- Padding padding = Padding.of(5);
-
- Entity entity = new TextBuilder(entityManager)
- .textWithAutoSize("In-memory PDF")
- .textStyle(style)
- .padding(padding)
- .build();
-
- ContentSize contentSize = entity.getComponent(ContentSize.class).orElseThrow();
- ContentSize measuredText = TextComponent.autoMeasureText(entity, entityManager);
- PdfFont font = (PdfFont) entityManager.getFonts().getFont(style.fontName(), PdfFont.class).orElseThrow();
- PdfFont.VerticalMetrics metrics = font.verticalMetrics(style);
-
- assertThat(measuredText.height()).isCloseTo(metrics.lineHeight(), within(0.001));
- assertThat(contentSize.height() - padding.vertical()).isCloseTo(measuredText.height(), within(0.001));
- }
-
- @Test
- void shouldAutosizeWithoutLayoutSystemWhenMeasurementSystemIsRegistered() {
- Entity entity = new TextBuilder(entityManager)
- .textWithAutoSize("Layout-free measurement")
- .textStyle(TextStyle.DEFAULT_STYLE)
- .build();
-
- assertThat(entity.getComponent(ContentSize.class)).isPresent();
- }
-
- @Test
- void shouldExposeFullPdfLineMetricsThroughMeasurementSystem() {
- TextStyle style = TextStyle.DEFAULT_STYLE;
- var measurementSystem = entityManager.getSystems()
- .textMeasurement()
- .orElseThrow();
-
- PdfFont font = (PdfFont) entityManager.getFonts().getFont(style.fontName(), PdfFont.class).orElseThrow();
- PdfFont.VerticalMetrics expected = font.verticalMetrics(style);
- var actual = measurementSystem.lineMetrics(style);
-
- assertThat(actual.ascent()).isCloseTo(expected.ascent(), within(0.001));
- assertThat(actual.descent()).isCloseTo(expected.descent(), within(0.001));
- assertThat(actual.leading()).isCloseTo(expected.leading(), within(0.001));
- assertThat(actual.lineHeight()).isCloseTo(expected.lineHeight(), within(0.001));
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/VContainerBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/VContainerBuilder.java
deleted file mode 100644
index 934cf52e3..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/VContainerBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly;
-import com.demcha.compose.testsupport.engine.assembly.container.ContainerBuilder;
-import com.demcha.compose.engine.components.layout.StackAxis;
-import com.demcha.compose.engine.components.renderable.VContainer;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-@Slf4j
-public class VContainerBuilder extends ContainerBuilder {
- /**
- * Constructs a new {@code VContainerBuilder} with the specified Entity Manager.
- *
- * @param entityManager The {@link EntityManager} to which the container will be added.
- */
- VContainerBuilder(EntityManager entityManager, Align align) {
- super(entityManager,align);
- }
-
- /**
- * Initializes the container builder with the specified alignment.
- * This method calls the common creation logic from the superclass and then
- * adds a {@link VContainer} component to the entity.
- *
- * @return This builder instance for method chaining.
- */
-
-
-
-
- @Override
- public void initialize() {
- entity.addComponent(new VContainer());
- entity.addComponent(StackAxis.VERTICAL);
- }
-
-
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Box.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Box.java
deleted file mode 100644
index 22d6615d8..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Box.java
+++ /dev/null
@@ -1,8 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import java.util.List;
-import java.util.UUID;
-
-public interface Box {
- List children();
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/BuildEntity.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/BuildEntity.java
deleted file mode 100644
index 6d000b156..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/BuildEntity.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Entity;
-
-/**
- * The {@code BuildEntity} interface defines a contract for classes that are responsible for building an {@link Entity} object.
- * It provides methods to build the entity and retrieve the entity being built.
- *
- * Implementations of this interface are typically used in builder patterns to construct complex {@link Entity} objects step-by-step.
- */
-public interface BuildEntity {
-
- /**
- * Builds the {@link Entity} object. This method is responsible for assembling all
- * components and child entities, if any, into a complete {@link Entity} object.
- *
- * @return The fully constructed and assembled {@link Entity} object.
- */
- Entity build();
-
- boolean built();
-
- /**
- * Returns the {@link Entity} object that is currently being built by this builder.
- *
- * @return The entity being built.
- */
- Entity entity();
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ContainerBuilder.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ContainerBuilder.java
deleted file mode 100644
index 15616c4b2..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ContainerBuilder.java
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-
-import com.demcha.compose.testsupport.engine.assembly.HContainerBuilder;
-import com.demcha.compose.testsupport.engine.assembly.VContainerBuilder;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.layout.Align;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.extern.slf4j.Slf4j;
-
-import java.util.List;
-import java.util.Set;
-import java.util.UUID;
-
-/**
- * Base class for builders that own child entities and participate in container layout.
- *
- * Container builders extend the basic entity registration behavior from
- * {@link EmptyBox} and add a parent/child contract. Child entities are linked
- * through {@code ParentComponent}, and the layout system later uses those links
- * to compute hierarchy-aware size expansion, alignment, and placement.
- *
- *
- * @param the concrete container builder type
- * @see HContainerBuilder
- * @see VContainerBuilder
- * @see EmptyBox
- */
-@Slf4j
-public abstract class ContainerBuilder> extends EmptyBox implements Box {
-
-
- public ContainerBuilder(EntityManager entityManager, Align align) {
- super(entityManager);
- entity.addComponent(align);
- }
-
-
- /**
- * Returns the UUIDs of entities currently attached as children.
- *
- * The list reflects builder-time hierarchy; the layout system later uses
- * the same relationship to resolve geometry.
- *
- * @return child entity identifiers in insertion order
- */
- public List children() {
- return this.entity.getChildren();
- }
-
-
- /**
- * Replaces the container alignment metadata.
- *
- * This alignment is later read during container layout to determine how
- * children should be arranged inside the container's inner box.
- *
- * @return the current builder
- */
- public T addAlin(Align align) {
- log.debug("add alin to entity {}", this.entity);
- this.entity.addComponent(align);
- return self();
- }
-
- /**
- * Registers the container entity after child relationships have been declared.
- *
- * Container geometry is still finalized later by layout systems; the build
- * step mainly persists the entity graph into the registry.
- *
- * @return the built container entity
- */
- @Override
- public Entity build() {
- log.debug("building entity {}", this.entity);
- return registerBuiltEntity();
- }
-
-
-}
-
-
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EmptyBox.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EmptyBox.java
deleted file mode 100644
index ce5f498c0..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EmptyBox.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.layout.ParentComponent;
-import com.demcha.compose.engine.core.EntityManager;
-import lombok.Getter;
-import lombok.experimental.Accessors;
-import lombok.extern.slf4j.Slf4j;
-
-/**
- * Base builder for entity-producing builders that do not manage a specialized
- * child layout contract of their own.
- *
- * {@code EmptyBox} creates the underlying {@code Entity}, applies shared builder
- * behavior inherited from {@code EntityBuilderBase}, and knows how to register
- * the final entity in the shared {@code EntityManager}. Leaf builders such as
- * text, image, line, and shape builders typically extend this class directly.
- * Container-oriented builders also inherit from it through {@code ContainerBuilder}.
- *
- *
- * @param the concrete builder type for fluent chaining
- */
-@Slf4j
-@Getter
-@Accessors(fluent = true)
-public abstract class EmptyBox extends EntityBuilderBase implements BuildEntity {
-
- /**
- * Shared entity registry that receives the built entity.
- */
- protected final EntityManager entityManager;
- private boolean built;
-
- protected EmptyBox(EntityManager entityManager) {
- this.entityManager = entityManager;
- this.entity = new Entity();
- autoName();
- initialize();
- log.debug("Created entity {}", self());
- }
-
- T addParrent(ParentComponent parent) {
- var parrentEntity = entityManager.getEntity(parent.uuid()).orElseThrow(()->{
- return new IllegalStateException("No found entity with id %s".formatted(parent.uuid()));
- });
-
- return addParrent(parrentEntity);
- }
-
- T addParrent(Entity parent) {
- log.debug("Add parent {}", parent);
- this.entity.addComponent(new ParentComponent(parent.getUuid()));
- parent.getChildren().add(this.entity.getUuid());
- return self();
- }
-
- public T addChild(Entity child) {
- log.debug("Add child {} to parent {}", child, this.entity);
- child.addComponent(new ParentComponent(this.entity));
- this.entity.getChildren().add(child.getUuid());
- return self();
- }
-
- @Override
- public boolean built() {
- return built;
- }
-
- /**
- * Finalizes and registers the current entity in the {@code EntityManager}.
- *
- * Most subclasses only need to attach the right components before
- * delegating to this behavior.
- */
- @Override
- public Entity build() {
- return registerBuiltEntity();
- }
-
- protected final Entity registerBuiltEntity() {
- entityManager.putEntity(entity);
- built = true;
- return entity;
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityBuilderBase.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityBuilderBase.java
deleted file mode 100644
index 5f8d24b3f..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityBuilderBase.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Component;
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.core.EntityName;
-import lombok.Getter;
-import lombok.experimental.Accessors;
-/**
- * Abstract base class for entity builders.
- *
- * This class provides common functionality for building {@link Entity} objects,
- * including managing components, auto-naming entities, and providing a fluent API
- * for method chaining.
- *
- */
-@Getter
-@Accessors(fluent = true)
-public abstract class EntityBuilderBase implements Layout, EntityCreator {
-
- /**
- * The {@link Entity} instance being built by this builder.
- */
- @Getter
- protected Entity entity;
-
-
-
- /**
- * Automatically generates a default name for the underlying {@link Entity}.
- * The name is composed of the simple class name of the builder and a truncated version of the entity's ID.
- *
- *
- * The generated name follows the pattern: {@code BuilderClassName + first 5 characters of EntityID}.
- * This name is then added to the entity as an {@link EntityName} component.
- *
- */
- protected void autoName() {
- String simpleName = self().getClass().getSimpleName();
- String defaultName = simpleName +"__" + entity.getUuid().toString().substring(0, 5);
- entity.addComponent(new EntityName(defaultName));
- }
-
- /**
- * Adds a {@link Component} to the underlying {@link Entity}.
- * This method allows for the composition of the entity with various functional components.
- *
- *
- * This method delegates the actual addition of the component to the {@link Entity#addComponent(Component)} method.
- *
- *
- * @param component The {@link Component} to be added to the entity.
- * @return The current builder instance, allowing for method chaining.
- */
- @Override
- public B addComponent(Component component) {
- entity.addComponent(component);
- return self();
- }
-
-
- /**
- * Returns the current builder instance, cast to its generic type {@code B}.
- * This method is crucial for enabling fluent API and method chaining in subclasses.
- *
- *
- * Subclasses should implement this method to return {@code this} cast to their specific builder type.
- *
- * @return The current builder instance.
- */
- public B self() {
- return (B) this;
- }
-
-
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityCreator.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityCreator.java
deleted file mode 100644
index ed83a0822..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/EntityCreator.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Component;
-import com.demcha.compose.engine.components.core.EntityName;
-
-/**
- * An interface for creating and configuring entities.
- *
- * @param The type of the builder itself, allowing for method chaining.
- */
-public interface EntityCreator {
-
-
- /**
- * Configures the entity with the specified name.
- *
- * @param name The name of the entity.
- * @return The builder instance for method chaining.
- */
- default B entityName(EntityName name) {
- addComponent(name);
- return self();
- }
-
- /**
- * Configures the entity with the specified name.
- *
- * @param name The name of the entity.
- * @return The builder instance for method chaining.
- */
-
- default B entityName(String name) {
- entityName(new EntityName(name));
- return self();
- }
-
- /**
- * This method provides an initialization step for components within the entity,
- * ensuring that all components are properly associated with this entity.
- */
-
- void initialize();
-
-
- /**
- * add component in to our entity
- *
- * @param component The component to add.
- * @return The builder instance for method chaining.
- */
- B addComponent(Component component);
-
- /**
- * Returns the builder instance itself.
- *
- * @return
- */
- B self();
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Layout.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Layout.java
deleted file mode 100644
index 7a8f91fae..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/Layout.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.layout.Anchor;
-import com.demcha.compose.engine.components.layout.HAnchor;
-import com.demcha.compose.engine.components.layout.Layer;
-import com.demcha.compose.engine.components.layout.VAnchor;
-import com.demcha.compose.engine.components.layout.coordinator.Position;
-import com.demcha.compose.engine.components.style.Margin;
-import com.demcha.compose.engine.components.style.Padding;
-
-/**
- * This interface defines a set of default methods for configuring layout-related properties
- * of a component builder. It is intended to be implemented by builder classes.
- *
- * @param The type of the builder implementing this interface.
- *
- * The interface provides methods for setting various layout properties such as:
- *
- * - {@code layer}: The z-order of the component.
- * - {@code position}: The absolute position (x, y) of the component.
- * - {@code anchor}: How the component is anchored within its parent.
- * - {@code size}: The width and height of the component.
- * - {@code margin}: The external spacing around the component.
- * - {@code padding}: The internal spacing within the component.
- *
- * Each method returns the builder instance itself, allowing for method chaining.
- */
-public interface Layout {
- default B layer(Layer layer) {
- entity().addComponent(layer);
- return self();
- }
-
- default B layer(int layer) {
- layer(new Layer(layer));
- return self();
- }
-
- default B position(Position position) {
- entity().addComponent(position);
- return self();
- }
-
- default B position(double x, double y) {
- position(new Position(x, y));
- return self();
- }
-
- default B anchor(Anchor anchor) {
- entity().addComponent(anchor);
- return self();
- }
-
- default B anchor(HAnchor hAnchor, VAnchor vAnchor) {
- anchor(new Anchor(hAnchor, vAnchor));
- return self();
- }
-
- default B size(ContentSize size) {
- entity().addComponent(size);
- return self();
- }
-
-
- default B size(double width, double height) {
- size(new ContentSize(width, height));
- return self();
- }
-
- default B margin(Margin margin) {
- entity().addComponent(margin);
- return self();
- }
-
- default B margin(double top, double right, double bottom, double left) {
- margin(new Margin(top, right, bottom, left));
- return self();
- }
-
- default B padding(Padding padding) {
- entity().addComponent(padding);
- return self();
- }
-
- default B padding(double top, double right, double bottom, double left) {
- padding(new Padding(top, right, bottom, left));
- return self();
- }
-
- Entity entity();
-
- /**
- * Returns the current builder instance, cast to its generic type {@code B}.
- * This method is used to enable method chaining in subclasses.
- */
- B self();
-
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ShapeBuilderBase.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ShapeBuilderBase.java
deleted file mode 100644
index b12022807..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/ShapeBuilderBase.java
+++ /dev/null
@@ -1,114 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import com.demcha.compose.engine.components.content.shape.CornerRadius;
-import com.demcha.compose.engine.components.content.shape.FillColor;
-import com.demcha.compose.engine.components.renderable.Rectangle;
-import com.demcha.compose.engine.components.style.ComponentColor;
-import com.demcha.compose.engine.core.EntityManager;
-
-import java.awt.*;
-
-
-/**
- * An abstract builder class for creating shapes within a Entity Manager.
- * This class provides methods to add various shape-related components like rectangles, corner radii, fill colors, and strokes.
- * @param The type of the concrete builder extending this abstract class, allowing for method chaining.
- */
-public abstract class ShapeBuilderBase extends EmptyBox {
-
- public ShapeBuilderBase(EntityManager entityManager) {
- super(entityManager);
- }
-
- /**
- * Adds a rectangle component to the shape.
- *
- * @param rectangle The Rectangle object to add.
- * @return The current builder instance for method chaining.
- */
- public T rectangle(Rectangle rectangle) {
- return addComponent(rectangle);
- }
-
- /**
- * Adds a default rectangle component to the shape.
- *
- * @return The current builder instance for method chaining.
- */
- public T rectangle() {
- return rectangle(new Rectangle());
- }
-
- /**
- * Sets the corner radius for the shape.
- *
- * @param radius The CornerRadius object specifying the corner radius.
- * @return The current builder instance for method chaining.
- */
- public T cornerRadius(CornerRadius radius) {
- return addComponent(radius);
- }
-
- /**
- * Sets the corner radius for the shape using a double value.
- *
- * @param radius The double value representing the corner radius.
- * @return The current builder instance for method chaining.
- */
- public T cornerRadius(double radius) {
- return cornerRadius(new CornerRadius(radius));
- }
-
- /**
- * Sets the fill color for the shape.
- *
- * @param fillColor The FillColor object to set.
- * @return The current builder instance for method chaining.
- */
- public T fillColor(FillColor fillColor) {
- return addComponent(fillColor);
- }
-
- /**
- * Sets the fill color for the shape using a AWT Color object.
- *
- * @param fillColor The AWT Color object to set.
- * @return The current builder instance for method chaining.
- */
- public T fillColor(Color fillColor) {
- return fillColor(new FillColor(fillColor));
- }
-
- /**
- * Sets the fill color for the shape using a ComponentColor object.
- *
- * @param fillColor The ComponentColor object to set.
- * @return The current builder instance for method chaining.
- */
- public T fillColor(ComponentColor fillColor) {
- return fillColor(new FillColor(fillColor));
- }
-
- /**
- * Sets the fill color for the shape using individual RGB integer values.
- *
- * @param r The red component (0-255).
- * @param g The green component (0-255).
- * @param b The blue component (0-255).
- * @return The current builder instance for method chaining.
- */
- public T fillColor(int r, int g, int b) {
- return fillColor(new FillColor(new Color(r, g, b)));
- }
-
- /**
- * Sets the stroke (outline) for the shape.
- *
- * @param stroke The Stroke object to set.
- * @return The current builder instance for method chaining.
- */
- public T stroke(Stroke stroke) {
- return addComponent(stroke);
- }
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/StackAxis.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/StackAxis.java
deleted file mode 100644
index 33d0e099d..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/StackAxis.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package com.demcha.compose.testsupport.engine.assembly.container;
-
-import com.demcha.compose.engine.components.core.Component;
-
-public enum StackAxis implements Component {
- VERTICAL, // элементы идут сверху вниз (как колонка)
- HORIZONTAL, // элементы идут слева направо (как строка)
- REVERSE_VERTICAL,
- REVERSE_HORIZONTAL,
- DEFAULT
-}
diff --git a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/package-info.java b/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/package-info.java
deleted file mode 100644
index 41ec84b87..000000000
--- a/render-pdf/src/test/java/com/demcha/compose/testsupport/engine/assembly/container/package-info.java
+++ /dev/null
@@ -1,7 +0,0 @@
-/**
- * Internal base builders and contracts for entity containers.
- *
- * Ownership: Owned by the shared engine layout layer because these classes define parent-child materialization rules.
- * Extension rules: Extend with care: new containers must document sizing, pagination, and child ownership semantics.
- */
-package com.demcha.compose.testsupport.engine.assembly.container;
\ No newline at end of file
diff --git a/src/test/java/com/demcha/compose/engine/components/content/ImageDataCacheTest.java b/src/test/java/com/demcha/compose/engine/components/content/ImageDataCacheTest.java
new file mode 100644
index 000000000..b6edd1fe4
--- /dev/null
+++ b/src/test/java/com/demcha/compose/engine/components/content/ImageDataCacheTest.java
@@ -0,0 +1,83 @@
+package com.demcha.compose.engine.components.content;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit coverage for {@link ImageData} decoding and the shared
+ * {@link ImageSourceCache}: repeated loads of the same source reuse cached bytes
+ * and metadata instead of re-decoding. This is the image path the canonical DSL
+ * ({@code addImage} / {@code ImageNode}) and the fixed-layout backend rely on.
+ */
+class ImageDataCacheTest {
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ ImageSourceCache.clearForTests();
+ }
+
+ @AfterEach
+ void tearDown() {
+ ImageSourceCache.clearForTests();
+ }
+
+ @Test
+ void repeatedPathLoadsReuseTheCachedSourceBytes() throws Exception {
+ Path imagePath = tempDir.resolve("cached-source.png");
+ Files.write(imagePath, pngBytes(120, 60, new Color(24, 92, 160)));
+
+ ImageData firstLoad = ImageData.create(imagePath);
+
+ // Overwrite the file on disk; the cache is keyed by path, so the second
+ // load must return the first (cached) bytes and metadata, not re-read.
+ Files.write(imagePath, pngBytes(48, 24, new Color(182, 44, 64)));
+ ImageData secondLoad = ImageData.create(imagePath);
+
+ assertThat(ImageSourceCache.sourceCacheSize()).isEqualTo(1);
+ assertThat(ImageSourceCache.metadataCacheSize()).isEqualTo(1);
+ assertThat(firstLoad.getFingerprint()).isEqualTo(secondLoad.getFingerprint());
+ assertThat(secondLoad.getMetadata().width()).isEqualTo(120);
+ assertThat(secondLoad.getMetadata().height()).isEqualTo(60);
+ }
+
+ @Test
+ void repeatedByteLoadsDecodeMetadataOnlyOnce() throws Exception {
+ byte[] bytes = pngBytes(200, 100, new Color(42, 52, 110));
+
+ ImageData first = ImageData.create(bytes);
+ ImageData second = ImageData.create(bytes);
+
+ assertThat(first.getFingerprint()).isEqualTo(second.getFingerprint());
+ assertThat(ImageSourceCache.metadataCacheSize()).isEqualTo(1);
+ assertThat(ImageSourceCache.metadataDecodeCount()).isEqualTo(1);
+ }
+
+ private static byte[] pngBytes(int width, int height, Color color) throws Exception {
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setColor(color);
+ graphics.fillRect(0, 0, width, height);
+ } finally {
+ graphics.dispose();
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", out);
+ return out.toByteArray();
+ }
+}
diff --git a/src/test/java/com/demcha/compose/engine/core/SystemRegistryTest.java b/src/test/java/com/demcha/compose/engine/core/SystemRegistryTest.java
deleted file mode 100644
index 34b794cf4..000000000
--- a/src/test/java/com/demcha/compose/engine/core/SystemRegistryTest.java
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.demcha.compose.engine.core;
-
-import com.demcha.compose.engine.components.content.text.TextStyle;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.measurement.TextMeasurementSystem;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatNullPointerException;
-
-/**
- * Unit coverage for the dedicated text-measurement service slot on
- * {@link SystemRegistry}. This seam is what lets {@code TextMeasurementSystem}
- * stay decoupled from {@link SystemECS}: the measurement system is a service
- * provider exposed on demand, not a {@code process()}-driven ECS system, so it is
- * held out-of-band from the {@code systems} map.
- */
-class SystemRegistryTest {
-
- @Test
- void textMeasurementIsEmptyWhenNoneRegistered() {
- assertThat(new SystemRegistry().textMeasurement()).isEmpty();
- }
-
- @Test
- void registerTextMeasurementExposesTheSameInstance() {
- SystemRegistry registry = new SystemRegistry();
- TextMeasurementSystem service = new StubMeasurement();
-
- registry.registerTextMeasurement(service);
-
- assertThat(registry.textMeasurement()).containsSame(service);
- }
-
- @Test
- void registerTextMeasurementRejectsNull() {
- assertThatNullPointerException()
- .isThrownBy(() -> new SystemRegistry().registerTextMeasurement(null))
- .withMessageContaining("textMeasurement");
- }
-
- @Test
- void measurementServiceIsNotEnrolledAsAProcessDrivenSystem() {
- SystemRegistry registry = new SystemRegistry();
-
- registry.registerTextMeasurement(new StubMeasurement());
-
- // It must stay out of the SystemECS map so the processSystems() loop never
- // calls process() on it — that is the whole point of the dedicated slot.
- assertThat(registry.getStream().toList()).isEmpty();
- }
-
- private static final class StubMeasurement implements TextMeasurementSystem {
- @Override
- public ContentSize measure(TextStyle style, String text) {
- return new ContentSize(0.0, 0.0);
- }
-
- @Override
- public LineMetrics lineMetrics(TextStyle style) {
- return new LineMetrics(0.0, 0.0, 0.0);
- }
- }
-}
diff --git a/src/test/java/com/demcha/compose/engine/pagination/ParentContainerUpdaterTest.java b/src/test/java/com/demcha/compose/engine/pagination/ParentContainerUpdaterTest.java
deleted file mode 100644
index 13617c8c2..000000000
--- a/src/test/java/com/demcha/compose/engine/pagination/ParentContainerUpdaterTest.java
+++ /dev/null
@@ -1,98 +0,0 @@
-package com.demcha.compose.engine.pagination;
-
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.geometry.ContentSize;
-import com.demcha.compose.engine.components.layout.ParentComponent;
-import com.demcha.compose.engine.components.layout.coordinator.ComputedPosition;
-import com.demcha.compose.engine.core.EntityManager;
-import org.junit.jupiter.api.Test;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-class ParentContainerUpdaterTest {
-
- @Test
- void shouldIncreaseParentSizesWithoutMovingParents() {
- Graph graph = graph();
-
- boolean updated = ParentContainerUpdater.updateParentContainerSize(graph.child(), graph.manager(), -15);
-
- assertThat(updated).isFalse();
- assertThat(graph.parent().require(ContentSize.class).height()).isEqualTo(45.0);
- assertThat(graph.parent().require(ComputedPosition.class).y()).isEqualTo(80.0);
- assertThat(graph.grandParent().require(ContentSize.class).height()).isEqualTo(55.0);
- assertThat(graph.grandParent().require(ComputedPosition.class).y()).isEqualTo(100.0);
- }
-
- @Test
- void shouldIncreaseParentSizesAndMoveAncestorsWhenUpdatingPosition() {
- Graph graph = graph();
-
- boolean updated = ParentContainerUpdater.updateParentContainer(graph.child(), graph.manager(), -15);
-
- assertThat(updated).isFalse();
- assertThat(graph.parent().require(ContentSize.class).height()).isEqualTo(45.0);
- assertThat(graph.parent().require(ComputedPosition.class).y()).isEqualTo(65.0);
- assertThat(graph.grandParent().require(ContentSize.class).height()).isEqualTo(55.0);
- assertThat(graph.grandParent().require(ComputedPosition.class).y()).isEqualTo(85.0);
- }
-
- @SuppressWarnings("deprecation")
- @Test
- void shouldDelegateDeprecatedEntityParentUpdateWrappers() {
- Graph graph = graph();
- Offset offset = new Offset();
- offset.incrementY(-10);
-
- boolean updated = graph.child().updateParentContainer(graph.manager(), offset);
-
- assertThat(updated).isFalse();
- assertThat(graph.parent().require(ContentSize.class).height()).isEqualTo(40.0);
- assertThat(graph.parent().require(ComputedPosition.class).y()).isEqualTo(70.0);
- assertThat(graph.grandParent().require(ContentSize.class).height()).isEqualTo(50.0);
- assertThat(graph.grandParent().require(ComputedPosition.class).y()).isEqualTo(90.0);
- }
-
- @SuppressWarnings("deprecation")
- @Test
- void shouldDelegateDeprecatedEntitySelfResizeWrapper() {
- Graph graph = graph();
-
- boolean updated = graph.child().updateEntitySize(graph.manager(), -12);
-
- assertThat(updated).isFalse();
- assertThat(graph.child().require(ContentSize.class).height()).isEqualTo(22.0);
- assertThat(graph.child().require(ComputedPosition.class).y()).isEqualTo(60.0);
- assertThat(graph.parent().require(ContentSize.class).height()).isEqualTo(42.0);
- assertThat(graph.parent().require(ComputedPosition.class).y()).isEqualTo(80.0);
- assertThat(graph.grandParent().require(ContentSize.class).height()).isEqualTo(52.0);
- }
-
- private static Graph graph() {
- EntityManager manager = new EntityManager();
-
- Entity grandParent = sizedEntity(0, 100, 200, 40);
- Entity parent = sizedEntity(10, 80, 120, 30);
- Entity child = sizedEntity(20, 60, 60, 10);
-
- parent.addComponent(new ParentComponent(grandParent.getUuid()));
- child.addComponent(new ParentComponent(parent.getUuid()));
- grandParent.getChildren().add(parent.getUuid());
- parent.getChildren().add(child.getUuid());
-
- manager.putEntity(grandParent);
- manager.putEntity(parent);
- manager.putEntity(child);
- return new Graph(manager, grandParent, parent, child);
- }
-
- private static Entity sizedEntity(double x, double y, double width, double height) {
- Entity entity = new Entity();
- entity.addComponent(new ComputedPosition(x, y));
- entity.addComponent(new ContentSize(width, height));
- return entity;
- }
-
- private record Graph(EntityManager manager, Entity grandParent, Entity parent, Entity child) {
- }
-}
diff --git a/src/test/java/com/demcha/compose/engine/render/EntityRenderOrderTest.java b/src/test/java/com/demcha/compose/engine/render/EntityRenderOrderTest.java
deleted file mode 100644
index d24998bd8..000000000
--- a/src/test/java/com/demcha/compose/engine/render/EntityRenderOrderTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package com.demcha.compose.engine.render;
-
-import com.demcha.compose.engine.components.core.Entity;
-import com.demcha.compose.engine.components.layout.coordinator.ComputedPosition;
-import com.demcha.compose.engine.components.style.Margin;
-import com.demcha.compose.engine.core.EntityManager;
-import org.junit.jupiter.api.Test;
-
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.UUID;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-class EntityRenderOrderTest {
-
- @Test
- void shouldSortByResolvedCoordinatesUsingDefaultZeroMargin() {
- EntityManager manager = new EntityManager();
- Entity baseEntity = positionedEntity(10, 50, null);
- Entity shiftedRightEntity = positionedEntity(10, 50, Margin.left(5));
- Entity raisedEntity = positionedEntity(10, 49, Margin.bottom(2));
-
- manager.putEntity(baseEntity);
- manager.putEntity(shiftedRightEntity);
- manager.putEntity(raisedEntity);
-
- LinkedHashMap ordered = EntityRenderOrder.sortByRenderingPosition(
- manager,
- List.of(shiftedRightEntity.getUuid(), baseEntity.getUuid(), raisedEntity.getUuid()));
-
- assertThat(ordered.keySet())
- .containsExactly(raisedEntity.getUuid(), baseEntity.getUuid(), shiftedRightEntity.getUuid());
- }
-
- @Test
- void shouldPreserveOriginalLayerOrderWhenRenderingCoordinatesMatch() {
- EntityManager manager = new EntityManager();
- Entity firstEntity = positionedEntity(10, 50, null);
- Entity secondEntity = positionedEntity(10, 50, Margin.zero());
-
- manager.putEntity(firstEntity);
- manager.putEntity(secondEntity);
-
- LinkedHashMap ordered = EntityRenderOrder.sortByRenderingPosition(
- manager,
- List.of(secondEntity.getUuid(), firstEntity.getUuid()));
-
- assertThat(ordered.keySet())
- .containsExactly(secondEntity.getUuid(), firstEntity.getUuid());
- }
-
- @Test
- void shouldSkipMissingAndDuplicateEntityIds() {
- EntityManager manager = new EntityManager();
- Entity lowerEntity = positionedEntity(0, 0, null);
- Entity upperEntity = positionedEntity(0, 10, null);
- UUID missingEntityId = UUID.randomUUID();
-
- manager.putEntity(lowerEntity);
- manager.putEntity(upperEntity);
-
- LinkedHashMap ordered = EntityRenderOrder.sortByRenderingPosition(
- manager,
- List.of(missingEntityId, lowerEntity.getUuid(), lowerEntity.getUuid(), upperEntity.getUuid()));
-
- assertThat(ordered.keySet())
- .containsExactly(upperEntity.getUuid(), lowerEntity.getUuid());
- assertThat(ordered).hasSize(2);
- }
-
- private static Entity positionedEntity(double x, double y, Margin margin) {
- Entity entity = new Entity();
- entity.addComponent(new ComputedPosition(x, y));
- if (margin != null) {
- entity.addComponent(margin);
- }
- return entity;
- }
-}