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]); + } +}