From f3a82990d410b74c1a5114f5d41eca99aea2c25b Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 4 Jun 2026 12:15:17 +0100
Subject: [PATCH 1/2] feat(api): inline shape runs + polygon shape geometry
(@since 1.7.0)
Add InlineShapeRun for inline geometric figures (dots, arrows, chevrons, diamonds, triangles, stars, checkmarks, plus, regular polygons) drawn on the text baseline from geometry, not font glyphs. Introduce ShapeOutline.Polygon + ShapePoint with a family of factories usable both block-level (ShapeContainer) and inline; DSL shortcuts on ParagraphBuilder/RichText/ShapeContainerBuilder. Additive only, zero breaking changes.
---
CHANGELOG.md | 30 +-
assets/readme/examples/inline-shapes.pdf | Bin 0 -> 3542 bytes
assets/readme/examples/rich-text-showcase.pdf | Bin 3190 -> 3878 bytes
examples/README.md | 22 ++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/text/InlineShapesExample.java | 149 +++++++++
.../text/RichTextShowcaseExample.java | 23 +-
.../fixed/pdf/PdfFixedLayoutBackend.java | 50 ++-
.../PdfEllipseFragmentRenderHandler.java | 38 +--
.../PdfParagraphFragmentRenderHandler.java | 84 ++++-
.../PdfPolygonFragmentRenderHandler.java | 45 +++
.../PdfShapeClipBeginRenderHandler.java | 2 +
.../PdfShapeFragmentRenderHandler.java | 2 +-
.../fixed/pdf/handlers/PdfShapeGeometry.java | 86 +++++
.../document/dsl/ParagraphBuilder.java | 148 +++++++++
.../demcha/compose/document/dsl/RichText.java | 143 +++++++++
.../document/dsl/ShapeContainerBuilder.java | 75 +++++
.../document/layout/TextFlowSupport.java | 65 +++-
.../definitions/ShapeContainerDefinition.java | 10 +
.../layout/payloads/ParagraphShapeSpan.java | 52 +++
.../layout/payloads/ParagraphSpan.java | 6 +-
.../payloads/PolygonFragmentPayload.java | 37 +++
.../compose/document/node/InlineRun.java | 9 +-
.../compose/document/node/InlineShapeRun.java | 77 +++++
.../compose/document/style/ShapeOutline.java | 295 +++++++++++++++++-
.../compose/document/style/ShapePoint.java | 29 ++
.../document/dsl/InlineShapeRenderTest.java | 148 +++++++++
.../compose/document/dsl/RichTextTest.java | 77 +++++
.../document/node/InlineShapeRunTest.java | 87 ++++++
.../document/style/ShapeOutlineTest.java | 149 +++++++++
30 files changed, 1868 insertions(+), 72 deletions(-)
create mode 100644 assets/readme/examples/inline-shapes.pdf
create mode 100644 examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java
create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java
create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java
create mode 100644 src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
create mode 100644 src/main/java/com/demcha/compose/document/style/ShapePoint.java
create mode 100644 src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
create mode 100644 src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
create mode 100644 src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7be71e63..70a9195c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +3,35 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.
-## v1.6.10 — Planned
+## v1.7.0 — Planned
-Next bug-fix / housekeeping cycle. Track open in `docs/private/` taskboard.
+Canonical DSL primitives — additive only, zero breaking changes. Adding public
+API turns the open cycle into a minor.
+
+### Public API
+
+- **Inline shape runs — geometry-based dots, diamonds, stars and bullets.** New
+ `com.demcha.compose.document.node.InlineShapeRun` (`@since 1.7.0`) joins the
+ sealed `InlineRun` hierarchy alongside text and image runs. It draws any
+ `ShapeOutline` figure on the paragraph baseline directly from geometry — no
+ raster payload, no font glyph — so skill rating dots (`Java ●●●●○`), custom
+ bullets and inline status markers no longer depend on a font shipping
+ `U+25CF` and friends. Authored through `ParagraphBuilder` / `RichText`
+ `dot(...)`, `ellipse(...)`, `diamond(...)`, `triangle(...)`, `star(...)` and
+ the generic `shape(ShapeOutline, ...)`; measured into line width and height
+ like inline images. A `null` fill paints an outlined figure, a `null` stroke
+ a filled one; at least one must be present.
+- **New polygon shape geometry, usable block-level and inline.** `ShapeOutline`
+ (`com.demcha.compose.document.style`) gains a `Polygon` kind plus a family of
+ factories built from normalized `ShapePoint` vertices (`@since 1.7.0`):
+ `diamond`, `triangle`, `star`, `polygon`, `arrow` / `arrowRight` / `arrowLeft`
+ (4-way `Direction`), `chevron`, `checkmark`, `plus` and `regularPolygon(sides)`.
+ Arrows and chevrons read as directional list bullets or inline markers
+ between text ("Step 1 → Step 2", "Home › Docs"). `ParagraphBuilder` /
+ `RichText` add `arrow(size, Direction, fill)` and `chevron(...)` shortcuts
+ (every other kind is reachable through `shape(ShapeOutline, ...)`);
+ `ShapeContainerBuilder` exposes matching block outlines. Rectangle,
+ rounded-rectangle and ellipse shape containers are unchanged.
## v1.6.9 — 2026-06-03
diff --git a/assets/readme/examples/inline-shapes.pdf b/assets/readme/examples/inline-shapes.pdf
new file mode 100644
index 0000000000000000000000000000000000000000..d457e6587e9d0592d123343344b7013b69ec9afd
GIT binary patch
literal 3542
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
literal 0
HcmV?d00001
diff --git a/assets/readme/examples/rich-text-showcase.pdf b/assets/readme/examples/rich-text-showcase.pdf
index f10d97d79189199bcbe630d7678235628078b0a4..87da3a114213e35b7d4cbd2667aa732c44f43713 100644
GIT binary patch
delta 3566
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
YCk>)a(WB*|k&}lgfJH=5`e^X~0VDITKL7v#
delta 2872
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 rich
+ .plain("Draft ")
+ .arrow(8, ShapeOutline.Direction.RIGHT, accent)
+ .plain(" Review ")
+ .arrow(8, ShapeOutline.Direction.RIGHT, accent)
+ .plain(" Published"))
+// also: dot(size, fill), diamond, triangle, star, chevron,
+// or shape(ShapeOutline.checkmark(size, size), fill) for any figure
+```
+
+[📄 View PDF](../assets/readme/examples/inline-shapes.pdf) ·
+[📜 Full source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java)
+
### Section presets
`pageBackground`, `band`, `softPanel`, the four
diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
index b7c6128f..37a05dec 100644
--- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
+++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java
@@ -9,6 +9,7 @@
import com.demcha.examples.features.streaming.HttpStreamingExample;
import com.demcha.examples.features.tables.ComposedTableCellExample;
import com.demcha.examples.features.tables.TableAdvancedExample;
+import com.demcha.examples.features.text.InlineShapesExample;
import com.demcha.examples.features.text.RichTextShowcaseExample;
import com.demcha.examples.features.text.SectionPresetsExample;
import com.demcha.examples.features.themes.CustomBusinessThemeExample;
@@ -127,6 +128,7 @@ public static void main(String[] args) throws Exception {
System.out.println("Generated: " + TableAdvancedExample.generate());
// Text + sections
+ System.out.println("Generated: " + InlineShapesExample.generate());
System.out.println("Generated: " + RichTextShowcaseExample.generate());
System.out.println("Generated: " + SectionPresetsExample.generate());
diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
new file mode 100644
index 00000000..a0b383f0
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
@@ -0,0 +1,149 @@
+package com.demcha.examples.features.text;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.RichText;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.document.theme.BusinessTheme;
+import com.demcha.compose.font.FontName;
+import com.demcha.examples.support.ExampleOutputPaths;
+
+import java.nio.file.Path;
+import java.util.function.Consumer;
+
+/**
+ * Runnable showcase for inline shape runs ({@code @since 1.7.0}).
+ *
+ * Geometric figures — rating dots, arrows, chevrons, diamonds, stars,
+ * checkmarks, plus signs, regular polygons — drawn on the text baseline from
+ * geometry (no font glyphs), used between text and as list bullets. Each row
+ * pairs the rendered output with the {@code ParagraphBuilder} / {@code RichText}
+ * call that produced it, so the PDF reads like a quick reference.
+ */
+public final class InlineShapesExample {
+ private static final BusinessTheme THEME = BusinessTheme.modern();
+ private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128);
+ private static final DocumentColor BRAND = DocumentColor.rgb(20, 80, 95);
+ private static final DocumentColor ACCENT = DocumentColor.rgb(196, 153, 76);
+ private static final DocumentColor GREEN = DocumentColor.rgb(34, 130, 92);
+ private static final DocumentColor PANEL = DocumentColor.rgb(248, 244, 234);
+
+ private InlineShapesExample() {
+ }
+
+ public static Path generate() throws Exception {
+ Path outputFile = ExampleOutputPaths.prepare("features/text", "inline-shapes.pdf");
+
+ try (DocumentSession document = GraphCompose.document(outputFile)
+ .pageSize(DocumentPageSize.A4)
+ .pageBackground(THEME.pageBackground())
+ .margin(34, 34, 34, 34)
+ .create()) {
+
+ document.pageFlow()
+ .name("InlineShapesShowcase")
+ .spacing(14)
+ .addSection("Hero", section -> section
+ .softPanel(THEME.palette().surfaceMuted(), 10, 16)
+ .accentLeft(ACCENT, 4)
+ .spacing(6)
+ .addParagraph(p -> p
+ .text("Inline shapes")
+ .textStyle(THEME.text().h1())
+ .margin(DocumentInsets.zero()))
+ .addRich(rich -> rich
+ .plain("Geometric figures drawn on the text baseline ")
+ .accent("from geometry, not font glyphs", BRAND)
+ .plain(" — between text and as list bullets, at any size and colour.")))
+ .addSection("Ratings", section -> labelledRow(section,
+ "dot(size, fill) — filled and outlined rating dots",
+ rich -> rich
+ .plain("Java ")
+ .dot(5, BRAND).dot(5, BRAND).dot(5, BRAND).dot(5, BRAND)
+ .dot(5, null, DocumentStroke.of(BRAND, 0.6))
+ .plain(" Kotlin ")
+ .dot(5, BRAND).dot(5, BRAND).dot(5, BRAND)
+ .dot(5, null, DocumentStroke.of(BRAND, 0.6))
+ .dot(5, null, DocumentStroke.of(BRAND, 0.6))))
+ .addSection("Flows", section -> labelledRow(section,
+ "arrow(size, Direction, fill) — direction between text",
+ rich -> rich
+ .plain("Draft ").arrow(8, ShapeOutline.Direction.RIGHT, ACCENT)
+ .plain(" Review ").arrow(8, ShapeOutline.Direction.RIGHT, ACCENT)
+ .plain(" Published")))
+ .addSection("Breadcrumb", section -> labelledRow(section,
+ "chevron(size, Direction, fill) — light directional separator",
+ rich -> rich
+ .plain("Home ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED)
+ .plain(" Docs ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED)
+ .plain(" API ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED)
+ .plain(" InlineShapeRun")))
+ .addSection("Checklist", section -> section
+ .softPanel(PANEL, 6, 12)
+ .spacing(5)
+ .addParagraph(p -> p
+ .text("shape(ShapeOutline.checkmark(...)/plus(...), fill) — checklist markers")
+ .textStyle(caption())
+ .margin(DocumentInsets.zero()))
+ .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN)
+ .plain(" Figures render from geometry"))
+ .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN)
+ .plain(" They reuse the ShapeOutline taxonomy"))
+ .addRich(rich -> rich.shape(ShapeOutline.plus(9, 9), ACCENT)
+ .plain(" A new figure is one factory away")))
+ .addSection("Bullets", section -> labelledRow(section,
+ "any ShapeOutline as a list bullet",
+ rich -> rich
+ .diamond(7, ACCENT).plain(" Diamond ")
+ .star(8, ACCENT).plain(" Star ")
+ .triangle(7, BRAND).plain(" Triangle ")
+ .arrow(8, ShapeOutline.Direction.RIGHT, BRAND).plain(" Arrow ")
+ .shape(ShapeOutline.regularPolygon(8, 8, 6), MUTED).plain(" Hexagon")))
+ .addSection("Footer", section -> section
+ .accentTop(THEME.palette().rule(), 0.6)
+ .padding(new DocumentInsets(8, 0, 0, 0))
+ .addRich(rich -> rich
+ .plain("Source: ")
+ .style("examples/.../InlineShapesExample.java",
+ DocumentTextStyle.builder()
+ .fontName(FontName.COURIER)
+ .size(8)
+ .color(MUTED)
+ .build())))
+ .build();
+
+ document.buildPdf();
+ }
+
+ return outputFile;
+ }
+
+ public static void main(String[] args) throws Exception {
+ System.out.println("Generated: " + generate());
+ }
+
+ private static void labelledRow(SectionBuilder section, String label, Consumer body) {
+ section
+ .softPanel(PANEL, 6, 12)
+ .spacing(4)
+ .addParagraph(p -> p
+ .text(label)
+ .textStyle(caption())
+ .margin(DocumentInsets.zero()))
+ .addRich(body::accept);
+ }
+
+ private static DocumentTextStyle caption() {
+ return DocumentTextStyle.builder()
+ .fontName(FontName.HELVETICA_BOLD)
+ .size(8.5)
+ .color(MUTED)
+ .build();
+ }
+}
diff --git a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java
index 5ad8850d..38b35725 100644
--- a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java
@@ -5,7 +5,9 @@
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.ShapeOutline;
import com.demcha.compose.document.theme.BusinessTheme;
import com.demcha.compose.font.FontName;
import com.demcha.examples.support.ExampleOutputPaths;
@@ -17,7 +19,8 @@
*
* Walks through every fluent method on {@code RichText} —
* {@code plain / bold / italic / boldItalic / underline / strikethrough /
- * color / accent / size / style / link / append} — laid out as labelled
+ * color / accent / size / style / link / append / dot / ellipse / diamond /
+ * star / shape} — laid out as labelled
* "what does this look like" rows on a single A4 page so the rendered PDF
* reads like a quick reference.
*/
@@ -121,6 +124,24 @@ public static Path generate() throws Exception {
.plain("Pre-built ")
.append(reusableRun())
.plain(" composes with ad-hoc fragments — share recurring fragments across paragraphs.")))
+ .addSection("Inline shapes", section -> labelledRow(section,
+ "dot / diamond / star / arrow / chevron / shape",
+ rich -> rich
+ .plain("Java ")
+ .dot(5, BRAND)
+ .dot(5, BRAND)
+ .dot(5, BRAND)
+ .dot(5, null, DocumentStroke.of(BRAND, 0.6))
+ .plain(" Step 1 ")
+ .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT)
+ .plain(" Step 2 Home ")
+ .chevron(6, ShapeOutline.Direction.RIGHT, MUTED)
+ .plain(" Docs ")
+ .diamond(7, ACCENT)
+ .plain(" ")
+ .star(8, ACCENT)
+ .plain(" ")
+ .shape(ShapeOutline.checkmark(8, 8), BRAND)))
.addSection("Footer", section -> section
.accentTop(THEME.palette().rule(), 0.6)
.padding(new DocumentInsets(8, 0, 0, 0))
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
index 1d8710b9..39254ff3 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext;
import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfBarcodeFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfEllipseFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfPolygonFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfImageFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfLineFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfParagraphFragmentRenderHandler;
@@ -96,6 +97,7 @@ private static List> defaultHandlers() {
new PdfShapeFragmentRenderHandler(),
new PdfLineFragmentRenderHandler(),
new PdfEllipseFragmentRenderHandler(),
+ new PdfPolygonFragmentRenderHandler(),
new PdfImageFragmentRenderHandler(),
new PdfTableRowFragmentRenderHandler(),
new PdfShapeClipBeginRenderHandler(),
@@ -411,29 +413,43 @@ private static PdfLinkAnnotationWriter.PlacedPdfRect spanLinkRectangle(Paragraph
double lineHeight,
double textAscent,
double baselineOffsetFromBottom) {
+ com.demcha.compose.document.node.InlineImageAlignment alignment;
+ double graphicHeight;
+ double baselineOffset;
if (span instanceof ParagraphImageSpan imageSpan) {
- double baselineY = lineTop - lineHeight + baselineOffsetFromBottom;
- double lineBottom = baselineY - baselineOffsetFromBottom;
- double base = switch (imageSpan.alignment() == null
- ? com.demcha.compose.document.node.InlineImageAlignment.CENTER
- : imageSpan.alignment()) {
- case BASELINE -> baselineY;
- case CENTER -> lineBottom + (lineHeight - imageSpan.height()) / 2.0;
- case TEXT_TOP -> baselineY + textAscent - imageSpan.height();
- case TEXT_BOTTOM -> lineBottom;
- };
- double imageBottom = base + imageSpan.baselineOffset();
+ alignment = imageSpan.alignment();
+ graphicHeight = imageSpan.height();
+ baselineOffset = imageSpan.baselineOffset();
+ } else if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphShapeSpan shapeSpan) {
+ alignment = shapeSpan.alignment();
+ graphicHeight = shapeSpan.height();
+ baselineOffset = shapeSpan.baselineOffset();
+ } else {
+ // Text spans cover the full line box.
return new PdfLinkAnnotationWriter.PlacedPdfRect(
spanX,
- imageBottom,
- imageSpan.width(),
- imageSpan.height());
- }
+ lineTop - lineHeight,
+ span.width(),
+ lineHeight);
+ }
+ // Inline-graphic baseline placement, kept in lockstep with
+ // PdfParagraphFragmentRenderHandler.resolveInlineGraphicBottom — both
+ // place an inline image or shape on the text baseline identically.
+ double baselineY = lineTop - lineHeight + baselineOffsetFromBottom;
+ double lineBottom = baselineY - baselineOffsetFromBottom;
+ double base = switch (alignment == null
+ ? com.demcha.compose.document.node.InlineImageAlignment.CENTER
+ : alignment) {
+ case BASELINE -> baselineY;
+ case CENTER -> lineBottom + (lineHeight - graphicHeight) / 2.0;
+ case TEXT_TOP -> baselineY + textAscent - graphicHeight;
+ case TEXT_BOTTOM -> lineBottom;
+ };
return new PdfLinkAnnotationWriter.PlacedPdfRect(
spanX,
- lineTop - lineHeight,
+ base + baselineOffset,
span.width(),
- lineHeight);
+ graphicHeight);
}
@SuppressWarnings("unchecked")
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java
index 11eeacbc..32aeafd7 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java
@@ -35,40 +35,16 @@ public void render(PlacedFragment fragment,
if (fragment.width() <= 0 || fragment.height() <= 0) {
return;
}
-
- boolean hasFill = payload.fillColor() != null;
- boolean hasStroke = payload.stroke() != null
- && payload.stroke().strokeColor() != null
- && payload.stroke().strokeColor().color() != null
- && payload.stroke().width() > 0;
- if (!hasFill && !hasStroke) {
- return;
- }
-
PDPageContentStream stream = environment.pageSurface(fragment.pageIndex());
- stream.saveGraphicsState();
- try {
- if (hasStroke) {
- stream.setStrokingColor(payload.stroke().strokeColor().color());
- stream.setLineWidth((float) payload.stroke().width());
- }
- if (hasFill) {
- stream.setNonStrokingColor(payload.fillColor());
- }
- drawEllipse(stream, (float) fragment.x(), (float) fragment.y(), (float) fragment.width(), (float) fragment.height());
- if (hasFill && hasStroke) {
- stream.fillAndStroke();
- } else if (hasFill) {
- stream.fill();
- } else {
- stream.stroke();
- }
- } finally {
- stream.restoreGraphicsState();
- }
+ float x = (float) fragment.x();
+ float y = (float) fragment.y();
+ float width = (float) fragment.width();
+ float height = (float) fragment.height();
+ PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(),
+ s -> drawEllipse(s, x, y, width, height));
}
- private static void drawEllipse(PDPageContentStream stream,
+ static void drawEllipse(PDPageContentStream stream,
float x,
float y,
float width,
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
index e79e91ee..f57c52b7 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
@@ -4,11 +4,13 @@
import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan;
import com.demcha.compose.document.layout.payloads.ParagraphImageSpan;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphSpan;
import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.style.ShapeOutline;
import com.demcha.compose.font.FontLibrary;
import com.demcha.compose.engine.render.pdf.PdfFont;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -127,6 +129,14 @@ private void renderLine(PDPageContentStream stream,
(float) imageSpan.width(),
(float) imageSpan.height());
cursorX += imageSpan.width();
+ } else if (span instanceof ParagraphShapeSpan shapeSpan) {
+ if (inTextBlock) {
+ stream.endText();
+ inTextBlock = false;
+ }
+ renderShape(stream, shapeSpan, cursorX, baselineY,
+ line.textAscent(), line.baselineOffsetFromBottom(), line.lineHeight());
+ cursorX += shapeSpan.width();
}
}
} finally {
@@ -141,18 +151,78 @@ private static double resolveImageBottom(ParagraphImageSpan imageSpan,
double textAscent,
double baselineOffsetFromBottom,
double lineHeight) {
- double imageHeight = imageSpan.height();
+ return resolveInlineGraphicBottom(
+ imageSpan.height(),
+ imageSpan.alignment(),
+ imageSpan.baselineOffset(),
+ baselineY,
+ textAscent,
+ baselineOffsetFromBottom,
+ lineHeight);
+ }
+
+ /**
+ * Resolves the PDF-space bottom edge of an inline graphic (image or
+ * ellipse) for the given vertical alignment. Shared by both span kinds so
+ * dots and icons sit identically next to text.
+ */
+ private static double resolveInlineGraphicBottom(double graphicHeight,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ double baselineY,
+ double textAscent,
+ double baselineOffsetFromBottom,
+ double lineHeight) {
double lineBottom = baselineY - baselineOffsetFromBottom;
- double base = switch (imageSpan.alignment() == null ? InlineImageAlignment.CENTER : imageSpan.alignment()) {
+ double base = switch (alignment == null ? InlineImageAlignment.CENTER : alignment) {
case BASELINE -> baselineY;
- // Visually centers the image inside the resolved line box
+ // Visually centers the graphic inside the resolved line box
// (lineBottom + lineHeight/2). This matches how readers expect
- // icons next to text to sit, regardless of text ascender height.
- case CENTER -> lineBottom + (lineHeight - imageHeight) / 2.0;
- case TEXT_TOP -> baselineY + textAscent - imageHeight;
+ // icons or dots next to text to sit, regardless of ascender height.
+ case CENTER -> lineBottom + (lineHeight - graphicHeight) / 2.0;
+ case TEXT_TOP -> baselineY + textAscent - graphicHeight;
case TEXT_BOTTOM -> lineBottom;
};
- return base + imageSpan.baselineOffset();
+ return base + baselineOffset;
+ }
+
+ private static void renderShape(PDPageContentStream stream,
+ ParagraphShapeSpan span,
+ double cursorX,
+ double baselineY,
+ double textAscent,
+ double baselineOffsetFromBottom,
+ double lineHeight) throws IOException {
+ double width = span.width();
+ double height = span.height();
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+ double bottom = resolveInlineGraphicBottom(
+ height,
+ span.alignment(),
+ span.baselineOffset(),
+ baselineY,
+ textAscent,
+ baselineOffsetFromBottom,
+ lineHeight);
+ float x = (float) cursorX;
+ float y = (float) bottom;
+ float w = (float) width;
+ float h = (float) height;
+ ShapeOutline outline = span.outline();
+ PdfShapeGeometry.fillAndStrokePath(stream, span.fillColor(), span.stroke(), s -> {
+ if (outline instanceof ShapeOutline.Ellipse) {
+ PdfEllipseFragmentRenderHandler.drawEllipse(s, x, y, w, h);
+ } else if (outline instanceof ShapeOutline.Rectangle) {
+ s.addRect(x, y, w, h);
+ } else if (outline instanceof ShapeOutline.RoundedRectangle r) {
+ float radius = (float) Math.min(r.cornerRadius(), Math.min(w, h) / 2.0f);
+ PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, x, y, w, h, radius, radius, radius, radius);
+ } else if (outline instanceof ShapeOutline.Polygon p) {
+ PdfShapeGeometry.addPolygonPath(s, x, y, w, h, p.points());
+ }
+ });
}
}
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java
new file mode 100644
index 00000000..f2d8bc02
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java
@@ -0,0 +1,45 @@
+package com.demcha.compose.document.backend.fixed.pdf.handlers;
+
+import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+
+import java.io.IOException;
+
+/**
+ * Renders fixed polygon fragments (diamond, triangle, star, arbitrary rings).
+ *
+ * @author Artem Demchyshyn
+ */
+public final class PdfPolygonFragmentRenderHandler
+ implements PdfFragmentRenderHandler {
+
+ /**
+ * Creates the polygon fragment renderer.
+ */
+ public PdfPolygonFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return PolygonFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ PolygonFragmentPayload payload,
+ PdfRenderEnvironment environment) throws IOException {
+ if (fragment.width() <= 0 || fragment.height() <= 0) {
+ return;
+ }
+ PDPageContentStream stream = environment.pageSurface(fragment.pageIndex());
+ float x = (float) fragment.x();
+ float y = (float) fragment.y();
+ float width = (float) fragment.width();
+ float height = (float) fragment.height();
+ PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(),
+ s -> PdfShapeGeometry.addPolygonPath(s, x, y, width, height, payload.points()));
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
index 6e42ad3e..6b030f1e 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java
@@ -79,6 +79,8 @@ public void render(PlacedFragment fragment,
(float) Math.min(r.cornerRadius(), Math.min(width, height) / 2.0f));
} else if (outline instanceof ShapeOutline.Rectangle) {
stream.addRect(x, y, width, height);
+ } else if (outline instanceof ShapeOutline.Polygon p) {
+ PdfShapeGeometry.addPolygonPath(stream, x, y, width, height, p.points());
} else {
throw new IllegalStateException("Unknown outline: " + outline);
}
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java
index 3a052cc4..4cc840c3 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java
@@ -132,7 +132,7 @@ private static float clampCornerRadius(double raw, float maxAllowed) {
* sharp 90° corner (no arc, just a line into the corner). PDF y
* grows up, so {@code (x, y)} is the bottom-left of the rectangle.
*/
- private static void drawRoundedRectangle(PDPageContentStream stream,
+ static void drawRoundedRectangle(PDPageContentStream stream,
float x,
float y,
float width,
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java
new file mode 100644
index 00000000..0869ffb5
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java
@@ -0,0 +1,86 @@
+package com.demcha.compose.document.backend.fixed.pdf.handlers;
+
+import com.demcha.compose.document.style.ShapePoint;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+
+import java.awt.Color;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Shared PDF path helpers for shape geometry, so block render, clip masking and
+ * inline shape render emit identical paths from one source.
+ */
+final class PdfShapeGeometry {
+ private PdfShapeGeometry() {
+ }
+
+ /**
+ * A path contribution: the caller adds the geometry (ellipse, rectangle,
+ * polygon, …) so the fill/stroke wrapper can be shared.
+ */
+ @FunctionalInterface
+ interface PathEmitter {
+ void emit(PDPageContentStream stream) throws IOException;
+ }
+
+ /**
+ * Paints a path with optional fill and/or stroke, sharing the
+ * save/restore + colour setup + fill/stroke selection across every shape
+ * render handler. No-op when neither a fill nor a visible stroke is present.
+ */
+ static void fillAndStrokePath(PDPageContentStream stream,
+ Color fillColor,
+ Stroke stroke,
+ PathEmitter path) throws IOException {
+ boolean hasFill = fillColor != null;
+ boolean hasStroke = stroke != null
+ && stroke.strokeColor() != null
+ && stroke.strokeColor().color() != null
+ && stroke.width() > 0;
+ if (!hasFill && !hasStroke) {
+ return;
+ }
+ stream.saveGraphicsState();
+ try {
+ if (hasStroke) {
+ stream.setStrokingColor(stroke.strokeColor().color());
+ stream.setLineWidth((float) stroke.width());
+ }
+ if (hasFill) {
+ stream.setNonStrokingColor(fillColor);
+ }
+ path.emit(stream);
+ if (hasFill && hasStroke) {
+ stream.fillAndStroke();
+ } else if (hasFill) {
+ stream.fill();
+ } else {
+ stream.stroke();
+ }
+ } finally {
+ stream.restoreGraphicsState();
+ }
+ }
+
+ /**
+ * Appends a closed polygon path to the stream. Normalized vertices (see
+ * {@link ShapePoint}) are scaled into the {@code [x, x+width] × [y, y+height]}
+ * box; the caller fills/strokes/clips the resulting path.
+ */
+ static void addPolygonPath(PDPageContentStream stream,
+ float x,
+ float y,
+ float width,
+ float height,
+ List points) throws IOException {
+ ShapePoint first = points.get(0);
+ stream.moveTo(x + (float) (first.x() * width), y + (float) (first.y() * height));
+ for (int i = 1; i < points.size(); i++) {
+ ShapePoint point = points.get(i);
+ stream.lineTo(x + (float) (point.x() * width), y + (float) (point.y() * height));
+ }
+ stream.closePath();
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
index 17cc9bd0..4148f474 100644
--- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
@@ -4,15 +4,19 @@
import com.demcha.compose.document.node.DocumentBookmarkOptions;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.node.InlineShapeRun;
import com.demcha.compose.document.node.InlineImageRun;
import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextAutoSize;
import com.demcha.compose.document.style.DocumentTextIndent;
import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.ShapeOutline;
import java.util.ArrayList;
import java.util.List;
@@ -240,6 +244,150 @@ public ParagraphBuilder inlineImage(DocumentImageData imageData,
return this;
}
+ /**
+ * Adds an inline filled circle ("dot") measured on the same baseline as the
+ * surrounding text — the building block for skill rating dots, custom
+ * bullets and status indicators that should not depend on font glyph
+ * coverage.
+ *
+ * @param diameter circle diameter in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder dot(double diameter, DocumentColor fill) {
+ return shape(ShapeOutline.circle(diameter), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline circle with an explicit fill and/or outline stroke — for
+ * example a filled dot ({@code ●}) or an outlined one ({@code ○}).
+ *
+ * @param diameter circle diameter in points
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @return this builder
+ */
+ public ParagraphBuilder dot(double diameter, DocumentColor fill, DocumentStroke stroke) {
+ return shape(ShapeOutline.circle(diameter), fill, stroke, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline ellipse measured on the surrounding text baseline.
+ *
+ * @param width target width in points
+ * @param height target height in points ({@code width == height} renders a circle)
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @return this builder
+ */
+ public ParagraphBuilder ellipse(double width, double height, DocumentColor fill, DocumentStroke stroke) {
+ return shape(new ShapeOutline.Ellipse(width, height), fill, stroke, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline diamond (rhombus) sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder diamond(double size, DocumentColor fill) {
+ return shape(ShapeOutline.diamond(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline upward-pointing triangle sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder triangle(double size, DocumentColor fill) {
+ return shape(ShapeOutline.triangle(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline five-pointed star sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder star(double size, DocumentColor fill) {
+ return shape(ShapeOutline.star(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline block arrow sized {@code size × size} pointing in
+ * {@code direction} — a directional marker between text or a list bullet.
+ *
+ * @param size figure width and height in points
+ * @param direction the way the arrow points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder arrow(double size, ShapeOutline.Direction direction, DocumentColor fill) {
+ return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline chevron sized {@code size × size} pointing in
+ * {@code direction} — a lighter directional separator for step lists.
+ *
+ * @param size figure width and height in points
+ * @param direction the way the chevron points
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder chevron(double size, ShapeOutline.Direction direction, DocumentColor fill) {
+ return shape(ShapeOutline.chevron(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline shape of any {@link ShapeOutline} kind with a filled
+ * interior, default {@link InlineImageAlignment#CENTER} alignment and zero
+ * offset.
+ *
+ * @param outline figure geometry; supplies the run's size
+ * @param fill fill color
+ * @return this builder
+ */
+ public ParagraphBuilder shape(ShapeOutline outline, DocumentColor fill) {
+ return shape(outline, fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Adds an inline shape of any {@link ShapeOutline} kind, measured on the
+ * surrounding text baseline. At least one of {@code fill} or {@code stroke}
+ * must be present; vertical alignment defaults to
+ * {@link InlineImageAlignment#CENTER} when {@code null}. The figure is drawn
+ * from geometry, so it never depends on font glyph coverage.
+ *
+ * @param outline figure geometry; supplies the run's size
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @param alignment vertical alignment relative to surrounding text
+ * @param baselineOffset extra vertical shift in points; positive moves up
+ * @param linkOptions optional inline link metadata
+ * @return this builder
+ */
+ public ParagraphBuilder shape(ShapeOutline outline,
+ DocumentColor fill,
+ DocumentStroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions) {
+ this.inlineRuns.add(new InlineShapeRun(
+ outline,
+ fill,
+ stroke,
+ alignment == null ? InlineImageAlignment.CENTER : alignment,
+ baselineOffset,
+ linkOptions));
+ this.text = "";
+ return this;
+ }
+
/**
* Replaces inline runs with the contents of a {@link RichText} builder.
*
diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java
index c588375b..d24cd485 100644
--- a/src/main/java/com/demcha/compose/document/dsl/RichText.java
+++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java
@@ -3,12 +3,15 @@
import com.demcha.compose.document.image.DocumentImageData;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.node.InlineShapeRun;
import com.demcha.compose.document.node.InlineImageRun;
import com.demcha.compose.document.node.InlineRun;
import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.ShapeOutline;
import java.awt.Color;
import java.util.ArrayList;
@@ -309,6 +312,146 @@ public RichText image(DocumentImageData imageData,
return this;
}
+ /**
+ * Appends an inline filled circle ("dot") run — the building block for
+ * skill rating dots, custom bullets and inline status indicators that
+ * should not depend on font glyph coverage.
+ *
+ * @param diameter circle diameter in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText dot(double diameter, DocumentColor fill) {
+ return shape(ShapeOutline.circle(diameter), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline circle run with an explicit fill and/or outline stroke
+ * — for example a filled dot ({@code ●}) or an outlined one ({@code ○}).
+ *
+ * @param diameter circle diameter in points
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @return this builder
+ */
+ public RichText dot(double diameter, DocumentColor fill, DocumentStroke stroke) {
+ return shape(ShapeOutline.circle(diameter), fill, stroke, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline ellipse run with default
+ * {@link InlineImageAlignment#CENTER} alignment and zero offset.
+ *
+ * @param width target width in points
+ * @param height target height in points
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @return this builder
+ */
+ public RichText ellipse(double width, double height, DocumentColor fill, DocumentStroke stroke) {
+ return shape(new ShapeOutline.Ellipse(width, height), fill, stroke, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline diamond (rhombus) sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText diamond(double size, DocumentColor fill) {
+ return shape(ShapeOutline.diamond(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline upward-pointing triangle sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText triangle(double size, DocumentColor fill) {
+ return shape(ShapeOutline.triangle(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline five-pointed star sized {@code size × size}.
+ *
+ * @param size figure width and height in points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText star(double size, DocumentColor fill) {
+ return shape(ShapeOutline.star(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline block arrow sized {@code size × size} pointing in
+ * {@code direction} — a directional marker between text or a list bullet.
+ *
+ * @param size figure width and height in points
+ * @param direction the way the arrow points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText arrow(double size, ShapeOutline.Direction direction, DocumentColor fill) {
+ return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline chevron sized {@code size × size} pointing in
+ * {@code direction} — a lighter directional separator for step lists.
+ *
+ * @param size figure width and height in points
+ * @param direction the way the chevron points
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText chevron(double size, ShapeOutline.Direction direction, DocumentColor fill) {
+ return shape(ShapeOutline.chevron(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends an inline shape of any {@link ShapeOutline} kind with a filled
+ * interior, default {@link InlineImageAlignment#CENTER} alignment and zero
+ * offset.
+ *
+ * @param outline figure geometry; supplies the run's size
+ * @param fill fill color
+ * @return this builder
+ */
+ public RichText shape(ShapeOutline outline, DocumentColor fill) {
+ return shape(outline, fill, null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+
+ /**
+ * Appends a fully-specified inline shape run of any {@link ShapeOutline}
+ * kind. At least one of {@code fill} or {@code stroke} must be present.
+ *
+ * @param outline figure geometry; supplies the run's size
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @param alignment vertical alignment relative to surrounding text
+ * @param baselineOffset extra vertical shift in points; positive moves up
+ * @param linkOptions optional inline link metadata
+ * @return this builder
+ */
+ public RichText shape(ShapeOutline outline,
+ DocumentColor fill,
+ DocumentStroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions) {
+ runs.add(new InlineShapeRun(
+ outline,
+ fill,
+ stroke,
+ alignment == null ? InlineImageAlignment.CENTER : alignment,
+ baselineOffset,
+ linkOptions));
+ return this;
+ }
+
/**
* Returns the accumulated runs as an immutable list.
*
diff --git a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
index 471e8bcb..5b30ce54 100644
--- a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java
@@ -107,6 +107,81 @@ public ShapeContainerBuilder circle(double diameter) {
return this;
}
+ /**
+ * Sets a diamond (rhombus) outline.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @return this builder
+ */
+ public ShapeContainerBuilder diamond(double width, double height) {
+ this.outline = ShapeOutline.diamond(width, height);
+ return this;
+ }
+
+ /**
+ * Sets an upward-pointing triangle outline.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @return this builder
+ */
+ public ShapeContainerBuilder triangle(double width, double height) {
+ this.outline = ShapeOutline.triangle(width, height);
+ return this;
+ }
+
+ /**
+ * Sets a five-pointed star outline.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @return this builder
+ */
+ public ShapeContainerBuilder star(double width, double height) {
+ this.outline = ShapeOutline.star(width, height);
+ return this;
+ }
+
+ /**
+ * Sets an {@code n}-pointed star outline.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @param points number of outer points (at least 2)
+ * @return this builder
+ */
+ public ShapeContainerBuilder star(double width, double height, int points) {
+ this.outline = ShapeOutline.star(width, height, points);
+ return this;
+ }
+
+ /**
+ * Sets a block arrow outline pointing in {@code direction}.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @param direction the way the arrow points
+ * @return this builder
+ */
+ public ShapeContainerBuilder arrow(double width, double height, ShapeOutline.Direction direction) {
+ this.outline = ShapeOutline.arrow(width, height, direction);
+ return this;
+ }
+
+ /**
+ * Sets a chevron outline pointing in {@code direction}.
+ *
+ * @param width outline width in points
+ * @param height outline height in points
+ * @param direction the way the chevron points
+ * @return this builder
+ */
+ public ShapeContainerBuilder chevron(double width, double height, ShapeOutline.Direction direction) {
+ this.outline = ShapeOutline.chevron(width, height, direction);
+ return this;
+ }
+
/**
* Replaces the outline with a pre-built {@link ShapeOutline} value.
*
diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
index 25bc421e..663a5652 100644
--- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
+++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
@@ -1,5 +1,6 @@
package com.demcha.compose.document.layout;
+import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphImageSpan;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
@@ -9,6 +10,7 @@
import com.demcha.compose.document.layout.payloads.PreparedListLayout;
import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout;
import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.InlineShapeRun;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineImageRun;
import com.demcha.compose.document.node.InlineRun;
@@ -22,7 +24,9 @@
import com.demcha.compose.document.style.DocumentTextAutoSize;
import com.demcha.compose.document.style.DocumentTextIndent;
import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.style.ShapeOutline;
import com.demcha.compose.engine.components.content.ImageData;
+import com.demcha.compose.engine.components.content.shape.Stroke;
import com.demcha.compose.engine.components.content.text.TextDataBody;
import com.demcha.compose.engine.components.content.text.TextIndentStrategy;
import com.demcha.compose.engine.components.content.text.TextStyle;
@@ -31,6 +35,7 @@
import com.demcha.compose.engine.measurement.TextMeasurementSystem;
import com.demcha.compose.engine.text.markdown.MarkDownParser;
+import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -38,6 +43,7 @@
import static com.demcha.compose.document.layout.DocumentNodeAdapters.toImageData;
import static com.demcha.compose.document.layout.DocumentNodeAdapters.toIndentStrategy;
import static com.demcha.compose.document.layout.DocumentNodeAdapters.toPadding;
+import static com.demcha.compose.document.layout.DocumentNodeAdapters.toStroke;
import static com.demcha.compose.document.layout.DocumentNodeAdapters.toTextStyle;
import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS;
@@ -601,6 +607,8 @@ private static boolean paragraphFitsSingleLine(ParagraphNode node,
width += measurement.textWidth(engineStyle, textRun.text());
} else if (run instanceof InlineImageRun imageRun) {
width += imageRun.width();
+ } else if (run instanceof InlineShapeRun shapeRun) {
+ width += shapeRun.outline().width();
}
}
return width <= innerWidth;
@@ -1253,6 +1261,8 @@ private static List> tokenizeInlineRuns(List
}
} else if (run instanceof InlineImageRun imageRun) {
currentLine.add(InlineImageToken.of(imageRun));
+ } else if (run instanceof InlineShapeRun shapeRun) {
+ currentLine.add(InlineShapeToken.of(shapeRun));
}
}
@@ -1290,15 +1300,19 @@ private static ParagraphLine toInlineParagraphLine(List token
dominantBaselineFromBottom = defaultMetrics.baselineOffsetFromBottom();
}
- double maxImageHeight = 0.0;
+ double maxInlineGraphicHeight = 0.0;
for (InlineLayoutToken token : trimmedTokens) {
if (token instanceof InlineImageToken imageToken) {
- if (imageToken.height() > maxImageHeight) {
- maxImageHeight = imageToken.height();
+ if (imageToken.height() > maxInlineGraphicHeight) {
+ maxInlineGraphicHeight = imageToken.height();
+ }
+ } else if (token instanceof InlineShapeToken shapeToken) {
+ if (shapeToken.height() > maxInlineGraphicHeight) {
+ maxInlineGraphicHeight = shapeToken.height();
}
}
}
- double resolvedLineHeight = Math.max(dominantTextLineHeight, maxImageHeight);
+ double resolvedLineHeight = Math.max(dominantTextLineHeight, maxInlineGraphicHeight);
List spans = new ArrayList<>(trimmedTokens.size());
StringBuilder text = new StringBuilder();
@@ -1322,6 +1336,15 @@ private static ParagraphLine toInlineParagraphLine(List token
imageToken.baselineOffset(),
imageToken.linkOptions()));
width += imageToken.width();
+ } else if (token instanceof InlineShapeToken shapeToken) {
+ spans.add(new ParagraphShapeSpan(
+ shapeToken.outline(),
+ shapeToken.fillColor(),
+ shapeToken.stroke(),
+ shapeToken.alignment(),
+ shapeToken.baselineOffset(),
+ shapeToken.linkOptions()));
+ width += shapeToken.width();
}
}
@@ -1537,7 +1560,7 @@ private static ParagraphIndentSpec from(String bulletOffset,
}
}
- private sealed interface InlineLayoutToken permits InlineTextToken, InlineImageToken {
+ private sealed interface InlineLayoutToken permits InlineTextToken, InlineImageToken, InlineShapeToken {
double width();
}
@@ -1586,4 +1609,36 @@ private static InlineImageToken of(InlineImageRun run) {
run.linkOptions());
}
}
+
+ private record InlineShapeToken(
+ ShapeOutline outline,
+ Color fillColor,
+ Stroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+ ) implements InlineLayoutToken {
+ private InlineShapeToken {
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+
+ @Override
+ public double width() {
+ return outline.width();
+ }
+
+ private double height() {
+ return outline.height();
+ }
+
+ private static InlineShapeToken of(InlineShapeRun run) {
+ return new InlineShapeToken(
+ run.outline(),
+ run.fill() == null ? null : run.fill().color(),
+ toStroke(run.stroke()),
+ run.alignment(),
+ run.baselineOffset(),
+ run.linkOptions());
+ }
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
index fd27f4a7..61dfc77c 100644
--- a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
+++ b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java
@@ -3,6 +3,7 @@
import com.demcha.compose.document.layout.BoxConstraints;
import com.demcha.compose.document.layout.CompositeLayoutSpec;
import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
import com.demcha.compose.document.layout.payloads.PreparedStackLayout;
import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload;
import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload;
@@ -140,6 +141,15 @@ public List emitFragments(PreparedNode prepa
width,
height,
new ShapeFragmentPayload(awtFill, stroke, r.cornerRadius(), null, null, null));
+ } else if (outline instanceof ShapeOutline.Polygon p) {
+ outlineFragment = new LayoutFragment(
+ placement.path(),
+ 0,
+ padLeft,
+ padBottom,
+ width,
+ height,
+ new PolygonFragmentPayload(p.points(), awtFill, stroke, null, null));
} else {
throw new IllegalStateException("Unsupported shape outline: " + outline);
}
diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
new file mode 100644
index 00000000..b9fa70ba
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
@@ -0,0 +1,52 @@
+package com.demcha.compose.document.layout.payloads;
+
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+
+import java.awt.Color;
+import java.util.Objects;
+
+/**
+ * Measured inline shape span inside a paragraph line.
+ *
+ * The semantic {@code InlineShapeRun} is resolved into this payload during
+ * wrapping: the DSL fill color becomes an AWT {@link Color} and the DSL stroke
+ * becomes an engine {@link Stroke}, while the {@link ShapeOutline} carries the
+ * figure geometry (and its intrinsic {@link #width()} / {@link #height()}). The
+ * PDF backend dispatches on the outline kind to paint the figure.
+ *
+ * @param outline figure geometry; supplies the span width and height
+ * @param fillColor optional resolved fill color
+ * @param stroke optional resolved outline stroke
+ * @param alignment vertical alignment relative to the surrounding text
+ * @param baselineOffset extra vertical offset in points; positive moves up
+ * @param linkOptions optional link metadata
+ */
+public record ParagraphShapeSpan(
+ ShapeOutline outline,
+ Color fillColor,
+ Stroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+) implements ParagraphSpan {
+ /**
+ * Validates the outline and normalizes alignment defaults.
+ */
+ public ParagraphShapeSpan {
+ Objects.requireNonNull(outline, "outline");
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+
+ @Override
+ public double width() {
+ return outline.width();
+ }
+
+ @Override
+ public double height() {
+ return outline.height();
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java
index e5a165db..09537695 100644
--- a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java
@@ -4,10 +4,10 @@
/**
* One measured span inside a paragraph line. Sealed because the wrapping
- * algorithm can produce either text spans or image spans for the same
- * line — both contribute to wrapping width and per-line height.
+ * algorithm can produce text, image or shape spans for the same line — all
+ * contribute to wrapping width and per-line height.
*/
-public sealed interface ParagraphSpan permits ParagraphTextSpan, ParagraphImageSpan {
+public sealed interface ParagraphSpan permits ParagraphTextSpan, ParagraphImageSpan, ParagraphShapeSpan {
/**
* Measured width of this span.
*
diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java
new file mode 100644
index 00000000..eb5ff1c7
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java
@@ -0,0 +1,37 @@
+package com.demcha.compose.document.layout.payloads;
+
+import com.demcha.compose.document.node.DocumentBookmarkOptions;
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.style.ShapePoint;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+
+import java.awt.Color;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * PDF payload for a resolved polygon fragment (diamond, triangle, star or any
+ * vertex ring). The normalized vertices are scaled to the placed fragment's
+ * size by the render handler.
+ *
+ * @param points normalized vertex ring (at least three), in draw order
+ * @param fillColor optional fill color
+ * @param stroke optional stroke
+ * @param linkOptions optional fragment-level link metadata
+ * @param bookmarkOptions optional fragment-level bookmark metadata
+ */
+public record PolygonFragmentPayload(
+ List points,
+ Color fillColor,
+ Stroke stroke,
+ DocumentLinkOptions linkOptions,
+ DocumentBookmarkOptions bookmarkOptions
+) implements PdfSemanticFragmentPayload {
+ /**
+ * Copies the vertex ring defensively.
+ */
+ public PolygonFragmentPayload {
+ Objects.requireNonNull(points, "points");
+ points = List.copyOf(points);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/node/InlineRun.java b/src/main/java/com/demcha/compose/document/node/InlineRun.java
index b3c52284..cfceca1b 100644
--- a/src/main/java/com/demcha/compose/document/node/InlineRun.java
+++ b/src/main/java/com/demcha/compose/document/node/InlineRun.java
@@ -4,11 +4,12 @@
* Marker for a single inline run inside a {@link ParagraphNode}.
*
* An inline paragraph is a sequence of runs measured and rendered on the
- * same baseline. Today there are two kinds of run: text and image. Both
- * participate in the wrapping algorithm so callers can mix small icons or
- * badges with styled text without resorting to nested layouts.
+ * same baseline. Today there are three kinds of run: text, image and shape.
+ * All participate in the wrapping algorithm so callers can mix small icons,
+ * badges or geometric figures (dots, diamonds, stars, …) with styled text
+ * without resorting to nested layouts.
*
* @author Artem Demchyshyn
*/
-public sealed interface InlineRun permits InlineTextRun, InlineImageRun {
+public sealed interface InlineRun permits InlineTextRun, InlineImageRun, InlineShapeRun {
}
diff --git a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
new file mode 100644
index 00000000..2ff8591d
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
@@ -0,0 +1,77 @@
+package com.demcha.compose.document.node;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.ShapeOutline;
+
+import java.util.Objects;
+
+/**
+ * One inline shape run inside a {@link ParagraphNode} — a geometric figure
+ * (circle / ellipse, rectangle, rounded rectangle, diamond, triangle, star, or
+ * any {@link ShapeOutline}) measured and rendered on the surrounding text
+ * baseline.
+ *
+ * Inline shapes are measured as part of paragraph wrapping exactly like
+ * {@link InlineImageRun}: the outline's width and height contribute to span
+ * placement, line breaking and per-line height, and the figure shares the text
+ * baseline. The shape is drawn directly from geometry — no raster payload and
+ * no font glyph — so skill rating dots ({@code Java ●●●●○}), custom bullets and
+ * inline status markers render regardless of font coverage. The kind is the
+ * existing {@code ShapeOutline} taxonomy, so any new outline kind is usable
+ * inline automatically.
+ *
+ * At least one of {@code fill} or {@code stroke} must be present, otherwise
+ * the run would be invisible: a filled figure uses {@code fill} with a
+ * {@code null} stroke; an outlined figure uses a {@code null} fill with a
+ * {@code stroke}; the two combined paint a filled-and-outlined figure.
+ *
+ * @param outline shape geometry; its {@link ShapeOutline#width()} and
+ * {@link ShapeOutline#height()} are the run's measured size
+ * @param fill optional fill color; {@code null} leaves the interior empty
+ * @param stroke optional outline stroke; {@code null} leaves the figure
+ * without a border
+ * @param alignment vertical alignment relative to the surrounding text;
+ * defaults to {@link InlineImageAlignment#CENTER}
+ * @param baselineOffset extra vertical offset in points applied after
+ * {@code alignment} resolution; positive values move the
+ * figure up
+ * @param linkOptions optional per-run link metadata
+ *
+ * @author Artem Demchyshyn
+ * @since 1.7.0
+ */
+public record InlineShapeRun(
+ ShapeOutline outline,
+ DocumentColor fill,
+ DocumentStroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+) implements InlineRun {
+ /**
+ * Validates the outline, requires at least one visible paint, and
+ * normalizes alignment defaults.
+ */
+ public InlineShapeRun {
+ Objects.requireNonNull(outline, "outline");
+ if (Double.isNaN(baselineOffset) || Double.isInfinite(baselineOffset)) {
+ throw new IllegalArgumentException("inline shape baselineOffset must be finite: " + baselineOffset);
+ }
+ if (fill == null && stroke == null) {
+ throw new IllegalArgumentException("inline shape must have a fill, a stroke, or both");
+ }
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+
+ /**
+ * Convenience constructor for a filled shape with default
+ * {@link InlineImageAlignment#CENTER} alignment and zero offset.
+ *
+ * @param outline shape geometry
+ * @param fill fill color; must not be {@code null}
+ */
+ public InlineShapeRun(ShapeOutline outline, DocumentColor fill) {
+ this(outline, Objects.requireNonNull(fill, "fill"), null, InlineImageAlignment.CENTER, 0.0, null);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
index 29d89507..9ab51d4f 100644
--- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
+++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
@@ -1,5 +1,9 @@
package com.demcha.compose.document.style;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
/**
* Geometric outline of a shape container. Sealed so layout, render, and
* snapshot code can pattern-match exhaustively against the supported kinds.
@@ -14,7 +18,8 @@
public sealed interface ShapeOutline permits
ShapeOutline.Rectangle,
ShapeOutline.RoundedRectangle,
- ShapeOutline.Ellipse {
+ ShapeOutline.Ellipse,
+ ShapeOutline.Polygon {
/**
* Returns the outline outer width.
@@ -83,6 +88,49 @@ record Ellipse(double width, double height) implements ShapeOutline {
}
}
+ /**
+ * Closed polygon outline described by a ring of normalized vertices. The
+ * vertices live in a unit box (see {@link ShapePoint}) and are scaled to
+ * {@code width × height} at render time, so one vertex ring renders at any
+ * size. Diamonds, triangles, stars and arbitrary convex/concave polygons
+ * are all expressed through this single kind via the factories below.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param points ring of at least three normalized vertices, in draw order
+ * @since 1.7.0
+ */
+ record Polygon(double width, double height, List points) implements ShapeOutline {
+ /**
+ * Validates dimensions and copies the vertex ring defensively.
+ */
+ public Polygon {
+ requirePositive("width", width);
+ requirePositive("height", height);
+ Objects.requireNonNull(points, "points");
+ points = List.copyOf(points);
+ if (points.size() < 3) {
+ throw new IllegalArgumentException("polygon needs at least 3 points: " + points.size());
+ }
+ }
+ }
+
+ /**
+ * Cardinal direction for directional figures (arrows, chevrons).
+ *
+ * @since 1.7.0
+ */
+ enum Direction {
+ /** Pointing right. */
+ RIGHT,
+ /** Pointing left. */
+ LEFT,
+ /** Pointing up. */
+ UP,
+ /** Pointing down. */
+ DOWN
+ }
+
/**
* Convenience factory for a circular {@link Ellipse}.
*
@@ -93,6 +141,251 @@ static Ellipse circle(double diameter) {
return new Ellipse(diameter, diameter);
}
+ /**
+ * Creates a {@link Polygon} from an explicit ring of normalized vertices.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param points ring of at least three normalized vertices, in draw order
+ * @return polygon outline
+ */
+ static Polygon polygon(double width, double height, List points) {
+ return new Polygon(width, height, points);
+ }
+
+ /**
+ * Creates a four-point diamond (rhombus) inscribed in the box.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return diamond polygon outline
+ */
+ static Polygon diamond(double width, double height) {
+ return new Polygon(width, height, List.of(
+ new ShapePoint(0.5, 1.0),
+ new ShapePoint(1.0, 0.5),
+ new ShapePoint(0.5, 0.0),
+ new ShapePoint(0.0, 0.5)));
+ }
+
+ /**
+ * Creates an upward-pointing triangle inscribed in the box.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return triangle polygon outline
+ */
+ static Polygon triangle(double width, double height) {
+ return new Polygon(width, height, List.of(
+ new ShapePoint(0.5, 1.0),
+ new ShapePoint(1.0, 0.0),
+ new ShapePoint(0.0, 0.0)));
+ }
+
+ /**
+ * Creates a five-pointed star inscribed in the box.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return five-pointed star polygon outline
+ */
+ static Polygon star(double width, double height) {
+ return star(width, height, 5);
+ }
+
+ /**
+ * Creates an {@code n}-pointed star inscribed in the box, with the first
+ * point facing up.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param points number of outer points (at least 3)
+ * @return star polygon outline
+ */
+ static Polygon star(double width, double height, int points) {
+ if (points < 3) {
+ throw new IllegalArgumentException("star needs at least 3 points: " + points);
+ }
+ double outerRadius = 0.5;
+ // Inner/outer ratio of a true star polygon (the inner ring sits on the
+ // chords between outer points); it tends to 1 as the point count grows
+ // and equals the classic 0.382 at five points. Below five points the
+ // formula degenerates, so fall back to a fixed spiky ratio.
+ double innerRatio = points >= 5
+ ? Math.cos(2 * Math.PI / points) / Math.cos(Math.PI / points)
+ : 0.38;
+ double innerRadius = 0.5 * innerRatio;
+ double start = Math.PI / 2.0; // first outer vertex faces up
+ List vertices = new ArrayList<>(points * 2);
+ for (int i = 0; i < points * 2; i++) {
+ double radius = (i % 2 == 0) ? outerRadius : innerRadius;
+ double angle = start + i * Math.PI / points;
+ double x = clampUnit(0.5 + radius * Math.cos(angle));
+ double y = clampUnit(0.5 + radius * Math.sin(angle));
+ vertices.add(new ShapePoint(x, y));
+ }
+ return new Polygon(width, height, vertices);
+ }
+
+ /**
+ * Creates a block arrow pointing in {@code direction} — a list bullet or an
+ * inline marker between text ("Step 1 → Step 2").
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param direction the way the arrow points
+ * @return arrow polygon outline
+ */
+ static Polygon arrow(double width, double height, Direction direction) {
+ Objects.requireNonNull(direction, "direction");
+ double[][] base = {
+ {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88},
+ {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35}
+ };
+ return new Polygon(width, height, directional(base, direction));
+ }
+
+ /**
+ * Creates a right-pointing block arrow.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return right arrow polygon outline
+ */
+ static Polygon arrowRight(double width, double height) {
+ return arrow(width, height, Direction.RIGHT);
+ }
+
+ /**
+ * Creates a left-pointing block arrow.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return left arrow polygon outline
+ */
+ static Polygon arrowLeft(double width, double height) {
+ return arrow(width, height, Direction.LEFT);
+ }
+
+ /**
+ * Creates a chevron ("›") pointing in {@code direction} — a lighter
+ * directional marker for breadcrumbs and step lists.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param direction the way the chevron points
+ * @return chevron polygon outline
+ */
+ static Polygon chevron(double width, double height, Direction direction) {
+ Objects.requireNonNull(direction, "direction");
+ double thickness = 0.45;
+ double[][] base = {
+ {0.00, 1.00}, {1.00, 0.50}, {0.00, 0.00},
+ {thickness, 0.00}, {1.00 - thickness, 0.50}, {thickness, 1.00}
+ };
+ return new Polygon(width, height, directional(base, direction));
+ }
+
+ /**
+ * Creates a checkmark ("✓") figure for "done" items in checklists.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return checkmark polygon outline
+ */
+ static Polygon checkmark(double width, double height) {
+ double[][] points = {
+ {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92},
+ {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44}
+ };
+ return new Polygon(width, height, toPoints(points));
+ }
+
+ /**
+ * Creates a plus ("+") figure for "add" affordances or checklist markers.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @return plus polygon outline
+ */
+ static Polygon plus(double width, double height) {
+ double low = 0.34;
+ double high = 0.66;
+ double[][] points = {
+ {low, 0.00}, {high, 0.00}, {high, low}, {1.00, low},
+ {1.00, high}, {high, high}, {high, 1.00}, {low, 1.00},
+ {low, high}, {0.00, high}, {0.00, low}, {low, low}
+ };
+ return new Polygon(width, height, toPoints(points));
+ }
+
+ /**
+ * Creates a regular {@code sides}-gon (pentagon, hexagon, …) inscribed in
+ * the box, with the first vertex facing up.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param sides number of sides (at least 3)
+ * @return regular polygon outline
+ */
+ static Polygon regularPolygon(double width, double height, int sides) {
+ if (sides < 3) {
+ throw new IllegalArgumentException("regular polygon needs at least 3 sides: " + sides);
+ }
+ double start = Math.PI / 2.0;
+ List vertices = new ArrayList<>(sides);
+ for (int i = 0; i < sides; i++) {
+ double angle = start + i * 2.0 * Math.PI / sides;
+ vertices.add(new ShapePoint(
+ clampUnit(0.5 + 0.5 * Math.cos(angle)),
+ clampUnit(0.5 + 0.5 * Math.sin(angle))));
+ }
+ return new Polygon(width, height, vertices);
+ }
+
+ private static List directional(double[][] base, Direction direction) {
+ Direction resolved = direction == null ? Direction.RIGHT : direction;
+ List points = new ArrayList<>(base.length);
+ for (double[] vertex : base) {
+ double x = vertex[0];
+ double y = vertex[1];
+ double tx;
+ double ty;
+ switch (resolved) {
+ case LEFT -> {
+ tx = 1.0 - x;
+ ty = y;
+ }
+ case UP -> {
+ tx = y;
+ ty = x;
+ }
+ case DOWN -> {
+ tx = y;
+ ty = 1.0 - x;
+ }
+ default -> {
+ tx = x;
+ ty = y;
+ }
+ }
+ points.add(new ShapePoint(clampUnit(tx), clampUnit(ty)));
+ }
+ return points;
+ }
+
+ private static List toPoints(double[][] raw) {
+ List points = new ArrayList<>(raw.length);
+ for (double[] vertex : raw) {
+ points.add(new ShapePoint(clampUnit(vertex[0]), clampUnit(vertex[1])));
+ }
+ return points;
+ }
+
+ private static double clampUnit(double value) {
+ return value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value);
+ }
+
private static void requirePositive(String label, double value) {
if (value <= 0 || Double.isNaN(value) || Double.isInfinite(value)) {
throw new IllegalArgumentException(label + " must be finite and positive: " + value);
diff --git a/src/main/java/com/demcha/compose/document/style/ShapePoint.java b/src/main/java/com/demcha/compose/document/style/ShapePoint.java
new file mode 100644
index 00000000..f56f8862
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/style/ShapePoint.java
@@ -0,0 +1,29 @@
+package com.demcha.compose.document.style;
+
+/**
+ * A normalized vertex of a {@link ShapeOutline.Polygon}, expressed in the
+ * outline's own unit box: {@code x} runs 0 (left) → 1 (right), {@code y} runs
+ * 0 (bottom) → 1 (top), following the PDF y-up convention. Points are scaled to
+ * the outline's {@code width × height} at render time, so the same normalized
+ * polygon renders at any size.
+ *
+ * @param x normalized horizontal position in {@code [0, 1]}
+ * @param y normalized vertical position in {@code [0, 1]} (0 = bottom, 1 = top)
+ * @author Artem Demchyshyn
+ * @since 1.7.0
+ */
+public record ShapePoint(double x, double y) {
+ /**
+ * Validates that both coordinates are finite and within the unit box.
+ */
+ public ShapePoint {
+ requireUnit("x", x);
+ requireUnit("y", y);
+ }
+
+ private static void requireUnit(String label, double value) {
+ if (Double.isNaN(value) || Double.isInfinite(value) || value < 0.0 || value > 1.0) {
+ throw new IllegalArgumentException(label + " must be a finite value within [0, 1]: " + value);
+ }
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
new file mode 100644
index 00000000..07c66085
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
@@ -0,0 +1,148 @@
+package com.demcha.compose.document.dsl;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.InlineImageAlignment;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.ShapeOutline;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.awt.image.BufferedImage;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * End-to-end coverage for inline shape runs: the measure → tokenize → span →
+ * PDF render pipeline must paint geometric figures (dots, diamonds, stars, …)
+ * without dropping them or substituting glyphs.
+ */
+class InlineShapeRenderTest {
+
+ private static final DocumentColor ACCENT = DocumentColor.of(new java.awt.Color(40, 90, 180));
+
+ @Test
+ void ratingShapesRenderEndToEndKeepingTextWithoutGlyphSubstitution() throws Exception {
+ byte[] pdf = renderRatingRow();
+ assertThat(pdf).isNotEmpty();
+
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ assertThat(document.getNumberOfPages()).isEqualTo(1);
+ String text = new PDFTextStripper().getText(document);
+ assertThat(text).contains("Java");
+ assertThat(text).doesNotContain("?");
+ }
+ }
+
+ @Test
+ void inlineShapesActuallyPaintTheirFillColor() throws Exception {
+ try (PDDocument document = Loader.loadPDF(renderRatingRow())) {
+ BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 96);
+ // The accent fill only enters the page through the inline figures —
+ // the text is default black and the background white — so finding
+ // accent pixels proves the figures were drawn, not silently dropped.
+ assertThat(containsColorNear(image, 40, 90, 180, 45))
+ .as("inline shapes must paint their accent fill")
+ .isTrue();
+ }
+ }
+
+ @Test
+ void linkedInlineShapeEmitsClickableAnnotation() throws Exception {
+ byte[] pdf;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(220, 120)
+ .margin(14, 14, 14, 14)
+ .create()) {
+ session.dsl()
+ .pageFlow()
+ .name("Flow")
+ .addParagraph(paragraph -> paragraph
+ .inlineText("Home ")
+ .shape(ShapeOutline.diamond(8, 8), ACCENT, null,
+ InlineImageAlignment.CENTER, 0.0,
+ new DocumentLinkOptions("https://example.com")))
+ .build();
+ pdf = session.toPdfBytes();
+ }
+
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ assertThat(document.getPage(0).getAnnotations())
+ .anyMatch(annotation -> annotation instanceof PDAnnotationLink);
+ }
+ }
+
+ @Test
+ void everyOutlineKindRendersWithoutThrowing() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(280, 160)
+ .margin(14, 14, 14, 14)
+ .create()) {
+ session.dsl()
+ .pageFlow()
+ .name("Flow")
+ .addParagraph(paragraph -> paragraph
+ .inlineText("Shapes ")
+ .shape(new ShapeOutline.Rectangle(8, 8), ACCENT)
+ .shape(new ShapeOutline.RoundedRectangle(8, 8, 2), ACCENT)
+ .shape(new ShapeOutline.Ellipse(8, 8), ACCENT, null,
+ InlineImageAlignment.TEXT_TOP, 0.0, null)
+ .diamond(8, ACCENT)
+ .triangle(8, ACCENT)
+ .star(8, ACCENT)
+ .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT)
+ .chevron(8, ShapeOutline.Direction.LEFT, ACCENT)
+ .shape(ShapeOutline.checkmark(8, 8), ACCENT)
+ .shape(ShapeOutline.plus(8, 8), ACCENT)
+ .shape(ShapeOutline.regularPolygon(8, 8, 6), ACCENT))
+ .build();
+ assertThat(session.toPdfBytes()).isNotEmpty();
+ }
+ }
+
+ private static byte[] renderRatingRow() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(320, 160)
+ .margin(16, 16, 16, 16)
+ .create()) {
+ session.dsl()
+ .pageFlow()
+ .name("Flow")
+ .addParagraph(paragraph -> paragraph
+ .name("SkillRating")
+ .inlineText("Java ")
+ .dot(7, ACCENT)
+ .dot(7, ACCENT)
+ .dot(7, ACCENT)
+ .dot(7, null, DocumentStroke.of(ACCENT, 0.6))
+ .inlineText(" ")
+ .diamond(8, ACCENT)
+ .star(9, ACCENT))
+ .build();
+ return session.toPdfBytes();
+ }
+ }
+
+ private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) {
+ for (int y = 0; y < image.getHeight(); y++) {
+ for (int x = 0; x < image.getWidth(); x++) {
+ int rgb = image.getRGB(x, y);
+ int rr = (rgb >> 16) & 0xFF;
+ int gg = (rgb >> 8) & 0xFF;
+ int bb = rgb & 0xFF;
+ if (Math.abs(rr - r) <= tolerance
+ && Math.abs(gg - g) <= tolerance
+ && Math.abs(bb - b) <= tolerance) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
index 2759ecad..1d54c754 100644
--- a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
@@ -1,12 +1,16 @@
package com.demcha.compose.document.dsl;
import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineRun;
+import com.demcha.compose.document.node.InlineShapeRun;
import com.demcha.compose.document.node.InlineTextRun;
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.SectionNode;
import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.ShapeOutline;
import org.junit.jupiter.api.Test;
import java.awt.Color;
@@ -211,4 +215,77 @@ void documentDslRichTextBuildsEquivalentRunSequence() {
throw new RuntimeException(e);
}
}
+
+ @Test
+ void dotProducesFilledCircleShapeRunWithCenterDefault() {
+ List runs = RichText.empty().dot(6.0, RED).runs();
+ assertThat(runs).hasSize(1);
+ InlineShapeRun dot = (InlineShapeRun) runs.get(0);
+ assertThat(dot.outline()).isInstanceOf(ShapeOutline.Ellipse.class);
+ assertThat(dot.outline().width()).isEqualTo(6.0, within(EPS));
+ assertThat(dot.fill()).isEqualTo(RED);
+ assertThat(dot.stroke()).isNull();
+ assertThat(dot.alignment()).isEqualTo(InlineImageAlignment.CENTER);
+ }
+
+ @Test
+ void ratingDotsMixWithTextInSourceOrder() {
+ List runs = RichText.text("Java ")
+ .dot(5.0, ACCENT)
+ .dot(5.0, ACCENT)
+ .dot(5.0, null, DocumentStroke.of(ACCENT, 0.5))
+ .runs();
+
+ assertThat(runs).hasSize(4);
+ assertThat(runs.get(0)).isInstanceOf(InlineTextRun.class);
+ assertThat(runs.get(1)).isInstanceOf(InlineShapeRun.class);
+
+ InlineShapeRun outlined = (InlineShapeRun) runs.get(3);
+ assertThat(outlined.fill()).isNull();
+ assertThat(outlined.stroke()).isNotNull();
+ }
+
+ @Test
+ void diamondAndStarFactoriesProducePolygonShapeRuns() {
+ InlineShapeRun diamond = (InlineShapeRun) RichText.empty().diamond(8, ACCENT).runs().get(0);
+ InlineShapeRun star = (InlineShapeRun) RichText.empty().star(8, ACCENT).runs().get(0);
+
+ assertThat(diamond.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(star.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(((ShapeOutline.Polygon) star.outline()).points()).hasSize(10);
+ }
+
+ @Test
+ void shapeAcceptsAnyOutlineAlignmentAndOffset() {
+ InlineShapeRun run = (InlineShapeRun) RichText.empty()
+ .shape(new ShapeOutline.Rectangle(10, 6), RED, null, InlineImageAlignment.BASELINE, 1.5, null)
+ .runs().get(0);
+ assertThat(run.outline()).isInstanceOf(ShapeOutline.Rectangle.class);
+ assertThat(run.alignment()).isEqualTo(InlineImageAlignment.BASELINE);
+ assertThat(run.baselineOffset()).isEqualTo(1.5, within(EPS));
+ }
+
+ @Test
+ void paragraphBuilderDotAppendsShapeRunAfterText() {
+ ParagraphNode paragraph = new ParagraphBuilder()
+ .name("Rating")
+ .inlineText("Java ")
+ .dot(5.0, ACCENT)
+ .build();
+
+ assertThat(paragraph.inlineRuns()).hasSize(2);
+ assertThat(paragraph.inlineRuns().get(0)).isInstanceOf(InlineTextRun.class);
+ assertThat(paragraph.inlineRuns().get(1)).isInstanceOf(InlineShapeRun.class);
+ }
+
+ @Test
+ void arrowAndChevronFactoriesProduceDirectionalPolygonRuns() {
+ InlineShapeRun arrow = (InlineShapeRun) RichText.empty()
+ .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT).runs().get(0);
+ InlineShapeRun chevron = (InlineShapeRun) RichText.empty()
+ .chevron(8, ShapeOutline.Direction.LEFT, ACCENT).runs().get(0);
+
+ assertThat(arrow.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(chevron.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
new file mode 100644
index 00000000..ab196d3c
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
@@ -0,0 +1,87 @@
+package com.demcha.compose.document.node;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.ShapeOutline;
+import org.junit.jupiter.api.Test;
+
+import java.awt.Color;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+class InlineShapeRunTest {
+
+ private static final double EPS = 1e-6;
+ private static final DocumentColor FILL = DocumentColor.of(new Color(40, 90, 180));
+ private static final DocumentStroke STROKE = DocumentStroke.of(DocumentColor.BLACK, 0.5);
+
+ @Test
+ void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() {
+ InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(6.0), FILL);
+
+ assertThat(run.outline()).isEqualTo(ShapeOutline.circle(6.0));
+ assertThat(run.outline().width()).isEqualTo(6.0, within(EPS));
+ assertThat(run.fill()).isSameAs(FILL);
+ assertThat(run.stroke()).isNull();
+ assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER);
+ assertThat(run.baselineOffset()).isEqualTo(0.0, within(EPS));
+ assertThat(run.linkOptions()).isNull();
+ }
+
+ @Test
+ void carriesAnyOutlineKind() {
+ assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).outline())
+ .isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).outline())
+ .isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).outline())
+ .isInstanceOf(ShapeOutline.Rectangle.class);
+ }
+
+ @Test
+ void outlinedOnlyShapeIsAllowed() {
+ InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(8), null, STROKE, null, 0.0, null);
+
+ assertThat(run.fill()).isNull();
+ assertThat(run.stroke()).isSameAs(STROKE);
+ assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER);
+ }
+
+ @Test
+ void nullAlignmentNormalizesToCenter() {
+ InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(5), FILL, null, null, 0.0, null);
+
+ assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER);
+ }
+
+ @Test
+ void invisibleShapeWithoutFillOrStrokeIsRejected() {
+ assertThatThrownBy(() ->
+ new InlineShapeRun(ShapeOutline.circle(6), null, null, InlineImageAlignment.CENTER, 0.0, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("fill");
+ }
+
+ @Test
+ void nullOutlineIsRejected() {
+ assertThatThrownBy(() -> new InlineShapeRun(null, FILL))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void nonFiniteBaselineOffsetIsRejected() {
+ assertThatThrownBy(() ->
+ new InlineShapeRun(ShapeOutline.circle(6), FILL, null, InlineImageAlignment.CENTER,
+ Double.POSITIVE_INFINITY, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("baselineOffset");
+ }
+
+ @Test
+ void filledConvenienceConstructorRejectsNullFill() {
+ assertThatThrownBy(() -> new InlineShapeRun(ShapeOutline.circle(6), null))
+ .isInstanceOf(NullPointerException.class);
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
new file mode 100644
index 00000000..fdc3662c
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
@@ -0,0 +1,149 @@
+package com.demcha.compose.document.style;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+class ShapeOutlineTest {
+
+ private static final double EPS = 1e-6;
+
+ @Test
+ void circleIsAnEqualSidedEllipse() {
+ ShapeOutline.Ellipse circle = ShapeOutline.circle(10);
+ assertThat(circle.width()).isEqualTo(10.0, within(EPS));
+ assertThat(circle.height()).isEqualTo(10.0, within(EPS));
+ }
+
+ @Test
+ void diamondHasFourVerticesAndKeepsSize() {
+ ShapeOutline.Polygon diamond = ShapeOutline.diamond(12, 8);
+ assertThat(diamond.width()).isEqualTo(12.0, within(EPS));
+ assertThat(diamond.height()).isEqualTo(8.0, within(EPS));
+ assertThat(diamond.points()).hasSize(4);
+ }
+
+ @Test
+ void triangleHasThreeVertices() {
+ assertThat(ShapeOutline.triangle(10, 10).points()).hasSize(3);
+ }
+
+ @Test
+ void starHasTwiceThePointCountVertices() {
+ assertThat(ShapeOutline.star(10, 10).points()).hasSize(10);
+ assertThat(ShapeOutline.star(10, 10, 6).points()).hasSize(12);
+ }
+
+ @Test
+ void starVerticesStayWithinUnitBox() {
+ for (ShapePoint point : ShapeOutline.star(10, 10, 7).points()) {
+ assertThat(point.x()).isBetween(0.0, 1.0);
+ assertThat(point.y()).isBetween(0.0, 1.0);
+ }
+ }
+
+ @Test
+ void polygonRejectsFewerThanThreePoints() {
+ assertThatThrownBy(() -> ShapeOutline.polygon(10, 10,
+ List.of(new ShapePoint(0, 0), new ShapePoint(1, 1))))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at least 3");
+ }
+
+ @Test
+ void polygonCopiesItsVertexRingDefensively() {
+ List mutable = new ArrayList<>(List.of(
+ new ShapePoint(0, 0), new ShapePoint(1, 0), new ShapePoint(0.5, 1)));
+ ShapeOutline.Polygon polygon = ShapeOutline.polygon(10, 10, mutable);
+ mutable.clear();
+ assertThat(polygon.points()).hasSize(3);
+ }
+
+ @Test
+ void starRejectsFewerThanThreePoints() {
+ assertThatThrownBy(() -> ShapeOutline.star(10, 10, 2))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("at least 3");
+ }
+
+ @Test
+ void arrowAndChevronRejectNullDirection() {
+ assertThatThrownBy(() -> ShapeOutline.arrow(10, 10, null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> ShapeOutline.chevron(10, 10, null))
+ .isInstanceOf(NullPointerException.class);
+ }
+
+ @Test
+ void rectangleRejectsNonPositiveDimensions() {
+ assertThatThrownBy(() -> new ShapeOutline.Rectangle(0, 5))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void shapePointRejectsOutOfRangeOrNonFiniteCoordinates() {
+ assertThatThrownBy(() -> new ShapePoint(1.5, 0.5)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new ShapePoint(-0.1, 0.5)).isInstanceOf(IllegalArgumentException.class);
+ assertThatThrownBy(() -> new ShapePoint(0.5, Double.NaN)).isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void arrowHasSevenVerticesAndDirectionalTip() {
+ assertThat(ShapeOutline.arrowRight(10, 10).points()).hasSize(7);
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT).points())
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(1.0, within(EPS));
+ assertThat(p.y()).isEqualTo(0.5, within(EPS));
+ });
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.LEFT).points())
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(0.0, within(EPS));
+ assertThat(p.y()).isEqualTo(0.5, within(EPS));
+ });
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.UP).points())
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(0.5, within(EPS));
+ assertThat(p.y()).isEqualTo(1.0, within(EPS));
+ });
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.DOWN).points())
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(0.5, within(EPS));
+ assertThat(p.y()).isEqualTo(0.0, within(EPS));
+ });
+ }
+
+ @Test
+ void chevronCheckmarkAndPlusHaveExpectedVertexCounts() {
+ assertThat(ShapeOutline.chevron(10, 10, ShapeOutline.Direction.RIGHT).points()).hasSize(6);
+ assertThat(ShapeOutline.checkmark(10, 10).points()).hasSize(6);
+ assertThat(ShapeOutline.plus(10, 10).points()).hasSize(12);
+ }
+
+ @Test
+ void regularPolygonHasRequestedSidesAndRejectsTooFew() {
+ assertThat(ShapeOutline.regularPolygon(10, 10, 6).points()).hasSize(6);
+ assertThatThrownBy(() -> ShapeOutline.regularPolygon(10, 10, 2))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void everyPolygonFactoryStaysWithinUnitBox() {
+ List shapes = List.of(
+ ShapeOutline.arrow(10, 10, ShapeOutline.Direction.DOWN),
+ ShapeOutline.chevron(10, 10, ShapeOutline.Direction.UP),
+ ShapeOutline.checkmark(10, 10),
+ ShapeOutline.plus(10, 10),
+ ShapeOutline.regularPolygon(10, 10, 7));
+ for (ShapeOutline.Polygon shape : shapes) {
+ for (ShapePoint point : shape.points()) {
+ assertThat(point.x()).isBetween(0.0, 1.0);
+ assertThat(point.y()).isBetween(0.0, 1.0);
+ }
+ }
+ }
+}
From a34e6cf17b9241b5f90245339e4aeaeff1a305a6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 4 Jun 2026 14:30:36 +0100
Subject: [PATCH 2/2] feat(api): inline checkboxes + swappable checkmark/arrow
designs (@since 1.7.0)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- InlineShapeRun is now a ShapeLayer stack; checkbox(size, checked, ...) draws a
rounded frame plus, when checked, a centred tick — checked/unchecked todo markers.
- ShapeOutline adds CheckmarkStyle {CLASSIC, HEAVY} and ArrowStyle {BLOCK, TRIANGLE}
with checkmark(w,h,style) / arrow(w,h,dir,style) overloads; no-style factories
delegate to the defaults, so the look is unchanged. checkbox also takes a raw mark.
- RichText / ParagraphBuilder gain matching overloads; dense band geometry lives in
package-private ShapeRings.
- Example checklist + variants; README, committed preview and CHANGELOG updated.
---
CHANGELOG.md | 21 +++
assets/readme/examples/inline-shapes.pdf | Bin 3542 -> 4584 bytes
examples/README.md | 11 +-
.../features/text/InlineShapesExample.java | 46 +++--
.../PdfParagraphFragmentRenderHandler.java | 39 ++--
.../document/dsl/ParagraphBuilder.java | 95 ++++++++++
.../demcha/compose/document/dsl/RichText.java | 93 +++++++++
.../document/layout/TextFlowSupport.java | 41 ++--
.../layout/payloads/ParagraphShapeSpan.java | 42 ++---
.../layout/payloads/ResolvedShapeLayer.java | 18 ++
.../compose/document/node/InlineShapeRun.java | 177 +++++++++++++++---
.../compose/document/node/ShapeLayer.java | 46 +++++
.../compose/document/style/ShapeOutline.java | 86 ++++++++-
.../compose/document/style/ShapeRings.java | 62 ++++++
.../document/dsl/InlineShapeRenderTest.java | 69 +++++++
.../compose/document/dsl/RichTextTest.java | 55 ++++--
.../document/node/InlineShapeRunTest.java | 95 +++++++++-
.../document/style/ShapeOutlineTest.java | 49 +++++
.../document/style/ShapeRingsTest.java | 28 +++
19 files changed, 932 insertions(+), 141 deletions(-)
create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java
create mode 100644 src/main/java/com/demcha/compose/document/node/ShapeLayer.java
create mode 100644 src/main/java/com/demcha/compose/document/style/ShapeRings.java
create mode 100644 src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70a9195c..c0254a25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,27 @@ API turns the open cycle into a minor.
(every other kind is reachable through `shape(ShapeOutline, ...)`);
`ShapeContainerBuilder` exposes matching block outlines. Rectangle,
rounded-rectangle and ellipse shape containers are unchanged.
+- **Inline checkboxes + composite (multi-layer) inline figures.** An inline
+ shape run is now a stack of paint layers
+ (`com.demcha.compose.document.node.ShapeLayer`, `@since 1.7.0`) drawn overlaid
+ and centred, so a figure can compose several outlines — each with its own
+ fill/stroke — and still measure and place as one unit on the baseline.
+ `ParagraphBuilder` / `RichText` gain `checkbox(size, checked, color)` /
+ `checkbox(size, checked, boxColor, checkColor)` (`@since 1.7.0`): a rounded
+ frame plus, in the checked state, a centred tick — the todo / checklist marker
+ for "some items done, some not". The single-outline `InlineShapeRun`
+ convenience constructors are unchanged; every other kind still renders as one
+ layer.
+- **Swappable tick and arrow designs (the "pick your figure" seam).**
+ `ShapeOutline` adds `CheckmarkStyle` (`CLASSIC`, `HEAVY`) and `ArrowStyle`
+ (`BLOCK`, `TRIANGLE`) enums plus the overloads
+ `checkmark(w, h, CheckmarkStyle)` and `arrow(w, h, Direction, ArrowStyle)`
+ (`@since 1.7.0`); the no-style factories delegate to `CLASSIC` / `BLOCK`, so
+ the default look is unchanged. `checkbox(...)`, `RichText.arrow(...)` and
+ `ParagraphBuilder.arrow(...)` gain matching style overloads, and `checkbox`
+ also accepts a raw `ShapeOutline` mark for fully custom ticks. Adding a new
+ design is one enum constant plus its vertex ring — the foundation for letting
+ a caller choose which tick or arrow to render.
## v1.6.9 — 2026-06-03
diff --git a/assets/readme/examples/inline-shapes.pdf b/assets/readme/examples/inline-shapes.pdf
index d457e6587e9d0592d123343344b7013b69ec9afd..4b2acbf52bf742bd09d6eb2b24b501d797a65830 100644
GIT binary patch
delta 3970
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
diff --git a/examples/README.md b/examples/README.md
index cb877cd3..cf6ca5fe 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -60,7 +60,7 @@ are with the canonical DSL, then jump to its detailed section below.
| Example | What it shows | Preview · Source |
|---|---|---|
| [Rich text](#rich-text) | Every `RichText` method (bold / italic / underline / link / colour / accent / size / append) | [PDF](../assets/readme/examples/rich-text-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java) |
-| [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) |
+| [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks and checkboxes drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) |
| [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) |
| [Nested lists](#nested-lists-v16) | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) |
| [Composed table cells](#composed-table-cells-v16) | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) |
@@ -414,8 +414,10 @@ visual reference when picking which call to make for inline text.
`InlineShapeRun` (`@since 1.7.0`) draws geometric figures on the text
baseline from geometry — no font glyph needed — so rating dots, arrows,
-chevrons, diamonds, stars, checkmarks and any other `ShapeOutline` work
-between text and as list bullets, at any size and colour.
+chevrons, diamonds, stars, checkmarks, checkboxes (checked / unchecked
+todo markers) and any other `ShapeOutline` work between text and as list
+bullets, at any size and colour. The tick and arrow designs are swappable
+via `CheckmarkStyle` / `ArrowStyle`.
```java
.addRich(rich -> rich
@@ -425,7 +427,8 @@ between text and as list bullets, at any size and colour.
.arrow(8, ShapeOutline.Direction.RIGHT, accent)
.plain(" Published"))
// also: dot(size, fill), diamond, triangle, star, chevron,
-// or shape(ShapeOutline.checkmark(size, size), fill) for any figure
+// checkbox(size, checked, color) for todo markers, and
+// arrow(size, dir, ArrowStyle.TRIANGLE, fill) to pick a design variant
```
[📄 View PDF](../assets/readme/examples/inline-shapes.pdf) ·
diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
index a0b383f0..9b9b9e43 100644
--- a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
+++ b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
@@ -21,10 +21,12 @@
* Runnable showcase for inline shape runs ({@code @since 1.7.0}).
*
* Geometric figures — rating dots, arrows, chevrons, diamonds, stars,
- * checkmarks, plus signs, regular polygons — drawn on the text baseline from
- * geometry (no font glyphs), used between text and as list bullets. Each row
- * pairs the rendered output with the {@code ParagraphBuilder} / {@code RichText}
- * call that produced it, so the PDF reads like a quick reference.
+ * checkmarks, plus signs, regular polygons and checkboxes (checked / unchecked
+ * todo markers) — drawn on the text baseline from geometry (no font glyphs),
+ * used between text and as list bullets. The closing rows show the swappable
+ * {@code CheckmarkStyle} / {@code ArrowStyle} designs. Each row pairs the
+ * rendered output with the {@code ParagraphBuilder} / {@code RichText} call that
+ * produced it, so the PDF reads like a quick reference.
*/
public final class InlineShapesExample {
private static final BusinessTheme THEME = BusinessTheme.modern();
@@ -88,15 +90,17 @@ public static Path generate() throws Exception {
.softPanel(PANEL, 6, 12)
.spacing(5)
.addParagraph(p -> p
- .text("shape(ShapeOutline.checkmark(...)/plus(...), fill) — checklist markers")
+ .text("checkbox(size, checked, color) — todo markers, checked and unchecked")
.textStyle(caption())
.margin(DocumentInsets.zero()))
- .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN)
- .plain(" Figures render from geometry"))
- .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN)
- .plain(" They reuse the ShapeOutline taxonomy"))
- .addRich(rich -> rich.shape(ShapeOutline.plus(9, 9), ACCENT)
- .plain(" A new figure is one factory away")))
+ .addRich(rich -> rich.checkbox(10, true, GREEN)
+ .plain(" A checked box stamps a filled tick inside the frame"))
+ .addRich(rich -> rich.checkbox(10, true, GREEN)
+ .plain(" Both states share the same geometry pipeline"))
+ .addRich(rich -> rich.checkbox(10, false, MUTED)
+ .plain(" An empty box is the unchecked state"))
+ .addRich(rich -> rich.checkbox(10, false, MUTED)
+ .plain(" No font glyph, so it renders anywhere")))
.addSection("Bullets", section -> labelledRow(section,
"any ShapeOutline as a list bullet",
rich -> rich
@@ -105,6 +109,26 @@ public static Path generate() throws Exception {
.triangle(7, BRAND).plain(" Triangle ")
.arrow(8, ShapeOutline.Direction.RIGHT, BRAND).plain(" Arrow ")
.shape(ShapeOutline.regularPolygon(8, 8, 6), MUTED).plain(" Hexagon")))
+ .addSection("Variants", section -> section
+ .softPanel(PANEL, 6, 12)
+ .spacing(5)
+ .addParagraph(p -> p
+ .text("checkmark(w, h, CheckmarkStyle) · arrow(w, h, Direction, ArrowStyle) — swap the design")
+ .textStyle(caption())
+ .margin(DocumentInsets.zero()))
+ .addRich(rich -> rich
+ .plain("Tick ")
+ .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.CLASSIC), GREEN)
+ .plain(" classic ")
+ .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.HEAVY), GREEN)
+ .plain(" heavy Arrow ")
+ .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.BLOCK, ACCENT)
+ .plain(" block ")
+ .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT)
+ .plain(" triangle"))
+ .addRich(rich -> rich
+ .checkbox(11, true, ShapeOutline.CheckmarkStyle.HEAVY, BRAND, BRAND)
+ .plain(" A checkbox takes any tick variant — here HEAVY")))
.addSection("Footer", section -> section
.accentTop(THEME.palette().rule(), 0.6)
.padding(new DocumentInsets(8, 0, 0, 0))
diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
index f57c52b7..5c46d4e9 100644
--- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
+++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
@@ -5,6 +5,7 @@
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan;
+import com.demcha.compose.document.layout.payloads.ResolvedShapeLayer;
import com.demcha.compose.document.layout.payloads.ParagraphImageSpan;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.layout.payloads.ParagraphSpan;
@@ -206,23 +207,27 @@ private static void renderShape(PDPageContentStream stream,
textAscent,
baselineOffsetFromBottom,
lineHeight);
- float x = (float) cursorX;
- float y = (float) bottom;
- float w = (float) width;
- float h = (float) height;
- ShapeOutline outline = span.outline();
- PdfShapeGeometry.fillAndStrokePath(stream, span.fillColor(), span.stroke(), s -> {
- if (outline instanceof ShapeOutline.Ellipse) {
- PdfEllipseFragmentRenderHandler.drawEllipse(s, x, y, w, h);
- } else if (outline instanceof ShapeOutline.Rectangle) {
- s.addRect(x, y, w, h);
- } else if (outline instanceof ShapeOutline.RoundedRectangle r) {
- float radius = (float) Math.min(r.cornerRadius(), Math.min(w, h) / 2.0f);
- PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, x, y, w, h, radius, radius, radius, radius);
- } else if (outline instanceof ShapeOutline.Polygon p) {
- PdfShapeGeometry.addPolygonPath(s, x, y, w, h, p.points());
- }
- });
+ for (ResolvedShapeLayer layer : span.layers()) {
+ ShapeOutline outline = layer.outline();
+ float lw = (float) outline.width();
+ float lh = (float) outline.height();
+ // Each layer is centred within the run's bounding box, so a smaller
+ // checkmark sits inside its larger checkbox frame.
+ float lx = (float) (cursorX + (width - outline.width()) / 2.0);
+ float ly = (float) (bottom + (height - outline.height()) / 2.0);
+ PdfShapeGeometry.fillAndStrokePath(stream, layer.fillColor(), layer.stroke(), s -> {
+ if (outline instanceof ShapeOutline.Ellipse) {
+ PdfEllipseFragmentRenderHandler.drawEllipse(s, lx, ly, lw, lh);
+ } else if (outline instanceof ShapeOutline.Rectangle) {
+ s.addRect(lx, ly, lw, lh);
+ } else if (outline instanceof ShapeOutline.RoundedRectangle r) {
+ float radius = (float) Math.min(r.cornerRadius(), Math.min(lw, lh) / 2.0f);
+ PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, lx, ly, lw, lh, radius, radius, radius, radius);
+ } else if (outline instanceof ShapeOutline.Polygon p) {
+ PdfShapeGeometry.addPolygonPath(s, lx, ly, lw, lh, p.points());
+ }
+ });
+ }
}
}
diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
index 4148f474..d4b3d3eb 100644
--- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java
@@ -330,6 +330,25 @@ public ParagraphBuilder arrow(double size, ShapeOutline.Direction direction, Doc
return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
}
+ /**
+ * Adds an inline arrow of the given {@link ShapeOutline.ArrowStyle} — the
+ * swappable-design overload (block arrow, triangular arrowhead, …).
+ *
+ * @param size figure width and height in points
+ * @param direction the way the arrow points
+ * @param style the arrow design
+ * @param fill fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public ParagraphBuilder arrow(double size,
+ ShapeOutline.Direction direction,
+ ShapeOutline.ArrowStyle style,
+ DocumentColor fill) {
+ return shape(ShapeOutline.arrow(size, size, direction, style), fill, null,
+ InlineImageAlignment.CENTER, 0.0, null);
+ }
+
/**
* Adds an inline chevron sized {@code size × size} pointing in
* {@code direction} — a lighter directional separator for step lists.
@@ -388,6 +407,82 @@ public ParagraphBuilder shape(ShapeOutline outline,
return this;
}
+ /**
+ * Adds an inline checkbox — a rounded square frame with an optional centred
+ * checkmark inside (the checked state), each in its own colour — for todo /
+ * checklist markers between text.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return this builder
+ */
+ public ParagraphBuilder checkbox(double size, boolean checked, DocumentColor boxColor, DocumentColor checkColor) {
+ this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, boxColor, checkColor));
+ this.text = "";
+ return this;
+ }
+
+ /**
+ * Adds an inline checkbox using one colour for both the frame and the
+ * checkmark.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param color frame and checkmark color
+ * @return this builder
+ */
+ public ParagraphBuilder checkbox(double size, boolean checked, DocumentColor color) {
+ return checkbox(size, checked, color, color);
+ }
+
+ /**
+ * Adds an inline checkbox whose checked-state tick uses the given
+ * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param markStyle design of the checked-state tick
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public ParagraphBuilder checkbox(double size,
+ boolean checked,
+ ShapeOutline.CheckmarkStyle markStyle,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, markStyle, boxColor, checkColor));
+ this.text = "";
+ return this;
+ }
+
+ /**
+ * Adds an inline checkbox whose checked-state mark is an arbitrary
+ * {@link ShapeOutline} — the power-user overload. Size the mark to fit the
+ * frame (≈ {@code 0.6 × size}); it is drawn centred in the box.
+ *
+ * @param size box width and height in points
+ * @param checked whether the mark is shown
+ * @param mark checked-state mark geometry, already sized; must be non-null
+ * when {@code checked} is {@code true}
+ * @param boxColor frame stroke color
+ * @param checkColor mark fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public ParagraphBuilder checkbox(double size,
+ boolean checked,
+ ShapeOutline mark,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, mark, boxColor, checkColor));
+ this.text = "";
+ return this;
+ }
+
/**
* Replaces inline runs with the contents of a {@link RichText} builder.
*
diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java
index d24cd485..5f9cc19c 100644
--- a/src/main/java/com/demcha/compose/document/dsl/RichText.java
+++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java
@@ -398,6 +398,26 @@ public RichText arrow(double size, ShapeOutline.Direction direction, DocumentCol
return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null);
}
+ /**
+ * Appends an inline arrow of the given {@link ShapeOutline.ArrowStyle} — the
+ * swappable-design overload, so a caller (or a future "pick your arrow" UI)
+ * can choose a block arrow, a triangular arrowhead, etc.
+ *
+ * @param size figure width and height in points
+ * @param direction the way the arrow points
+ * @param style the arrow design
+ * @param fill fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public RichText arrow(double size,
+ ShapeOutline.Direction direction,
+ ShapeOutline.ArrowStyle style,
+ DocumentColor fill) {
+ return shape(ShapeOutline.arrow(size, size, direction, style), fill, null,
+ InlineImageAlignment.CENTER, 0.0, null);
+ }
+
/**
* Appends an inline chevron sized {@code size × size} pointing in
* {@code direction} — a lighter directional separator for step lists.
@@ -452,6 +472,79 @@ public RichText shape(ShapeOutline outline,
return this;
}
+ /**
+ * Appends an inline checkbox — a rounded square frame with an optional
+ * centred checkmark inside (the checked state), each in its own colour —
+ * for todo / checklist markers between text.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return this builder
+ */
+ public RichText checkbox(double size, boolean checked, DocumentColor boxColor, DocumentColor checkColor) {
+ runs.add(InlineShapeRun.checkbox(size, checked, boxColor, checkColor));
+ return this;
+ }
+
+ /**
+ * Appends an inline checkbox using one colour for both the frame and the
+ * checkmark.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param color frame and checkmark color
+ * @return this builder
+ */
+ public RichText checkbox(double size, boolean checked, DocumentColor color) {
+ return checkbox(size, checked, color, color);
+ }
+
+ /**
+ * Appends an inline checkbox whose checked-state tick uses the given
+ * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param markStyle design of the checked-state tick
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public RichText checkbox(double size,
+ boolean checked,
+ ShapeOutline.CheckmarkStyle markStyle,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ runs.add(InlineShapeRun.checkbox(size, checked, markStyle, boxColor, checkColor));
+ return this;
+ }
+
+ /**
+ * Appends an inline checkbox whose checked-state mark is an arbitrary
+ * {@link ShapeOutline} — the power-user overload. Size the mark to fit the
+ * frame (≈ {@code 0.6 × size}); it is drawn centred in the box.
+ *
+ * @param size box width and height in points
+ * @param checked whether the mark is shown
+ * @param mark checked-state mark geometry, already sized; must be non-null
+ * when {@code checked} is {@code true}
+ * @param boxColor frame stroke color
+ * @param checkColor mark fill color
+ * @return this builder
+ * @since 1.7.0
+ */
+ public RichText checkbox(double size,
+ boolean checked,
+ ShapeOutline mark,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ runs.add(InlineShapeRun.checkbox(size, checked, mark, boxColor, checkColor));
+ return this;
+ }
+
/**
* Returns the accumulated runs as an immutable list.
*
diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
index 663a5652..1e4a78e1 100644
--- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
+++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java
@@ -1,6 +1,7 @@
package com.demcha.compose.document.layout;
import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan;
+import com.demcha.compose.document.layout.payloads.ResolvedShapeLayer;
import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
import com.demcha.compose.document.layout.payloads.ParagraphImageSpan;
import com.demcha.compose.document.layout.payloads.ParagraphLine;
@@ -11,6 +12,7 @@
import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout;
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineShapeRun;
+import com.demcha.compose.document.node.ShapeLayer;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineImageRun;
import com.demcha.compose.document.node.InlineRun;
@@ -24,9 +26,7 @@
import com.demcha.compose.document.style.DocumentTextAutoSize;
import com.demcha.compose.document.style.DocumentTextIndent;
import com.demcha.compose.document.style.DocumentTextStyle;
-import com.demcha.compose.document.style.ShapeOutline;
import com.demcha.compose.engine.components.content.ImageData;
-import com.demcha.compose.engine.components.content.shape.Stroke;
import com.demcha.compose.engine.components.content.text.TextDataBody;
import com.demcha.compose.engine.components.content.text.TextIndentStrategy;
import com.demcha.compose.engine.components.content.text.TextStyle;
@@ -35,7 +35,6 @@
import com.demcha.compose.engine.measurement.TextMeasurementSystem;
import com.demcha.compose.engine.text.markdown.MarkDownParser;
-import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -608,7 +607,7 @@ private static boolean paragraphFitsSingleLine(ParagraphNode node,
} else if (run instanceof InlineImageRun imageRun) {
width += imageRun.width();
} else if (run instanceof InlineShapeRun shapeRun) {
- width += shapeRun.outline().width();
+ width += shapeRun.width();
}
}
return width <= innerWidth;
@@ -1338,9 +1337,9 @@ private static ParagraphLine toInlineParagraphLine(List token
width += imageToken.width();
} else if (token instanceof InlineShapeToken shapeToken) {
spans.add(new ParagraphShapeSpan(
- shapeToken.outline(),
- shapeToken.fillColor(),
- shapeToken.stroke(),
+ shapeToken.layers(),
+ shapeToken.width(),
+ shapeToken.height(),
shapeToken.alignment(),
shapeToken.baselineOffset(),
shapeToken.linkOptions()));
@@ -1611,9 +1610,9 @@ private static InlineImageToken of(InlineImageRun run) {
}
private record InlineShapeToken(
- ShapeOutline outline,
- Color fillColor,
- Stroke stroke,
+ List layers,
+ double width,
+ double height,
InlineImageAlignment alignment,
double baselineOffset,
DocumentLinkOptions linkOptions
@@ -1622,20 +1621,18 @@ private record InlineShapeToken(
alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
}
- @Override
- public double width() {
- return outline.width();
- }
-
- private double height() {
- return outline.height();
- }
-
private static InlineShapeToken of(InlineShapeRun run) {
+ List resolved = new ArrayList<>(run.layers().size());
+ for (ShapeLayer layer : run.layers()) {
+ resolved.add(new ResolvedShapeLayer(
+ layer.outline(),
+ layer.fill() == null ? null : layer.fill().color(),
+ toStroke(layer.stroke())));
+ }
return new InlineShapeToken(
- run.outline(),
- run.fill() == null ? null : run.fill().color(),
- toStroke(run.stroke()),
+ List.copyOf(resolved),
+ run.width(),
+ run.height(),
run.alignment(),
run.baselineOffset(),
run.linkOptions());
diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
index b9fa70ba..94c4b420 100644
--- a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
@@ -2,51 +2,35 @@
import com.demcha.compose.document.node.DocumentLinkOptions;
import com.demcha.compose.document.node.InlineImageAlignment;
-import com.demcha.compose.document.style.ShapeOutline;
-import com.demcha.compose.engine.components.content.shape.Stroke;
-import java.awt.Color;
-import java.util.Objects;
+import java.util.List;
/**
- * Measured inline shape span inside a paragraph line.
+ * Measured inline shape span inside a paragraph line — a stack of resolved
+ * {@link ResolvedShapeLayer}s drawn overlaid and centred within the span's
+ * bounding box, so composite figures (e.g. a checkbox: box + checkmark) place
+ * on the text baseline as one unit.
*
- * The semantic {@code InlineShapeRun} is resolved into this payload during
- * wrapping: the DSL fill color becomes an AWT {@link Color} and the DSL stroke
- * becomes an engine {@link Stroke}, while the {@link ShapeOutline} carries the
- * figure geometry (and its intrinsic {@link #width()} / {@link #height()}). The
- * PDF backend dispatches on the outline kind to paint the figure.
- *
- * @param outline figure geometry; supplies the span width and height
- * @param fillColor optional resolved fill color
- * @param stroke optional resolved outline stroke
+ * @param layers resolved paint layers, back-to-front
+ * @param width bounding width in points
+ * @param height bounding height in points
* @param alignment vertical alignment relative to the surrounding text
* @param baselineOffset extra vertical offset in points; positive moves up
* @param linkOptions optional link metadata
*/
public record ParagraphShapeSpan(
- ShapeOutline outline,
- Color fillColor,
- Stroke stroke,
+ List layers,
+ double width,
+ double height,
InlineImageAlignment alignment,
double baselineOffset,
DocumentLinkOptions linkOptions
) implements ParagraphSpan {
/**
- * Validates the outline and normalizes alignment defaults.
+ * Copies the layer stack defensively and normalizes alignment defaults.
*/
public ParagraphShapeSpan {
- Objects.requireNonNull(outline, "outline");
+ layers = List.copyOf(layers);
alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
}
-
- @Override
- public double width() {
- return outline.width();
- }
-
- @Override
- public double height() {
- return outline.height();
- }
}
diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java b/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java
new file mode 100644
index 00000000..9142cd91
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java
@@ -0,0 +1,18 @@
+package com.demcha.compose.document.layout.payloads;
+
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+
+import java.awt.Color;
+
+/**
+ * One resolved paint layer of a {@link ParagraphShapeSpan}: an outline figure
+ * whose fill colour and stroke are already resolved to AWT / engine primitives,
+ * ready for the PDF backend.
+ *
+ * @param outline figure geometry
+ * @param fillColor optional resolved fill color
+ * @param stroke optional resolved outline stroke
+ */
+public record ResolvedShapeLayer(ShapeOutline outline, Color fillColor, Stroke stroke) {
+}
diff --git a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
index 2ff8591d..a2b0a607 100644
--- a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
+++ b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
@@ -4,33 +4,30 @@
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.ShapeOutline;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Objects;
/**
- * One inline shape run inside a {@link ParagraphNode} — a geometric figure
- * (circle / ellipse, rectangle, rounded rectangle, diamond, triangle, star, or
- * any {@link ShapeOutline}) measured and rendered on the surrounding text
- * baseline.
+ * One inline shape run inside a {@link ParagraphNode} — a stack of geometric
+ * {@link ShapeLayer}s (circle / ellipse, rectangle, rounded rectangle, diamond,
+ * triangle, star, arrow, chevron, checkmark, plus, polygon, …) measured and
+ * rendered on the surrounding text baseline.
*
* Inline shapes are measured as part of paragraph wrapping exactly like
- * {@link InlineImageRun}: the outline's width and height contribute to span
- * placement, line breaking and per-line height, and the figure shares the text
- * baseline. The shape is drawn directly from geometry — no raster payload and
- * no font glyph — so skill rating dots ({@code Java ●●●●○}), custom bullets and
- * inline status markers render regardless of font coverage. The kind is the
- * existing {@code ShapeOutline} taxonomy, so any new outline kind is usable
- * inline automatically.
+ * {@link InlineImageRun}: the run's bounding {@link #width()} / {@link #height()}
+ * (the widest / tallest layer) contribute to span placement, line breaking and
+ * per-line height, and the figure shares the text baseline. Each layer is drawn
+ * directly from geometry — no raster payload and no font glyph — so skill rating
+ * dots, custom bullets, arrows between text and checkboxes render regardless of
+ * font coverage.
*
- * At least one of {@code fill} or {@code stroke} must be present, otherwise
- * the run would be invisible: a filled figure uses {@code fill} with a
- * {@code null} stroke; an outlined figure uses a {@code null} fill with a
- * {@code stroke}; the two combined paint a filled-and-outlined figure.
+ * Most figures are a single layer (a dot, an arrow). Composite figures stack
+ * layers overlaid and centred: a checkbox is a box layer plus an optional
+ * checkmark layer, each with its own colour.
*
- * @param outline shape geometry; its {@link ShapeOutline#width()} and
- * {@link ShapeOutline#height()} are the run's measured size
- * @param fill optional fill color; {@code null} leaves the interior empty
- * @param stroke optional outline stroke; {@code null} leaves the figure
- * without a border
+ * @param layers one or more paint layers, drawn back-to-front and centred in the
+ * run's bounding box
* @param alignment vertical alignment relative to the surrounding text;
* defaults to {@link InlineImageAlignment#CENTER}
* @param baselineOffset extra vertical offset in points applied after
@@ -42,36 +39,156 @@
* @since 1.7.0
*/
public record InlineShapeRun(
- ShapeOutline outline,
- DocumentColor fill,
- DocumentStroke stroke,
+ List layers,
InlineImageAlignment alignment,
double baselineOffset,
DocumentLinkOptions linkOptions
) implements InlineRun {
/**
- * Validates the outline, requires at least one visible paint, and
+ * Copies the layer stack defensively, requires at least one layer, and
* normalizes alignment defaults.
*/
public InlineShapeRun {
- Objects.requireNonNull(outline, "outline");
+ Objects.requireNonNull(layers, "layers");
+ layers = List.copyOf(layers);
+ if (layers.isEmpty()) {
+ throw new IllegalArgumentException("inline shape needs at least one layer");
+ }
if (Double.isNaN(baselineOffset) || Double.isInfinite(baselineOffset)) {
throw new IllegalArgumentException("inline shape baselineOffset must be finite: " + baselineOffset);
}
- if (fill == null && stroke == null) {
- throw new IllegalArgumentException("inline shape must have a fill, a stroke, or both");
- }
alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
}
/**
- * Convenience constructor for a filled shape with default
+ * Single-layer convenience constructor.
+ *
+ * @param outline figure geometry
+ * @param fill optional fill color
+ * @param stroke optional outline stroke
+ * @param alignment vertical alignment relative to surrounding text
+ * @param baselineOffset extra vertical shift in points; positive moves up
+ * @param linkOptions optional inline link metadata
+ */
+ public InlineShapeRun(ShapeOutline outline,
+ DocumentColor fill,
+ DocumentStroke stroke,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions) {
+ this(List.of(new ShapeLayer(outline, fill, stroke)), alignment, baselineOffset, linkOptions);
+ }
+
+ /**
+ * Single filled-layer convenience constructor with default
* {@link InlineImageAlignment#CENTER} alignment and zero offset.
*
- * @param outline shape geometry
+ * @param outline figure geometry
* @param fill fill color; must not be {@code null}
*/
public InlineShapeRun(ShapeOutline outline, DocumentColor fill) {
this(outline, Objects.requireNonNull(fill, "fill"), null, InlineImageAlignment.CENTER, 0.0, null);
}
+
+ /**
+ * Returns the bounding width of the run — the widest layer.
+ *
+ * @return bounding width in points
+ */
+ public double width() {
+ double max = 0.0;
+ for (ShapeLayer layer : layers) {
+ max = Math.max(max, layer.outline().width());
+ }
+ return max;
+ }
+
+ /**
+ * Returns the bounding height of the run — the tallest layer.
+ *
+ * @return bounding height in points
+ */
+ public double height() {
+ double max = 0.0;
+ for (ShapeLayer layer : layers) {
+ max = Math.max(max, layer.outline().height());
+ }
+ return max;
+ }
+
+ /**
+ * Creates an inline checkbox with the default
+ * {@link ShapeOutline.CheckmarkStyle#CLASSIC} tick — a rounded square frame
+ * with an optional centred checkmark inside (the checked state), each in its
+ * own colour. The frame is stroke-only; the checkmark, when present, is a
+ * smaller filled figure centred inside the frame.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return checkbox shape run
+ */
+ public static InlineShapeRun checkbox(double size,
+ boolean checked,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ return checkbox(size, checked, ShapeOutline.CheckmarkStyle.CLASSIC, boxColor, checkColor);
+ }
+
+ /**
+ * Creates an inline checkbox whose checked-state tick uses the given
+ * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload. The
+ * mark is sized to fit the frame automatically; an unchecked box ignores the
+ * style and renders the frame alone.
+ *
+ * @param size box width and height in points
+ * @param checked whether the checkmark is shown
+ * @param markStyle design of the checked-state tick
+ * @param boxColor frame stroke color
+ * @param checkColor checkmark fill color
+ * @return checkbox shape run
+ * @since 1.7.0
+ */
+ public static InlineShapeRun checkbox(double size,
+ boolean checked,
+ ShapeOutline.CheckmarkStyle markStyle,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ Objects.requireNonNull(markStyle, "markStyle");
+ double inner = size * 0.62;
+ ShapeOutline mark = checked ? ShapeOutline.checkmark(inner, inner, markStyle) : null;
+ return checkbox(size, checked, mark, boxColor, checkColor);
+ }
+
+ /**
+ * Creates an inline checkbox whose checked-state mark is an arbitrary
+ * {@link ShapeOutline} — the power-user overload for any glyph (a custom
+ * tick, a dash, a cross, …). The mark is drawn centred in the frame at its
+ * own size, so size it to fit (≈ {@code 0.6 × size}); an unchecked box
+ * renders the frame alone and the {@code mark} is ignored.
+ *
+ * @param size box width and height in points
+ * @param checked whether the mark is shown
+ * @param mark checked-state mark geometry, already sized; must be non-null
+ * when {@code checked} is {@code true}
+ * @param boxColor frame stroke color
+ * @param checkColor mark fill color
+ * @return checkbox shape run
+ * @since 1.7.0
+ */
+ public static InlineShapeRun checkbox(double size,
+ boolean checked,
+ ShapeOutline mark,
+ DocumentColor boxColor,
+ DocumentColor checkColor) {
+ DocumentStroke frame = DocumentStroke.of(boxColor, Math.max(0.5, size * 0.09));
+ List layers = new ArrayList<>(2);
+ layers.add(new ShapeLayer(new ShapeOutline.RoundedRectangle(size, size, size * 0.18), null, frame));
+ if (checked) {
+ Objects.requireNonNull(mark, "mark");
+ layers.add(new ShapeLayer(mark, checkColor));
+ }
+ return new InlineShapeRun(layers, InlineImageAlignment.CENTER, 0.0, null);
+ }
}
diff --git a/src/main/java/com/demcha/compose/document/node/ShapeLayer.java b/src/main/java/com/demcha/compose/document/node/ShapeLayer.java
new file mode 100644
index 00000000..54aafafc
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/node/ShapeLayer.java
@@ -0,0 +1,46 @@
+package com.demcha.compose.document.node;
+
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.ShapeOutline;
+
+import java.util.Objects;
+
+/**
+ * One paint layer of an {@link InlineShapeRun}: a {@link ShapeOutline} figure
+ * with its own fill and/or stroke.
+ *
+ * Layers are drawn overlaid, each centred within the run's bounding box, so
+ * composite inline figures are expressed as a stack — a checkbox is a box layer
+ * plus an optional checkmark layer, each with its own colour; a single dot or
+ * arrow is just one layer.
+ *
+ * @param outline figure geometry; its {@link ShapeOutline#width()} /
+ * {@link ShapeOutline#height()} size this layer
+ * @param fill optional fill color; {@code null} leaves the interior empty
+ * @param stroke optional outline stroke; {@code null} leaves no border
+ *
+ * @author Artem Demchyshyn
+ * @since 1.7.0
+ */
+public record ShapeLayer(ShapeOutline outline, DocumentColor fill, DocumentStroke stroke) {
+ /**
+ * Validates the outline and requires at least one visible paint.
+ */
+ public ShapeLayer {
+ Objects.requireNonNull(outline, "outline");
+ if (fill == null && stroke == null) {
+ throw new IllegalArgumentException("shape layer must have a fill, a stroke, or both");
+ }
+ }
+
+ /**
+ * Creates a filled layer with no stroke.
+ *
+ * @param outline figure geometry
+ * @param fill fill color; must not be {@code null}
+ */
+ public ShapeLayer(ShapeOutline outline, DocumentColor fill) {
+ this(outline, Objects.requireNonNull(fill, "fill"), null);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
index 9ab51d4f..ec551978 100644
--- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
+++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java
@@ -131,6 +131,37 @@ enum Direction {
DOWN
}
+ /**
+ * Selectable design of a checkmark ("✓") figure — the swappable "tick"
+ * variant used by {@link #checkmark(double, double, CheckmarkStyle)} and by
+ * inline checkbox factories. Adding a new look is one enum constant plus its
+ * vertex ring, so callers (and a future "pick your tick" UI) choose a style
+ * by name rather than hand-building geometry.
+ *
+ * @since 1.7.0
+ */
+ enum CheckmarkStyle {
+ /** The default tick: a slim six-vertex checkmark band. */
+ CLASSIC,
+ /** A bolder tick with a visibly thicker band. */
+ HEAVY
+ }
+
+ /**
+ * Selectable design of an arrow figure — the swappable "arrow" variant used
+ * by {@link #arrow(double, double, Direction, ArrowStyle)}. Each style is a
+ * normalized vertex ring pointed right and rotated to {@link Direction} at
+ * build time, so a new arrow look is one enum constant plus its ring.
+ *
+ * @since 1.7.0
+ */
+ enum ArrowStyle {
+ /** The default arrow: a seven-vertex block arrow with a shaft. */
+ BLOCK,
+ /** A solid triangular arrowhead ("▶") with no shaft. */
+ TRIANGLE
+ }
+
/**
* Convenience factory for a circular {@link Ellipse}.
*
@@ -237,10 +268,32 @@ static Polygon star(double width, double height, int points) {
* @return arrow polygon outline
*/
static Polygon arrow(double width, double height, Direction direction) {
+ return arrow(width, height, direction, ArrowStyle.BLOCK);
+ }
+
+ /**
+ * Creates an arrow of the given {@link ArrowStyle} pointing in
+ * {@code direction} — the swappable-design overload. {@link ArrowStyle#BLOCK}
+ * reproduces {@link #arrow(double, double, Direction)} exactly.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param direction the way the arrow points
+ * @param style the arrow design
+ * @return arrow polygon outline
+ * @since 1.7.0
+ */
+ static Polygon arrow(double width, double height, Direction direction, ArrowStyle style) {
Objects.requireNonNull(direction, "direction");
- double[][] base = {
- {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88},
- {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35}
+ Objects.requireNonNull(style, "style");
+ double[][] base = switch (style) {
+ case BLOCK -> new double[][] {
+ {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88},
+ {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35}
+ };
+ case TRIANGLE -> new double[][] {
+ {0.00, 0.00}, {1.00, 0.50}, {0.00, 1.00}
+ };
};
return new Polygon(width, height, directional(base, direction));
}
@@ -294,11 +347,30 @@ static Polygon chevron(double width, double height, Direction direction) {
* @return checkmark polygon outline
*/
static Polygon checkmark(double width, double height) {
- double[][] points = {
- {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92},
- {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44}
+ return checkmark(width, height, CheckmarkStyle.CLASSIC);
+ }
+
+ /**
+ * Creates a checkmark ("✓") of the given {@link CheckmarkStyle} — the
+ * swappable-design overload. {@link CheckmarkStyle#CLASSIC} reproduces
+ * {@link #checkmark(double, double)} exactly.
+ *
+ * @param width outer width in points
+ * @param height outer height in points
+ * @param style the checkmark design
+ * @return checkmark polygon outline
+ * @since 1.7.0
+ */
+ static Polygon checkmark(double width, double height, CheckmarkStyle style) {
+ Objects.requireNonNull(style, "style");
+ List ring = switch (style) {
+ case CLASSIC -> toPoints(new double[][] {
+ {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92},
+ {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44}
+ });
+ case HEAVY -> toPoints(ShapeRings.checkmarkBand(0.13));
};
- return new Polygon(width, height, toPoints(points));
+ return new Polygon(width, height, ring);
}
/**
diff --git a/src/main/java/com/demcha/compose/document/style/ShapeRings.java b/src/main/java/com/demcha/compose/document/style/ShapeRings.java
new file mode 100644
index 00000000..3b5416a5
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/style/ShapeRings.java
@@ -0,0 +1,62 @@
+package com.demcha.compose.document.style;
+
+/**
+ * Package-private geometry helpers that compute raw vertex rings for the more
+ * involved {@link ShapeOutline} figures, keeping the vector math out of the
+ * public factory surface.
+ *
+ * Coordinates are in the unit box (x right, y up) and may land slightly
+ * outside {@code [0, 1]}; {@code ShapeOutline} clamps and wraps them into
+ * {@link ShapePoint}s. Each method here is a candidate home for future design
+ * variants (a thinner tick, a rounded tick, …).
+ */
+final class ShapeRings {
+
+ private ShapeRings() {
+ }
+
+ /**
+ * Builds a constant-width checkmark band of perpendicular half-thickness
+ * {@code half} around a fixed left-tip → elbow → right-tip centreline, with a
+ * mitred elbow and flat-cut tips. Larger {@code half} reads as a bolder tick.
+ *
+ * @param half perpendicular half-thickness of the band, in unit-box units
+ * @return six ring vertices in draw order: outer elbow, right tip (outer then
+ * inner), inner elbow, left tip (inner then outer)
+ */
+ static double[][] checkmarkBand(double half) {
+ double[] left = {0.12, 0.50};
+ double[] elbow = {0.42, 0.18};
+ double[] right = {0.92, 0.84};
+ double[] toRight = unit(right[0] - elbow[0], right[1] - elbow[1]);
+ double[] toLeft = unit(left[0] - elbow[0], left[1] - elbow[1]);
+ double[] normalRight = outwardNormal(toRight);
+ double[] normalLeft = outwardNormal(toLeft);
+ double[] bisector = unit(normalRight[0] + normalLeft[0], normalRight[1] + normalLeft[1]);
+ double miter = half / (bisector[0] * normalRight[0] + bisector[1] * normalRight[1]);
+ return new double[][] {
+ {elbow[0] + bisector[0] * miter, elbow[1] + bisector[1] * miter}, // outer elbow
+ {right[0] + normalRight[0] * half, right[1] + normalRight[1] * half}, // right tip, outer
+ {right[0] - normalRight[0] * half, right[1] - normalRight[1] * half}, // right tip, inner
+ {elbow[0] - bisector[0] * miter, elbow[1] - bisector[1] * miter}, // inner elbow
+ {left[0] - normalLeft[0] * half, left[1] - normalLeft[1] * half}, // left tip, inner
+ {left[0] + normalLeft[0] * half, left[1] + normalLeft[1] * half} // left tip, outer
+ };
+ }
+
+ /** Returns the unit vector along {@code (x, y)}, or the zero vector for zero length. */
+ private static double[] unit(double x, double y) {
+ double length = Math.hypot(x, y);
+ return length == 0 ? new double[] {0.0, 0.0} : new double[] {x / length, y / length};
+ }
+
+ /** Returns the normal of {@code u} flipped to point toward the bottom (convex) side. */
+ private static double[] outwardNormal(double[] u) {
+ double nx = u[1];
+ double ny = -u[0];
+ if (ny > 0) {
+ return new double[] {-nx, -ny};
+ }
+ return new double[] {nx, ny};
+ }
+}
diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
index 07c66085..9fd02e1c 100644
--- a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
@@ -26,6 +26,7 @@
class InlineShapeRenderTest {
private static final DocumentColor ACCENT = DocumentColor.of(new java.awt.Color(40, 90, 180));
+ private static final DocumentColor CHECK = DocumentColor.of(new java.awt.Color(34, 130, 92));
@Test
void ratingShapesRenderEndToEndKeepingTextWithoutGlyphSubstitution() throws Exception {
@@ -106,6 +107,74 @@ void everyOutlineKindRendersWithoutThrowing() throws Exception {
}
}
+ @Test
+ void checkboxRendersCheckedStateWithMoreInkThanUnchecked() throws Exception {
+ int checked = countColorNear(renderCheckbox(true), 34, 130, 92, 45);
+ int unchecked = countColorNear(renderCheckbox(false), 34, 130, 92, 45);
+
+ // The empty box paints its frame stroke; the checked box stamps a filled
+ // tick inside the same frame, so it must add ink — that is exactly the
+ // "marked vs unmarked" distinction a checklist needs.
+ assertThat(unchecked).as("the unchecked frame still paints").isGreaterThan(0);
+ assertThat(checked).as("the checked tick adds ink inside the frame").isGreaterThan(unchecked);
+ }
+
+ @Test
+ void checkmarkAndArrowVariantsRenderEndToEnd() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(260, 140)
+ .margin(14, 14, 14, 14)
+ .create()) {
+ session.dsl()
+ .pageFlow()
+ .name("Flow")
+ .addParagraph(paragraph -> paragraph
+ .inlineText("Variants ")
+ .checkbox(12, true, ShapeOutline.CheckmarkStyle.HEAVY, CHECK, CHECK)
+ .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT)
+ .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.HEAVY), CHECK))
+ .build();
+ assertThat(session.toPdfBytes()).isNotEmpty();
+ }
+ }
+
+ private static byte[] renderCheckbox(boolean checked) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(160, 90)
+ .margin(14, 14, 14, 14)
+ .create()) {
+ session.dsl()
+ .pageFlow()
+ .name("Flow")
+ .addParagraph(paragraph -> paragraph
+ .inlineText("Task ")
+ .checkbox(16, checked, CHECK, CHECK))
+ .build();
+ return session.toPdfBytes();
+ }
+ }
+
+ private static int countColorNear(byte[] pdf, int r, int g, int b, int tolerance) throws Exception {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144);
+ int count = 0;
+ for (int y = 0; y < image.getHeight(); y++) {
+ for (int x = 0; x < image.getWidth(); x++) {
+ int rgb = image.getRGB(x, y);
+ int rr = (rgb >> 16) & 0xFF;
+ int gg = (rgb >> 8) & 0xFF;
+ int bb = rgb & 0xFF;
+ if (Math.abs(rr - r) <= tolerance
+ && Math.abs(gg - g) <= tolerance
+ && Math.abs(bb - b) <= tolerance) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+ }
+
private static byte[] renderRatingRow() throws Exception {
try (DocumentSession session = GraphCompose.document()
.pageSize(320, 160)
diff --git a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
index 1d54c754..258b8a51 100644
--- a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java
@@ -221,10 +221,10 @@ void dotProducesFilledCircleShapeRunWithCenterDefault() {
List runs = RichText.empty().dot(6.0, RED).runs();
assertThat(runs).hasSize(1);
InlineShapeRun dot = (InlineShapeRun) runs.get(0);
- assertThat(dot.outline()).isInstanceOf(ShapeOutline.Ellipse.class);
- assertThat(dot.outline().width()).isEqualTo(6.0, within(EPS));
- assertThat(dot.fill()).isEqualTo(RED);
- assertThat(dot.stroke()).isNull();
+ assertThat(dot.layers().get(0).outline()).isInstanceOf(ShapeOutline.Ellipse.class);
+ assertThat(dot.layers().get(0).outline().width()).isEqualTo(6.0, within(EPS));
+ assertThat(dot.layers().get(0).fill()).isEqualTo(RED);
+ assertThat(dot.layers().get(0).stroke()).isNull();
assertThat(dot.alignment()).isEqualTo(InlineImageAlignment.CENTER);
}
@@ -241,8 +241,8 @@ void ratingDotsMixWithTextInSourceOrder() {
assertThat(runs.get(1)).isInstanceOf(InlineShapeRun.class);
InlineShapeRun outlined = (InlineShapeRun) runs.get(3);
- assertThat(outlined.fill()).isNull();
- assertThat(outlined.stroke()).isNotNull();
+ assertThat(outlined.layers().get(0).fill()).isNull();
+ assertThat(outlined.layers().get(0).stroke()).isNotNull();
}
@Test
@@ -250,9 +250,9 @@ void diamondAndStarFactoriesProducePolygonShapeRuns() {
InlineShapeRun diamond = (InlineShapeRun) RichText.empty().diamond(8, ACCENT).runs().get(0);
InlineShapeRun star = (InlineShapeRun) RichText.empty().star(8, ACCENT).runs().get(0);
- assertThat(diamond.outline()).isInstanceOf(ShapeOutline.Polygon.class);
- assertThat(star.outline()).isInstanceOf(ShapeOutline.Polygon.class);
- assertThat(((ShapeOutline.Polygon) star.outline()).points()).hasSize(10);
+ assertThat(diamond.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(star.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(((ShapeOutline.Polygon) star.layers().get(0).outline()).points()).hasSize(10);
}
@Test
@@ -260,7 +260,7 @@ void shapeAcceptsAnyOutlineAlignmentAndOffset() {
InlineShapeRun run = (InlineShapeRun) RichText.empty()
.shape(new ShapeOutline.Rectangle(10, 6), RED, null, InlineImageAlignment.BASELINE, 1.5, null)
.runs().get(0);
- assertThat(run.outline()).isInstanceOf(ShapeOutline.Rectangle.class);
+ assertThat(run.layers().get(0).outline()).isInstanceOf(ShapeOutline.Rectangle.class);
assertThat(run.alignment()).isEqualTo(InlineImageAlignment.BASELINE);
assertThat(run.baselineOffset()).isEqualTo(1.5, within(EPS));
}
@@ -285,7 +285,38 @@ void arrowAndChevronFactoriesProduceDirectionalPolygonRuns() {
InlineShapeRun chevron = (InlineShapeRun) RichText.empty()
.chevron(8, ShapeOutline.Direction.LEFT, ACCENT).runs().get(0);
- assertThat(arrow.outline()).isInstanceOf(ShapeOutline.Polygon.class);
- assertThat(chevron.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(arrow.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(chevron.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ }
+
+ @Test
+ void checkboxAppendsCheckedAndUncheckedShapeRuns() {
+ InlineShapeRun checked = (InlineShapeRun) RichText.empty().checkbox(10, true, ACCENT).runs().get(0);
+ InlineShapeRun unchecked = (InlineShapeRun) RichText.empty().checkbox(10, false, ACCENT).runs().get(0);
+
+ assertThat(checked.layers()).hasSize(2);
+ assertThat(unchecked.layers()).hasSize(1);
+ }
+
+ @Test
+ void checkboxStyleAndRawMarkOverloadsReachInlineShapeRun() {
+ InlineShapeRun styled = (InlineShapeRun) RichText.empty()
+ .checkbox(10, true, ShapeOutline.CheckmarkStyle.HEAVY, ACCENT, ACCENT).runs().get(0);
+ InlineShapeRun raw = (InlineShapeRun) RichText.empty()
+ .checkbox(10, true, ShapeOutline.plus(6, 6), ACCENT, ACCENT).runs().get(0);
+
+ assertThat(styled.layers()).hasSize(2);
+ assertThat(raw.layers().get(1).outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ }
+
+ @Test
+ void arrowStyleOverloadProducesChosenArrowDesign() {
+ InlineShapeRun block = (InlineShapeRun) RichText.empty()
+ .arrow(8, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.BLOCK, ACCENT).runs().get(0);
+ InlineShapeRun triangle = (InlineShapeRun) RichText.empty()
+ .arrow(8, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT).runs().get(0);
+
+ assertThat(((ShapeOutline.Polygon) block.layers().get(0).outline()).points()).hasSize(7);
+ assertThat(((ShapeOutline.Polygon) triangle.layers().get(0).outline()).points()).hasSize(3);
}
}
diff --git a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
index ab196d3c..72a2895d 100644
--- a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
+++ b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
@@ -20,11 +20,13 @@ class InlineShapeRunTest {
@Test
void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() {
InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(6.0), FILL);
+ ShapeLayer layer = run.layers().get(0);
- assertThat(run.outline()).isEqualTo(ShapeOutline.circle(6.0));
- assertThat(run.outline().width()).isEqualTo(6.0, within(EPS));
- assertThat(run.fill()).isSameAs(FILL);
- assertThat(run.stroke()).isNull();
+ assertThat(run.layers()).hasSize(1);
+ assertThat(layer.outline()).isEqualTo(ShapeOutline.circle(6.0));
+ assertThat(layer.outline().width()).isEqualTo(6.0, within(EPS));
+ assertThat(layer.fill()).isSameAs(FILL);
+ assertThat(layer.stroke()).isNull();
assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER);
assertThat(run.baselineOffset()).isEqualTo(0.0, within(EPS));
assertThat(run.linkOptions()).isNull();
@@ -32,20 +34,21 @@ void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() {
@Test
void carriesAnyOutlineKind() {
- assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).outline())
+ assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).layers().get(0).outline())
.isInstanceOf(ShapeOutline.Polygon.class);
- assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).outline())
+ assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).layers().get(0).outline())
.isInstanceOf(ShapeOutline.Polygon.class);
- assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).outline())
+ assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).layers().get(0).outline())
.isInstanceOf(ShapeOutline.Rectangle.class);
}
@Test
void outlinedOnlyShapeIsAllowed() {
InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(8), null, STROKE, null, 0.0, null);
+ ShapeLayer layer = run.layers().get(0);
- assertThat(run.fill()).isNull();
- assertThat(run.stroke()).isSameAs(STROKE);
+ assertThat(layer.fill()).isNull();
+ assertThat(layer.stroke()).isSameAs(STROKE);
assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER);
}
@@ -84,4 +87,78 @@ void filledConvenienceConstructorRejectsNullFill() {
assertThatThrownBy(() -> new InlineShapeRun(ShapeOutline.circle(6), null))
.isInstanceOf(NullPointerException.class);
}
+
+ @Test
+ void checkedCheckboxStacksFrameAndMarkLayers() {
+ InlineShapeRun box = InlineShapeRun.checkbox(12, true, DocumentColor.BLACK, FILL);
+
+ assertThat(box.layers()).hasSize(2);
+ ShapeLayer frame = box.layers().get(0);
+ assertThat(frame.outline()).isInstanceOf(ShapeOutline.RoundedRectangle.class);
+ assertThat(frame.fill()).isNull();
+ assertThat(frame.stroke()).isNotNull();
+ ShapeLayer mark = box.layers().get(1);
+ assertThat(mark.outline()).isInstanceOf(ShapeOutline.Polygon.class);
+ assertThat(mark.fill()).isSameAs(FILL);
+ assertThat(box.width()).isEqualTo(12.0, within(EPS));
+ assertThat(box.height()).isEqualTo(12.0, within(EPS));
+ }
+
+ @Test
+ void uncheckedCheckboxIsFrameOnly() {
+ InlineShapeRun box = InlineShapeRun.checkbox(12, false, DocumentColor.BLACK, FILL);
+
+ assertThat(box.layers()).hasSize(1);
+ assertThat(box.layers().get(0).fill()).isNull();
+ assertThat(box.layers().get(0).stroke()).isNotNull();
+ }
+
+ @Test
+ void defaultCheckboxUsesClassicTick() {
+ ShapeOutline preset = InlineShapeRun.checkbox(12, true, DocumentColor.BLACK, FILL)
+ .layers().get(1).outline();
+ ShapeOutline classic = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.CLASSIC,
+ DocumentColor.BLACK, FILL)
+ .layers().get(1).outline();
+
+ assertThat(((ShapeOutline.Polygon) preset).points())
+ .isEqualTo(((ShapeOutline.Polygon) classic).points());
+ }
+
+ @Test
+ void checkboxStyleOverloadSwapsTheTickDesign() {
+ ShapeOutline classic = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.CLASSIC,
+ DocumentColor.BLACK, FILL)
+ .layers().get(1).outline();
+ ShapeOutline heavy = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.HEAVY,
+ DocumentColor.BLACK, FILL)
+ .layers().get(1).outline();
+
+ assertThat(((ShapeOutline.Polygon) heavy).points())
+ .isNotEqualTo(((ShapeOutline.Polygon) classic).points());
+ }
+
+ @Test
+ void rawMarkCheckboxUsesGivenOutlineAndGuardsNullWhenChecked() {
+ ShapeOutline mark = ShapeOutline.plus(7, 7);
+ InlineShapeRun box = InlineShapeRun.checkbox(12, true, mark, DocumentColor.BLACK, FILL);
+ assertThat(box.layers().get(1).outline()).isSameAs(mark);
+
+ assertThatThrownBy(() ->
+ InlineShapeRun.checkbox(12, true, (ShapeOutline) null, DocumentColor.BLACK, FILL))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("mark");
+
+ // An unchecked box ignores the mark, so a null mark is tolerated.
+ assertThat(InlineShapeRun.checkbox(12, false, (ShapeOutline) null, DocumentColor.BLACK, FILL).layers())
+ .hasSize(1);
+ }
+
+ @Test
+ void checkboxStyleOverloadRejectsNullStyle() {
+ assertThatThrownBy(() ->
+ InlineShapeRun.checkbox(12, true, (ShapeOutline.CheckmarkStyle) null, DocumentColor.BLACK, FILL))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("markStyle");
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
index fdc3662c..8f0c334b 100644
--- a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
+++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
@@ -146,4 +146,53 @@ void everyPolygonFactoryStaysWithinUnitBox() {
}
}
}
+
+ @Test
+ void checkmarkDefaultEqualsClassicStyle() {
+ assertThat(ShapeOutline.checkmark(10, 10).points())
+ .isEqualTo(ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.CLASSIC).points());
+ }
+
+ @Test
+ void heavyCheckmarkDiffersFromClassicButKeepsSixVerticesInBox() {
+ ShapeOutline.Polygon classic = ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.CLASSIC);
+ ShapeOutline.Polygon heavy = ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.HEAVY);
+
+ assertThat(heavy.points()).hasSize(6);
+ assertThat(heavy.points()).isNotEqualTo(classic.points());
+ for (ShapePoint point : heavy.points()) {
+ assertThat(point.x()).isBetween(0.0, 1.0);
+ assertThat(point.y()).isBetween(0.0, 1.0);
+ }
+ }
+
+ @Test
+ void arrowDefaultEqualsBlockStyle() {
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT).points())
+ .isEqualTo(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT,
+ ShapeOutline.ArrowStyle.BLOCK).points());
+ }
+
+ @Test
+ void triangleArrowHasThreeVerticesAndDirectionalTip() {
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE).points())
+ .hasSize(3)
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(1.0, within(EPS));
+ assertThat(p.y()).isEqualTo(0.5, within(EPS));
+ });
+ assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.UP, ShapeOutline.ArrowStyle.TRIANGLE).points())
+ .anySatisfy(p -> {
+ assertThat(p.x()).isEqualTo(0.5, within(EPS));
+ assertThat(p.y()).isEqualTo(1.0, within(EPS));
+ });
+ }
+
+ @Test
+ void checkmarkAndArrowStyleOverloadsRejectNullStyle() {
+ assertThatThrownBy(() -> ShapeOutline.checkmark(10, 10, null))
+ .isInstanceOf(NullPointerException.class);
+ assertThatThrownBy(() -> ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, null))
+ .isInstanceOf(NullPointerException.class);
+ }
}
diff --git a/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java
new file mode 100644
index 00000000..24b0b3f3
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java
@@ -0,0 +1,28 @@
+package com.demcha.compose.document.style;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ShapeRingsTest {
+
+ @Test
+ void checkmarkBandReturnsSixTwoCoordinateVertices() {
+ double[][] ring = ShapeRings.checkmarkBand(0.13);
+
+ assertThat(ring.length).isEqualTo(6);
+ for (double[] vertex : ring) {
+ assertThat(vertex.length).isEqualTo(2);
+ }
+ }
+
+ @Test
+ void thickerBandPushesTheOuterElbowLower() {
+ // Vertex 0 is the outer elbow (bottom of the tick); a thicker band must
+ // push it lower — a stable proxy that {@code half} actually widens it.
+ double[][] thin = ShapeRings.checkmarkBand(0.08);
+ double[][] thick = ShapeRings.checkmarkBand(0.16);
+
+ assertThat(thick[0][1]).isLessThan(thin[0][1]);
+ }
+}