diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7be71e63..c0254a25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +3,56 @@
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.
+- **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
new file mode 100644
index 00000000..4b2acbf5
Binary files /dev/null and b/assets/readme/examples/inline-shapes.pdf differ
diff --git a/assets/readme/examples/rich-text-showcase.pdf b/assets/readme/examples/rich-text-showcase.pdf
index f10d97d7..87da3a11 100644
Binary files a/assets/readme/examples/rich-text-showcase.pdf and b/assets/readme/examples/rich-text-showcase.pdf differ
diff --git a/examples/README.md b/examples/README.md
index 4e85a873..cf6ca5fe 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -60,6 +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 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) |
@@ -409,6 +410,30 @@ visual reference when picking which call to make for inline text.
[📄 View PDF](../assets/readme/examples/rich-text-showcase.pdf) ·
[📜 Full source](src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java)
+### Inline shapes
+
+`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, 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
+ .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,
+// 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) ·
+[📜 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..9b9b9e43
--- /dev/null
+++ b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java
@@ -0,0 +1,173 @@
+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 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();
+ 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("checkbox(size, checked, color) — todo markers, checked and unchecked")
+ .textStyle(caption())
+ .margin(DocumentInsets.zero()))
+ .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
+ .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("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))
+ .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..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
@@ -4,11 +4,14 @@
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.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;
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 +130,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 +152,82 @@ 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);
+ 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/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..d4b3d3eb 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,245 @@ 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 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.
+ *
+ * @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;
+ }
+
+ /**
+ * 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 c588375b..5f9cc19c 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,239 @@ 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 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.
+ *
+ * @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;
+ }
+
+ /**
+ * 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/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..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,5 +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;
@@ -9,6 +11,8 @@
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.ShapeLayer;
import com.demcha.compose.document.node.InlineImageAlignment;
import com.demcha.compose.document.node.InlineImageRun;
import com.demcha.compose.document.node.InlineRun;
@@ -38,6 +42,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 +606,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.width();
}
}
return width <= innerWidth;
@@ -1253,6 +1260,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 +1299,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 +1335,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.layers(),
+ shapeToken.width(),
+ shapeToken.height(),
+ shapeToken.alignment(),
+ shapeToken.baselineOffset(),
+ shapeToken.linkOptions()));
+ width += shapeToken.width();
}
}
@@ -1537,7 +1559,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 +1608,34 @@ private static InlineImageToken of(InlineImageRun run) {
run.linkOptions());
}
}
+
+ private record InlineShapeToken(
+ List layers,
+ double width,
+ double height,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+ ) implements InlineLayoutToken {
+ private InlineShapeToken {
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+
+ 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(
+ List.copyOf(resolved),
+ run.width(),
+ run.height(),
+ 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..94c4b420
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java
@@ -0,0 +1,36 @@
+package com.demcha.compose.document.layout.payloads;
+
+import com.demcha.compose.document.node.DocumentLinkOptions;
+import com.demcha.compose.document.node.InlineImageAlignment;
+
+import java.util.List;
+
+/**
+ * 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.
+ *
+ * @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(
+ List layers,
+ double width,
+ double height,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+) implements ParagraphSpan {
+ /**
+ * Copies the layer stack defensively and normalizes alignment defaults.
+ */
+ public ParagraphShapeSpan {
+ layers = List.copyOf(layers);
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+}
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/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/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..a2b0a607
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java
@@ -0,0 +1,194 @@
+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.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 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 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.
+ *
+ * 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 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
+ * {@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(
+ List layers,
+ InlineImageAlignment alignment,
+ double baselineOffset,
+ DocumentLinkOptions linkOptions
+) implements InlineRun {
+ /**
+ * Copies the layer stack defensively, requires at least one layer, and
+ * normalizes alignment defaults.
+ */
+ public InlineShapeRun {
+ 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);
+ }
+ alignment = alignment == null ? InlineImageAlignment.CENTER : alignment;
+ }
+
+ /**
+ * 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 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 29d89507..ec551978 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,80 @@ 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
+ }
+
+ /**
+ * 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}.
*
@@ -93,6 +172,292 @@ 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) {
+ 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");
+ 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));
+ }
+
+ /**
+ * 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) {
+ 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, ring);
+ }
+
+ /**
+ * 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/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
new file mode 100644
index 00000000..9fd02e1c
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java
@@ -0,0 +1,217 @@
+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));
+ private static final DocumentColor CHECK = DocumentColor.of(new java.awt.Color(34, 130, 92));
+
+ @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();
+ }
+ }
+
+ @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)
+ .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..258b8a51 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,108 @@ 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.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);
+ }
+
+ @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.layers().get(0).fill()).isNull();
+ assertThat(outlined.layers().get(0).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.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
+ 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.layers().get(0).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.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
new file mode 100644
index 00000000..72a2895d
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java
@@ -0,0 +1,164 @@
+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);
+ ShapeLayer layer = run.layers().get(0);
+
+ 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();
+ }
+
+ @Test
+ void carriesAnyOutlineKind() {
+ assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).layers().get(0).outline())
+ .isInstanceOf(ShapeOutline.Polygon.class);
+ 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).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(layer.fill()).isNull();
+ assertThat(layer.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);
+ }
+
+ @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
new file mode 100644
index 00000000..8f0c334b
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java
@@ -0,0 +1,198 @@
+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);
+ }
+ }
+ }
+
+ @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]);
+ }
+}