From f3a82990d410b74c1a5114f5d41eca99aea2c25b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 4 Jun 2026 12:15:17 +0100 Subject: [PATCH 1/2] feat(api): inline shape runs + polygon shape geometry (@since 1.7.0) Add InlineShapeRun for inline geometric figures (dots, arrows, chevrons, diamonds, triangles, stars, checkmarks, plus, regular polygons) drawn on the text baseline from geometry, not font glyphs. Introduce ShapeOutline.Polygon + ShapePoint with a family of factories usable both block-level (ShapeContainer) and inline; DSL shortcuts on ParagraphBuilder/RichText/ShapeContainerBuilder. Additive only, zero breaking changes. --- CHANGELOG.md | 30 +- assets/readme/examples/inline-shapes.pdf | Bin 0 -> 3542 bytes assets/readme/examples/rich-text-showcase.pdf | Bin 3190 -> 3878 bytes examples/README.md | 22 ++ .../demcha/examples/GenerateAllExamples.java | 2 + .../features/text/InlineShapesExample.java | 149 +++++++++ .../text/RichTextShowcaseExample.java | 23 +- .../fixed/pdf/PdfFixedLayoutBackend.java | 50 ++- .../PdfEllipseFragmentRenderHandler.java | 38 +-- .../PdfParagraphFragmentRenderHandler.java | 84 ++++- .../PdfPolygonFragmentRenderHandler.java | 45 +++ .../PdfShapeClipBeginRenderHandler.java | 2 + .../PdfShapeFragmentRenderHandler.java | 2 +- .../fixed/pdf/handlers/PdfShapeGeometry.java | 86 +++++ .../document/dsl/ParagraphBuilder.java | 148 +++++++++ .../demcha/compose/document/dsl/RichText.java | 143 +++++++++ .../document/dsl/ShapeContainerBuilder.java | 75 +++++ .../document/layout/TextFlowSupport.java | 65 +++- .../definitions/ShapeContainerDefinition.java | 10 + .../layout/payloads/ParagraphShapeSpan.java | 52 +++ .../layout/payloads/ParagraphSpan.java | 6 +- .../payloads/PolygonFragmentPayload.java | 37 +++ .../compose/document/node/InlineRun.java | 9 +- .../compose/document/node/InlineShapeRun.java | 77 +++++ .../compose/document/style/ShapeOutline.java | 295 +++++++++++++++++- .../compose/document/style/ShapePoint.java | 29 ++ .../document/dsl/InlineShapeRenderTest.java | 148 +++++++++ .../compose/document/dsl/RichTextTest.java | 77 +++++ .../document/node/InlineShapeRunTest.java | 87 ++++++ .../document/style/ShapeOutlineTest.java | 149 +++++++++ 30 files changed, 1868 insertions(+), 72 deletions(-) create mode 100644 assets/readme/examples/inline-shapes.pdf create mode 100644 examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java create mode 100644 src/main/java/com/demcha/compose/document/node/InlineShapeRun.java create mode 100644 src/main/java/com/demcha/compose/document/style/ShapePoint.java create mode 100644 src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java create mode 100644 src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java create mode 100644 src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be71e63..70a9195c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,35 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. -## v1.6.10 — Planned +## v1.7.0 — Planned -Next bug-fix / housekeeping cycle. Track open in `docs/private/` taskboard. +Canonical DSL primitives — additive only, zero breaking changes. Adding public +API turns the open cycle into a minor. + +### Public API + +- **Inline shape runs — geometry-based dots, diamonds, stars and bullets.** New + `com.demcha.compose.document.node.InlineShapeRun` (`@since 1.7.0`) joins the + sealed `InlineRun` hierarchy alongside text and image runs. It draws any + `ShapeOutline` figure on the paragraph baseline directly from geometry — no + raster payload, no font glyph — so skill rating dots (`Java ●●●●○`), custom + bullets and inline status markers no longer depend on a font shipping + `U+25CF` and friends. Authored through `ParagraphBuilder` / `RichText` + `dot(...)`, `ellipse(...)`, `diamond(...)`, `triangle(...)`, `star(...)` and + the generic `shape(ShapeOutline, ...)`; measured into line width and height + like inline images. A `null` fill paints an outlined figure, a `null` stroke + a filled one; at least one must be present. +- **New polygon shape geometry, usable block-level and inline.** `ShapeOutline` + (`com.demcha.compose.document.style`) gains a `Polygon` kind plus a family of + factories built from normalized `ShapePoint` vertices (`@since 1.7.0`): + `diamond`, `triangle`, `star`, `polygon`, `arrow` / `arrowRight` / `arrowLeft` + (4-way `Direction`), `chevron`, `checkmark`, `plus` and `regularPolygon(sides)`. + Arrows and chevrons read as directional list bullets or inline markers + between text ("Step 1 → Step 2", "Home › Docs"). `ParagraphBuilder` / + `RichText` add `arrow(size, Direction, fill)` and `chevron(...)` shortcuts + (every other kind is reachable through `shape(ShapeOutline, ...)`); + `ShapeContainerBuilder` exposes matching block outlines. Rectangle, + rounded-rectangle and ellipse shape containers are unchanged. ## v1.6.9 — 2026-06-03 diff --git a/assets/readme/examples/inline-shapes.pdf b/assets/readme/examples/inline-shapes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d457e6587e9d0592d123343344b7013b69ec9afd GIT binary patch literal 3542 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 literal 0 HcmV?d00001 diff --git a/assets/readme/examples/rich-text-showcase.pdf b/assets/readme/examples/rich-text-showcase.pdf index f10d97d79189199bcbe630d7678235628078b0a4..87da3a114213e35b7d4cbd2667aa732c44f43713 100644 GIT binary patch delta 3566 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 YCk>)a(WB*|k&}lgfJH=5`e^X~0VDITKL7v# delta 2872 zcma);Su_-m0>&AG3?XD!gUD`XFlI8N>;|JqDoeKPhOsYUkTuCLmNaC|E@aIf$r$=0 z+eC>PTgj50XAL8Jwq%5OQ_%w6`~Sk>FoudX%q*w#6>s>OvJiypYL@=nwi2z{M(!p|jYhHdi4N6$ zZQ2LVIHJ{ruE|+fp2tL)r$7qAR9DR&#b)H-x&w+S=HnXl`Y}(tYM#<_w;v@k+($uB z{NbL~!@pMq_jT}Dzprt*lp8if`dGIggt2T_*@yk?m_e52yhEWiupwa@Li^i8oMKlW z_P-(w?Jl9tA>y8-eE`Tk_em!a42=TAB41k>SF5%}m`S_A_ta|_ee9@?54^kf-CJ

nt+-Bbf(0rWQ&S zp`s(dv+EJAL8~ymI%bIHc@|hFLsTH9@4JI$m004%g}U^+SGX zpzp9Q!=M7uFVzUJz=i>Ks4OHxv19Mu{C%B$*VtA9ASQcvay)p!F6T%?d#3$mN2M$J zaB=v4m3sc+!`chp6TO$nvv%LB_ZpnKHi$Hnvm>g^>sH zrV!Aqnr(ySN6T@a0}f5>OTey7@B9hz3YZaK3JT?x`F1vy`KOh2P<<4E{3)epe1c0Q zH~;+W9;YIEg?jp{k|P8RRIWy&+{$cw_Dz=9CztzNOav3>?c= z8ky(vuHGe2g^N((F8c%ZH7d{r=Py0LclV!))(ePytW(Fi!lk7*HJzeVEzieV2{Khi z^^1N(fkfYZ6g`UuB^VQK-=yheBccGH2YjtE%z3fW*JKl;<7t8cIt{N~=81jMwYJa< zW>zaHeEdKRz6NK;T-1BQ-63^#6d%9-J%&ic81Y;TNG|oTB1U(}WJ!)Po-!{Ro(T_V zRKo-1k1wVUSnsn8ZUI4B5ubR6#-~m;w@wnWi?G%~(`E)w!U*G^G{rkxmp9SB3=DE@ zzUt3>nktQ;0?^?Bp=dAaq}kWYyZMgo>W4~J=9FGIk?+0u6_p`-o=rRXf{a%-!7?Lt zRTy!*Eb$noJ&g>e6d|COyJDDy%Nd>d<%*3sW8{Cm>urH4vFcvzFE=n|_yU8!+Swmn zbRi(F1~bRGf}-``UKX}J-1wX<>S@V~x0LtIecXW~pO}dR_$eX0*Y~iq%LX<}pZb#b z67|1(r+&td8Q2aq>WJ|c4$Vk=r)5iGC(wO*p1*#SlWx!_shU39Rq!tZHcK{^?{Pba z`N?rN+hYN|rA*NV(N=;Z2CS*t|I|&;uMv7VT#p#G6}WB`R7DXrKkk(4@|p5n!++uG z$n#*ifb{a4J+HHfFF4Qc^OQ9rmAW8SU0NjaP-QVb3}PVv)1JtzxlH`9d$scT?L*p5 zf|C0C)TDv3=0lH1#qcYk3THZ7+BX{+Z>>te{cOUOo~4AMP{|qdgAZT?DRJ)TwMIg( zmm}KtdVY7&Q|j%f13WRS&Q-KpMF6yhsTyD+8JASVCfhlot35+_x{eaLSK-yz& z?;SYcN%05)+H%Ot<==P;y;7d#(kA#@^v8Ozt*7+_V_e95yZRza2t+v;!!W=~s~(|H zwB~B3MeTd_y-ZT+Jpv>H+e>|5X&M?R#>xTqt)5;T^e&d3vM7 z=$|G`X70wl%uftlOU^Hh?noZyHfny^z;@pK!Si0`1CdiyjQqYTxv<)CZ+Kg?S@XGP zZ8t-}Y=;_G*9oRw)AVez>{ud_UpMk-SiQCxd#)HtEZi^=+YVIzWi?*Yzf&{0j?NUX z$>eJKVZ7+*CRseB!g64J&$$sR5O~ri8qV3|d&16%yLk&-j0r3IU)k%FI6#@jLsR{BA@N;&+odt#De$K@D%tAQg zPeG}(;#s;8cZ44SFfA|px*i2tbG_kwqNw_gXQ(01@npYvZDKIX*o=>61%^7ChgTRB z;eebxd@%q%kZJ*+Jp&FTORc)WtDG+AVs=K&vAOs(%EzotWX)s$rd7?1(wG^dr5jfT~b7CIBcFSNZ zE65dZQYAO~(}qK+F{QJ<^Nj-!=U^l27`LC#IzPI4HzU@;aPG=i$INoNhwzf>Vb!#_ z5<>i5%x#-$JAZL#d3cKLmD3P-su_g-w7t>DO5&o(=`?&6{sv8Xwbj2UP+wM8% z<=-cJ3kd_TQ~g7@L3ufX&zc4W?Uh-e#!7QZ$kSw6Q=})RBqkM3Z-1eAzt#UkuQ&Lc zliUJF>4_R%ha7b+L=CT_OTMA_net%T(x8*EJFnzky`<80uIuWv>vf;Id%Mmh<*16w zPu)GaF|W~qwkIR}H(?}4VAerNbW4b2opxA#64Sk=Zogars>6tz5y^ksu~6O+aj2qQ&~@TSFr03#SM$ rich + .plain("Draft ") + .arrow(8, ShapeOutline.Direction.RIGHT, accent) + .plain(" Review ") + .arrow(8, ShapeOutline.Direction.RIGHT, accent) + .plain(" Published")) +// also: dot(size, fill), diamond, triangle, star, chevron, +// or shape(ShapeOutline.checkmark(size, size), fill) for any figure +``` + +[📄 View PDF](../assets/readme/examples/inline-shapes.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) + ### Section presets `pageBackground`, `band`, `softPanel`, the four diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index b7c6128f..37a05dec 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -9,6 +9,7 @@ import com.demcha.examples.features.streaming.HttpStreamingExample; import com.demcha.examples.features.tables.ComposedTableCellExample; import com.demcha.examples.features.tables.TableAdvancedExample; +import com.demcha.examples.features.text.InlineShapesExample; import com.demcha.examples.features.text.RichTextShowcaseExample; import com.demcha.examples.features.text.SectionPresetsExample; import com.demcha.examples.features.themes.CustomBusinessThemeExample; @@ -127,6 +128,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + TableAdvancedExample.generate()); // Text + sections + System.out.println("Generated: " + InlineShapesExample.generate()); System.out.println("Generated: " + RichTextShowcaseExample.generate()); System.out.println("Generated: " + SectionPresetsExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java new file mode 100644 index 00000000..a0b383f0 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java @@ -0,0 +1,149 @@ +package com.demcha.examples.features.text; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.RichText; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.document.theme.BusinessTheme; +import com.demcha.compose.font.FontName; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * Runnable showcase for inline shape runs ({@code @since 1.7.0}). + * + *

Geometric figures — rating dots, arrows, chevrons, diamonds, stars, + * checkmarks, plus signs, regular polygons — drawn on the text baseline from + * geometry (no font glyphs), used between text and as list bullets. Each row + * pairs the rendered output with the {@code ParagraphBuilder} / {@code RichText} + * call that produced it, so the PDF reads like a quick reference.

+ */ +public final class InlineShapesExample { + private static final BusinessTheme THEME = BusinessTheme.modern(); + private static final DocumentColor MUTED = DocumentColor.rgb(112, 116, 128); + private static final DocumentColor BRAND = DocumentColor.rgb(20, 80, 95); + private static final DocumentColor ACCENT = DocumentColor.rgb(196, 153, 76); + private static final DocumentColor GREEN = DocumentColor.rgb(34, 130, 92); + private static final DocumentColor PANEL = DocumentColor.rgb(248, 244, 234); + + private InlineShapesExample() { + } + + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("features/text", "inline-shapes.pdf"); + + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(DocumentPageSize.A4) + .pageBackground(THEME.pageBackground()) + .margin(34, 34, 34, 34) + .create()) { + + document.pageFlow() + .name("InlineShapesShowcase") + .spacing(14) + .addSection("Hero", section -> section + .softPanel(THEME.palette().surfaceMuted(), 10, 16) + .accentLeft(ACCENT, 4) + .spacing(6) + .addParagraph(p -> p + .text("Inline shapes") + .textStyle(THEME.text().h1()) + .margin(DocumentInsets.zero())) + .addRich(rich -> rich + .plain("Geometric figures drawn on the text baseline ") + .accent("from geometry, not font glyphs", BRAND) + .plain(" — between text and as list bullets, at any size and colour."))) + .addSection("Ratings", section -> labelledRow(section, + "dot(size, fill) — filled and outlined rating dots", + rich -> rich + .plain("Java ") + .dot(5, BRAND).dot(5, BRAND).dot(5, BRAND).dot(5, BRAND) + .dot(5, null, DocumentStroke.of(BRAND, 0.6)) + .plain(" Kotlin ") + .dot(5, BRAND).dot(5, BRAND).dot(5, BRAND) + .dot(5, null, DocumentStroke.of(BRAND, 0.6)) + .dot(5, null, DocumentStroke.of(BRAND, 0.6)))) + .addSection("Flows", section -> labelledRow(section, + "arrow(size, Direction, fill) — direction between text", + rich -> rich + .plain("Draft ").arrow(8, ShapeOutline.Direction.RIGHT, ACCENT) + .plain(" Review ").arrow(8, ShapeOutline.Direction.RIGHT, ACCENT) + .plain(" Published"))) + .addSection("Breadcrumb", section -> labelledRow(section, + "chevron(size, Direction, fill) — light directional separator", + rich -> rich + .plain("Home ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED) + .plain(" Docs ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED) + .plain(" API ").chevron(6, ShapeOutline.Direction.RIGHT, MUTED) + .plain(" InlineShapeRun"))) + .addSection("Checklist", section -> section + .softPanel(PANEL, 6, 12) + .spacing(5) + .addParagraph(p -> p + .text("shape(ShapeOutline.checkmark(...)/plus(...), fill) — checklist markers") + .textStyle(caption()) + .margin(DocumentInsets.zero())) + .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN) + .plain(" Figures render from geometry")) + .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN) + .plain(" They reuse the ShapeOutline taxonomy")) + .addRich(rich -> rich.shape(ShapeOutline.plus(9, 9), ACCENT) + .plain(" A new figure is one factory away"))) + .addSection("Bullets", section -> labelledRow(section, + "any ShapeOutline as a list bullet", + rich -> rich + .diamond(7, ACCENT).plain(" Diamond ") + .star(8, ACCENT).plain(" Star ") + .triangle(7, BRAND).plain(" Triangle ") + .arrow(8, ShapeOutline.Direction.RIGHT, BRAND).plain(" Arrow ") + .shape(ShapeOutline.regularPolygon(8, 8, 6), MUTED).plain(" Hexagon"))) + .addSection("Footer", section -> section + .accentTop(THEME.palette().rule(), 0.6) + .padding(new DocumentInsets(8, 0, 0, 0)) + .addRich(rich -> rich + .plain("Source: ") + .style("examples/.../InlineShapesExample.java", + DocumentTextStyle.builder() + .fontName(FontName.COURIER) + .size(8) + .color(MUTED) + .build()))) + .build(); + + document.buildPdf(); + } + + return outputFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } + + private static void labelledRow(SectionBuilder section, String label, Consumer body) { + section + .softPanel(PANEL, 6, 12) + .spacing(4) + .addParagraph(p -> p + .text(label) + .textStyle(caption()) + .margin(DocumentInsets.zero())) + .addRich(body::accept); + } + + private static DocumentTextStyle caption() { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(8.5) + .color(MUTED) + .build(); + } +} diff --git a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java index 5ad8850d..38b35725 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java @@ -5,7 +5,9 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.document.theme.BusinessTheme; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; @@ -17,7 +19,8 @@ * *

Walks through every fluent method on {@code RichText} — * {@code plain / bold / italic / boldItalic / underline / strikethrough / - * color / accent / size / style / link / append} — laid out as labelled + * color / accent / size / style / link / append / dot / ellipse / diamond / + * star / shape} — laid out as labelled * "what does this look like" rows on a single A4 page so the rendered PDF * reads like a quick reference.

*/ @@ -121,6 +124,24 @@ public static Path generate() throws Exception { .plain("Pre-built ") .append(reusableRun()) .plain(" composes with ad-hoc fragments — share recurring fragments across paragraphs."))) + .addSection("Inline shapes", section -> labelledRow(section, + "dot / diamond / star / arrow / chevron / shape", + rich -> rich + .plain("Java ") + .dot(5, BRAND) + .dot(5, BRAND) + .dot(5, BRAND) + .dot(5, null, DocumentStroke.of(BRAND, 0.6)) + .plain(" Step 1 ") + .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT) + .plain(" Step 2 Home ") + .chevron(6, ShapeOutline.Direction.RIGHT, MUTED) + .plain(" Docs ") + .diamond(7, ACCENT) + .plain(" ") + .star(8, ACCENT) + .plain(" ") + .shape(ShapeOutline.checkmark(8, 8), BRAND))) .addSection("Footer", section -> section .accentTop(THEME.palette().rule(), 0.6) .padding(new DocumentInsets(8, 0, 0, 0)) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index 1d8710b9..39254ff3 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext; import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfBarcodeFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfEllipseFragmentRenderHandler; +import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfPolygonFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfImageFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfLineFragmentRenderHandler; import com.demcha.compose.document.backend.fixed.pdf.handlers.PdfParagraphFragmentRenderHandler; @@ -96,6 +97,7 @@ private static List> defaultHandlers() { new PdfShapeFragmentRenderHandler(), new PdfLineFragmentRenderHandler(), new PdfEllipseFragmentRenderHandler(), + new PdfPolygonFragmentRenderHandler(), new PdfImageFragmentRenderHandler(), new PdfTableRowFragmentRenderHandler(), new PdfShapeClipBeginRenderHandler(), @@ -411,29 +413,43 @@ private static PdfLinkAnnotationWriter.PlacedPdfRect spanLinkRectangle(Paragraph double lineHeight, double textAscent, double baselineOffsetFromBottom) { + com.demcha.compose.document.node.InlineImageAlignment alignment; + double graphicHeight; + double baselineOffset; if (span instanceof ParagraphImageSpan imageSpan) { - double baselineY = lineTop - lineHeight + baselineOffsetFromBottom; - double lineBottom = baselineY - baselineOffsetFromBottom; - double base = switch (imageSpan.alignment() == null - ? com.demcha.compose.document.node.InlineImageAlignment.CENTER - : imageSpan.alignment()) { - case BASELINE -> baselineY; - case CENTER -> lineBottom + (lineHeight - imageSpan.height()) / 2.0; - case TEXT_TOP -> baselineY + textAscent - imageSpan.height(); - case TEXT_BOTTOM -> lineBottom; - }; - double imageBottom = base + imageSpan.baselineOffset(); + alignment = imageSpan.alignment(); + graphicHeight = imageSpan.height(); + baselineOffset = imageSpan.baselineOffset(); + } else if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphShapeSpan shapeSpan) { + alignment = shapeSpan.alignment(); + graphicHeight = shapeSpan.height(); + baselineOffset = shapeSpan.baselineOffset(); + } else { + // Text spans cover the full line box. return new PdfLinkAnnotationWriter.PlacedPdfRect( spanX, - imageBottom, - imageSpan.width(), - imageSpan.height()); - } + lineTop - lineHeight, + span.width(), + lineHeight); + } + // Inline-graphic baseline placement, kept in lockstep with + // PdfParagraphFragmentRenderHandler.resolveInlineGraphicBottom — both + // place an inline image or shape on the text baseline identically. + double baselineY = lineTop - lineHeight + baselineOffsetFromBottom; + double lineBottom = baselineY - baselineOffsetFromBottom; + double base = switch (alignment == null + ? com.demcha.compose.document.node.InlineImageAlignment.CENTER + : alignment) { + case BASELINE -> baselineY; + case CENTER -> lineBottom + (lineHeight - graphicHeight) / 2.0; + case TEXT_TOP -> baselineY + textAscent - graphicHeight; + case TEXT_BOTTOM -> lineBottom; + }; return new PdfLinkAnnotationWriter.PlacedPdfRect( spanX, - lineTop - lineHeight, + base + baselineOffset, span.width(), - lineHeight); + graphicHeight); } @SuppressWarnings("unchecked") diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java index 11eeacbc..32aeafd7 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfEllipseFragmentRenderHandler.java @@ -35,40 +35,16 @@ public void render(PlacedFragment fragment, if (fragment.width() <= 0 || fragment.height() <= 0) { return; } - - boolean hasFill = payload.fillColor() != null; - boolean hasStroke = payload.stroke() != null - && payload.stroke().strokeColor() != null - && payload.stroke().strokeColor().color() != null - && payload.stroke().width() > 0; - if (!hasFill && !hasStroke) { - return; - } - PDPageContentStream stream = environment.pageSurface(fragment.pageIndex()); - stream.saveGraphicsState(); - try { - if (hasStroke) { - stream.setStrokingColor(payload.stroke().strokeColor().color()); - stream.setLineWidth((float) payload.stroke().width()); - } - if (hasFill) { - stream.setNonStrokingColor(payload.fillColor()); - } - drawEllipse(stream, (float) fragment.x(), (float) fragment.y(), (float) fragment.width(), (float) fragment.height()); - if (hasFill && hasStroke) { - stream.fillAndStroke(); - } else if (hasFill) { - stream.fill(); - } else { - stream.stroke(); - } - } finally { - stream.restoreGraphicsState(); - } + float x = (float) fragment.x(); + float y = (float) fragment.y(); + float width = (float) fragment.width(); + float height = (float) fragment.height(); + PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), + s -> drawEllipse(s, x, y, width, height)); } - private static void drawEllipse(PDPageContentStream stream, + static void drawEllipse(PDPageContentStream stream, float x, float y, float width, diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index e79e91ee..f57c52b7 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -4,11 +4,13 @@ import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan; import com.demcha.compose.document.layout.payloads.ParagraphImageSpan; import com.demcha.compose.document.layout.payloads.ParagraphLine; import com.demcha.compose.document.layout.payloads.ParagraphSpan; import com.demcha.compose.document.layout.payloads.ParagraphTextSpan; import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.font.FontLibrary; import com.demcha.compose.engine.render.pdf.PdfFont; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -127,6 +129,14 @@ private void renderLine(PDPageContentStream stream, (float) imageSpan.width(), (float) imageSpan.height()); cursorX += imageSpan.width(); + } else if (span instanceof ParagraphShapeSpan shapeSpan) { + if (inTextBlock) { + stream.endText(); + inTextBlock = false; + } + renderShape(stream, shapeSpan, cursorX, baselineY, + line.textAscent(), line.baselineOffsetFromBottom(), line.lineHeight()); + cursorX += shapeSpan.width(); } } } finally { @@ -141,18 +151,78 @@ private static double resolveImageBottom(ParagraphImageSpan imageSpan, double textAscent, double baselineOffsetFromBottom, double lineHeight) { - double imageHeight = imageSpan.height(); + return resolveInlineGraphicBottom( + imageSpan.height(), + imageSpan.alignment(), + imageSpan.baselineOffset(), + baselineY, + textAscent, + baselineOffsetFromBottom, + lineHeight); + } + + /** + * Resolves the PDF-space bottom edge of an inline graphic (image or + * ellipse) for the given vertical alignment. Shared by both span kinds so + * dots and icons sit identically next to text. + */ + private static double resolveInlineGraphicBottom(double graphicHeight, + InlineImageAlignment alignment, + double baselineOffset, + double baselineY, + double textAscent, + double baselineOffsetFromBottom, + double lineHeight) { double lineBottom = baselineY - baselineOffsetFromBottom; - double base = switch (imageSpan.alignment() == null ? InlineImageAlignment.CENTER : imageSpan.alignment()) { + double base = switch (alignment == null ? InlineImageAlignment.CENTER : alignment) { case BASELINE -> baselineY; - // Visually centers the image inside the resolved line box + // Visually centers the graphic inside the resolved line box // (lineBottom + lineHeight/2). This matches how readers expect - // icons next to text to sit, regardless of text ascender height. - case CENTER -> lineBottom + (lineHeight - imageHeight) / 2.0; - case TEXT_TOP -> baselineY + textAscent - imageHeight; + // icons or dots next to text to sit, regardless of ascender height. + case CENTER -> lineBottom + (lineHeight - graphicHeight) / 2.0; + case TEXT_TOP -> baselineY + textAscent - graphicHeight; case TEXT_BOTTOM -> lineBottom; }; - return base + imageSpan.baselineOffset(); + return base + baselineOffset; + } + + private static void renderShape(PDPageContentStream stream, + ParagraphShapeSpan span, + double cursorX, + double baselineY, + double textAscent, + double baselineOffsetFromBottom, + double lineHeight) throws IOException { + double width = span.width(); + double height = span.height(); + if (width <= 0 || height <= 0) { + return; + } + double bottom = resolveInlineGraphicBottom( + height, + span.alignment(), + span.baselineOffset(), + baselineY, + textAscent, + baselineOffsetFromBottom, + lineHeight); + float x = (float) cursorX; + float y = (float) bottom; + float w = (float) width; + float h = (float) height; + ShapeOutline outline = span.outline(); + PdfShapeGeometry.fillAndStrokePath(stream, span.fillColor(), span.stroke(), s -> { + if (outline instanceof ShapeOutline.Ellipse) { + PdfEllipseFragmentRenderHandler.drawEllipse(s, x, y, w, h); + } else if (outline instanceof ShapeOutline.Rectangle) { + s.addRect(x, y, w, h); + } else if (outline instanceof ShapeOutline.RoundedRectangle r) { + float radius = (float) Math.min(r.cornerRadius(), Math.min(w, h) / 2.0f); + PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, x, y, w, h, radius, radius, radius, radius); + } else if (outline instanceof ShapeOutline.Polygon p) { + PdfShapeGeometry.addPolygonPath(s, x, y, w, h, p.points()); + } + }); } } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java new file mode 100644 index 00000000..f2d8bc02 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java @@ -0,0 +1,45 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler; +import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload; +import org.apache.pdfbox.pdmodel.PDPageContentStream; + +import java.io.IOException; + +/** + * Renders fixed polygon fragments (diamond, triangle, star, arbitrary rings). + * + * @author Artem Demchyshyn + */ +public final class PdfPolygonFragmentRenderHandler + implements PdfFragmentRenderHandler { + + /** + * Creates the polygon fragment renderer. + */ + public PdfPolygonFragmentRenderHandler() { + } + + @Override + public Class payloadType() { + return PolygonFragmentPayload.class; + } + + @Override + public void render(PlacedFragment fragment, + PolygonFragmentPayload payload, + PdfRenderEnvironment environment) throws IOException { + if (fragment.width() <= 0 || fragment.height() <= 0) { + return; + } + PDPageContentStream stream = environment.pageSurface(fragment.pageIndex()); + float x = (float) fragment.x(); + float y = (float) fragment.y(); + float width = (float) fragment.width(); + float height = (float) fragment.height(); + PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), + s -> PdfShapeGeometry.addPolygonPath(s, x, y, width, height, payload.points())); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java index 6e42ad3e..6b030f1e 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeClipBeginRenderHandler.java @@ -79,6 +79,8 @@ public void render(PlacedFragment fragment, (float) Math.min(r.cornerRadius(), Math.min(width, height) / 2.0f)); } else if (outline instanceof ShapeOutline.Rectangle) { stream.addRect(x, y, width, height); + } else if (outline instanceof ShapeOutline.Polygon p) { + PdfShapeGeometry.addPolygonPath(stream, x, y, width, height, p.points()); } else { throw new IllegalStateException("Unknown outline: " + outline); } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java index 3a052cc4..4cc840c3 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java @@ -132,7 +132,7 @@ private static float clampCornerRadius(double raw, float maxAllowed) { * sharp 90° corner (no arc, just a line into the corner). PDF y * grows up, so {@code (x, y)} is the bottom-left of the rectangle. */ - private static void drawRoundedRectangle(PDPageContentStream stream, + static void drawRoundedRectangle(PDPageContentStream stream, float x, float y, float width, diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java new file mode 100644 index 00000000..0869ffb5 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java @@ -0,0 +1,86 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.style.ShapePoint; +import com.demcha.compose.engine.components.content.shape.Stroke; +import org.apache.pdfbox.pdmodel.PDPageContentStream; + +import java.awt.Color; +import java.io.IOException; +import java.util.List; + +/** + * Shared PDF path helpers for shape geometry, so block render, clip masking and + * inline shape render emit identical paths from one source. + */ +final class PdfShapeGeometry { + private PdfShapeGeometry() { + } + + /** + * A path contribution: the caller adds the geometry (ellipse, rectangle, + * polygon, …) so the fill/stroke wrapper can be shared. + */ + @FunctionalInterface + interface PathEmitter { + void emit(PDPageContentStream stream) throws IOException; + } + + /** + * Paints a path with optional fill and/or stroke, sharing the + * save/restore + colour setup + fill/stroke selection across every shape + * render handler. No-op when neither a fill nor a visible stroke is present. + */ + static void fillAndStrokePath(PDPageContentStream stream, + Color fillColor, + Stroke stroke, + PathEmitter path) throws IOException { + boolean hasFill = fillColor != null; + boolean hasStroke = stroke != null + && stroke.strokeColor() != null + && stroke.strokeColor().color() != null + && stroke.width() > 0; + if (!hasFill && !hasStroke) { + return; + } + stream.saveGraphicsState(); + try { + if (hasStroke) { + stream.setStrokingColor(stroke.strokeColor().color()); + stream.setLineWidth((float) stroke.width()); + } + if (hasFill) { + stream.setNonStrokingColor(fillColor); + } + path.emit(stream); + if (hasFill && hasStroke) { + stream.fillAndStroke(); + } else if (hasFill) { + stream.fill(); + } else { + stream.stroke(); + } + } finally { + stream.restoreGraphicsState(); + } + } + + /** + * Appends a closed polygon path to the stream. Normalized vertices (see + * {@link ShapePoint}) are scaled into the {@code [x, x+width] × [y, y+height]} + * box; the caller fills/strokes/clips the resulting path. + */ + static void addPolygonPath(PDPageContentStream stream, + float x, + float y, + float width, + float height, + List points) throws IOException { + ShapePoint first = points.get(0); + stream.moveTo(x + (float) (first.x() * width), y + (float) (first.y() * height)); + for (int i = 1; i < points.size(); i++) { + ShapePoint point = points.get(i); + stream.lineTo(x + (float) (point.x() * width), y + (float) (point.y() * height)); + } + stream.closePath(); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 17cc9bd0..4148f474 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -4,15 +4,19 @@ import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineImageRun; import com.demcha.compose.document.node.InlineRun; import com.demcha.compose.document.node.InlineTextRun; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextAutoSize; import com.demcha.compose.document.style.DocumentTextIndent; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; import java.util.ArrayList; import java.util.List; @@ -240,6 +244,150 @@ public ParagraphBuilder inlineImage(DocumentImageData imageData, return this; } + /** + * Adds an inline filled circle ("dot") measured on the same baseline as the + * surrounding text — the building block for skill rating dots, custom + * bullets and status indicators that should not depend on font glyph + * coverage. + * + * @param diameter circle diameter in points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder dot(double diameter, DocumentColor fill) { + return shape(ShapeOutline.circle(diameter), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline circle with an explicit fill and/or outline stroke — for + * example a filled dot ({@code ●}) or an outlined one ({@code ○}). + * + * @param diameter circle diameter in points + * @param fill optional fill color + * @param stroke optional outline stroke + * @return this builder + */ + public ParagraphBuilder dot(double diameter, DocumentColor fill, DocumentStroke stroke) { + return shape(ShapeOutline.circle(diameter), fill, stroke, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline ellipse measured on the surrounding text baseline. + * + * @param width target width in points + * @param height target height in points ({@code width == height} renders a circle) + * @param fill optional fill color + * @param stroke optional outline stroke + * @return this builder + */ + public ParagraphBuilder ellipse(double width, double height, DocumentColor fill, DocumentStroke stroke) { + return shape(new ShapeOutline.Ellipse(width, height), fill, stroke, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline diamond (rhombus) sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder diamond(double size, DocumentColor fill) { + return shape(ShapeOutline.diamond(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline upward-pointing triangle sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder triangle(double size, DocumentColor fill) { + return shape(ShapeOutline.triangle(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline five-pointed star sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder star(double size, DocumentColor fill) { + return shape(ShapeOutline.star(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline block arrow sized {@code size × size} pointing in + * {@code direction} — a directional marker between text or a list bullet. + * + * @param size figure width and height in points + * @param direction the way the arrow points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder arrow(double size, ShapeOutline.Direction direction, DocumentColor fill) { + return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline chevron sized {@code size × size} pointing in + * {@code direction} — a lighter directional separator for step lists. + * + * @param size figure width and height in points + * @param direction the way the chevron points + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder chevron(double size, ShapeOutline.Direction direction, DocumentColor fill) { + return shape(ShapeOutline.chevron(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline shape of any {@link ShapeOutline} kind with a filled + * interior, default {@link InlineImageAlignment#CENTER} alignment and zero + * offset. + * + * @param outline figure geometry; supplies the run's size + * @param fill fill color + * @return this builder + */ + public ParagraphBuilder shape(ShapeOutline outline, DocumentColor fill) { + return shape(outline, fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Adds an inline shape of any {@link ShapeOutline} kind, measured on the + * surrounding text baseline. At least one of {@code fill} or {@code stroke} + * must be present; vertical alignment defaults to + * {@link InlineImageAlignment#CENTER} when {@code null}. The figure is drawn + * from geometry, so it never depends on font glyph coverage. + * + * @param outline figure geometry; supplies the run's size + * @param fill optional fill color + * @param stroke optional outline stroke + * @param alignment vertical alignment relative to surrounding text + * @param baselineOffset extra vertical shift in points; positive moves up + * @param linkOptions optional inline link metadata + * @return this builder + */ + public ParagraphBuilder shape(ShapeOutline outline, + DocumentColor fill, + DocumentStroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions) { + this.inlineRuns.add(new InlineShapeRun( + outline, + fill, + stroke, + alignment == null ? InlineImageAlignment.CENTER : alignment, + baselineOffset, + linkOptions)); + this.text = ""; + return this; + } + /** * Replaces inline runs with the contents of a {@link RichText} builder. * diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java index c588375b..d24cd485 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RichText.java +++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java @@ -3,12 +3,15 @@ import com.demcha.compose.document.image.DocumentImageData; import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineImageRun; import com.demcha.compose.document.node.InlineRun; import com.demcha.compose.document.node.InlineTextRun; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextDecoration; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; import java.awt.Color; import java.util.ArrayList; @@ -309,6 +312,146 @@ public RichText image(DocumentImageData imageData, return this; } + /** + * Appends an inline filled circle ("dot") run — the building block for + * skill rating dots, custom bullets and inline status indicators that + * should not depend on font glyph coverage. + * + * @param diameter circle diameter in points + * @param fill fill color + * @return this builder + */ + public RichText dot(double diameter, DocumentColor fill) { + return shape(ShapeOutline.circle(diameter), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline circle run with an explicit fill and/or outline stroke + * — for example a filled dot ({@code ●}) or an outlined one ({@code ○}). + * + * @param diameter circle diameter in points + * @param fill optional fill color + * @param stroke optional outline stroke + * @return this builder + */ + public RichText dot(double diameter, DocumentColor fill, DocumentStroke stroke) { + return shape(ShapeOutline.circle(diameter), fill, stroke, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline ellipse run with default + * {@link InlineImageAlignment#CENTER} alignment and zero offset. + * + * @param width target width in points + * @param height target height in points + * @param fill optional fill color + * @param stroke optional outline stroke + * @return this builder + */ + public RichText ellipse(double width, double height, DocumentColor fill, DocumentStroke stroke) { + return shape(new ShapeOutline.Ellipse(width, height), fill, stroke, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline diamond (rhombus) sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public RichText diamond(double size, DocumentColor fill) { + return shape(ShapeOutline.diamond(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline upward-pointing triangle sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public RichText triangle(double size, DocumentColor fill) { + return shape(ShapeOutline.triangle(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline five-pointed star sized {@code size × size}. + * + * @param size figure width and height in points + * @param fill fill color + * @return this builder + */ + public RichText star(double size, DocumentColor fill) { + return shape(ShapeOutline.star(size, size), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline block arrow sized {@code size × size} pointing in + * {@code direction} — a directional marker between text or a list bullet. + * + * @param size figure width and height in points + * @param direction the way the arrow points + * @param fill fill color + * @return this builder + */ + public RichText arrow(double size, ShapeOutline.Direction direction, DocumentColor fill) { + return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline chevron sized {@code size × size} pointing in + * {@code direction} — a lighter directional separator for step lists. + * + * @param size figure width and height in points + * @param direction the way the chevron points + * @param fill fill color + * @return this builder + */ + public RichText chevron(double size, ShapeOutline.Direction direction, DocumentColor fill) { + return shape(ShapeOutline.chevron(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends an inline shape of any {@link ShapeOutline} kind with a filled + * interior, default {@link InlineImageAlignment#CENTER} alignment and zero + * offset. + * + * @param outline figure geometry; supplies the run's size + * @param fill fill color + * @return this builder + */ + public RichText shape(ShapeOutline outline, DocumentColor fill) { + return shape(outline, fill, null, InlineImageAlignment.CENTER, 0.0, null); + } + + /** + * Appends a fully-specified inline shape run of any {@link ShapeOutline} + * kind. At least one of {@code fill} or {@code stroke} must be present. + * + * @param outline figure geometry; supplies the run's size + * @param fill optional fill color + * @param stroke optional outline stroke + * @param alignment vertical alignment relative to surrounding text + * @param baselineOffset extra vertical shift in points; positive moves up + * @param linkOptions optional inline link metadata + * @return this builder + */ + public RichText shape(ShapeOutline outline, + DocumentColor fill, + DocumentStroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions) { + runs.add(new InlineShapeRun( + outline, + fill, + stroke, + alignment == null ? InlineImageAlignment.CENTER : alignment, + baselineOffset, + linkOptions)); + return this; + } + /** * Returns the accumulated runs as an immutable list. * diff --git a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java index 471e8bcb..5b30ce54 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java @@ -107,6 +107,81 @@ public ShapeContainerBuilder circle(double diameter) { return this; } + /** + * Sets a diamond (rhombus) outline. + * + * @param width outline width in points + * @param height outline height in points + * @return this builder + */ + public ShapeContainerBuilder diamond(double width, double height) { + this.outline = ShapeOutline.diamond(width, height); + return this; + } + + /** + * Sets an upward-pointing triangle outline. + * + * @param width outline width in points + * @param height outline height in points + * @return this builder + */ + public ShapeContainerBuilder triangle(double width, double height) { + this.outline = ShapeOutline.triangle(width, height); + return this; + } + + /** + * Sets a five-pointed star outline. + * + * @param width outline width in points + * @param height outline height in points + * @return this builder + */ + public ShapeContainerBuilder star(double width, double height) { + this.outline = ShapeOutline.star(width, height); + return this; + } + + /** + * Sets an {@code n}-pointed star outline. + * + * @param width outline width in points + * @param height outline height in points + * @param points number of outer points (at least 2) + * @return this builder + */ + public ShapeContainerBuilder star(double width, double height, int points) { + this.outline = ShapeOutline.star(width, height, points); + return this; + } + + /** + * Sets a block arrow outline pointing in {@code direction}. + * + * @param width outline width in points + * @param height outline height in points + * @param direction the way the arrow points + * @return this builder + */ + public ShapeContainerBuilder arrow(double width, double height, ShapeOutline.Direction direction) { + this.outline = ShapeOutline.arrow(width, height, direction); + return this; + } + + /** + * Sets a chevron outline pointing in {@code direction}. + * + * @param width outline width in points + * @param height outline height in points + * @param direction the way the chevron points + * @return this builder + */ + public ShapeContainerBuilder chevron(double width, double height, ShapeOutline.Direction direction) { + this.outline = ShapeOutline.chevron(width, height, direction); + return this; + } + /** * Replaces the outline with a pre-built {@link ShapeOutline} value. * diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index 25bc421e..663a5652 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.layout; +import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.ParagraphImageSpan; import com.demcha.compose.document.layout.payloads.ParagraphLine; @@ -9,6 +10,7 @@ import com.demcha.compose.document.layout.payloads.PreparedListLayout; import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.InlineImageRun; import com.demcha.compose.document.node.InlineRun; @@ -22,7 +24,9 @@ import com.demcha.compose.document.style.DocumentTextAutoSize; import com.demcha.compose.document.style.DocumentTextIndent; import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.engine.components.content.ImageData; +import com.demcha.compose.engine.components.content.shape.Stroke; import com.demcha.compose.engine.components.content.text.TextDataBody; import com.demcha.compose.engine.components.content.text.TextIndentStrategy; import com.demcha.compose.engine.components.content.text.TextStyle; @@ -31,6 +35,7 @@ import com.demcha.compose.engine.measurement.TextMeasurementSystem; import com.demcha.compose.engine.text.markdown.MarkDownParser; +import java.awt.Color; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -38,6 +43,7 @@ import static com.demcha.compose.document.layout.DocumentNodeAdapters.toImageData; import static com.demcha.compose.document.layout.DocumentNodeAdapters.toIndentStrategy; import static com.demcha.compose.document.layout.DocumentNodeAdapters.toPadding; +import static com.demcha.compose.document.layout.DocumentNodeAdapters.toStroke; import static com.demcha.compose.document.layout.DocumentNodeAdapters.toTextStyle; import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; @@ -601,6 +607,8 @@ private static boolean paragraphFitsSingleLine(ParagraphNode node, width += measurement.textWidth(engineStyle, textRun.text()); } else if (run instanceof InlineImageRun imageRun) { width += imageRun.width(); + } else if (run instanceof InlineShapeRun shapeRun) { + width += shapeRun.outline().width(); } } return width <= innerWidth; @@ -1253,6 +1261,8 @@ private static List> tokenizeInlineRuns(List } } else if (run instanceof InlineImageRun imageRun) { currentLine.add(InlineImageToken.of(imageRun)); + } else if (run instanceof InlineShapeRun shapeRun) { + currentLine.add(InlineShapeToken.of(shapeRun)); } } @@ -1290,15 +1300,19 @@ private static ParagraphLine toInlineParagraphLine(List token dominantBaselineFromBottom = defaultMetrics.baselineOffsetFromBottom(); } - double maxImageHeight = 0.0; + double maxInlineGraphicHeight = 0.0; for (InlineLayoutToken token : trimmedTokens) { if (token instanceof InlineImageToken imageToken) { - if (imageToken.height() > maxImageHeight) { - maxImageHeight = imageToken.height(); + if (imageToken.height() > maxInlineGraphicHeight) { + maxInlineGraphicHeight = imageToken.height(); + } + } else if (token instanceof InlineShapeToken shapeToken) { + if (shapeToken.height() > maxInlineGraphicHeight) { + maxInlineGraphicHeight = shapeToken.height(); } } } - double resolvedLineHeight = Math.max(dominantTextLineHeight, maxImageHeight); + double resolvedLineHeight = Math.max(dominantTextLineHeight, maxInlineGraphicHeight); List spans = new ArrayList<>(trimmedTokens.size()); StringBuilder text = new StringBuilder(); @@ -1322,6 +1336,15 @@ private static ParagraphLine toInlineParagraphLine(List token imageToken.baselineOffset(), imageToken.linkOptions())); width += imageToken.width(); + } else if (token instanceof InlineShapeToken shapeToken) { + spans.add(new ParagraphShapeSpan( + shapeToken.outline(), + shapeToken.fillColor(), + shapeToken.stroke(), + shapeToken.alignment(), + shapeToken.baselineOffset(), + shapeToken.linkOptions())); + width += shapeToken.width(); } } @@ -1537,7 +1560,7 @@ private static ParagraphIndentSpec from(String bulletOffset, } } - private sealed interface InlineLayoutToken permits InlineTextToken, InlineImageToken { + private sealed interface InlineLayoutToken permits InlineTextToken, InlineImageToken, InlineShapeToken { double width(); } @@ -1586,4 +1609,36 @@ private static InlineImageToken of(InlineImageRun run) { run.linkOptions()); } } + + private record InlineShapeToken( + ShapeOutline outline, + Color fillColor, + Stroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions + ) implements InlineLayoutToken { + private InlineShapeToken { + alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; + } + + @Override + public double width() { + return outline.width(); + } + + private double height() { + return outline.height(); + } + + private static InlineShapeToken of(InlineShapeRun run) { + return new InlineShapeToken( + run.outline(), + run.fill() == null ? null : run.fill().color(), + toStroke(run.stroke()), + run.alignment(), + run.baselineOffset(), + run.linkOptions()); + } + } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java index fd27f4a7..61dfc77c 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeContainerDefinition.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.layout.BoxConstraints; import com.demcha.compose.document.layout.CompositeLayoutSpec; import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload; +import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload; import com.demcha.compose.document.layout.payloads.PreparedStackLayout; import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload; @@ -140,6 +141,15 @@ public List emitFragments(PreparedNode prepa width, height, new ShapeFragmentPayload(awtFill, stroke, r.cornerRadius(), null, null, null)); + } else if (outline instanceof ShapeOutline.Polygon p) { + outlineFragment = new LayoutFragment( + placement.path(), + 0, + padLeft, + padBottom, + width, + height, + new PolygonFragmentPayload(p.points(), awtFill, stroke, null, null)); } else { throw new IllegalStateException("Unsupported shape outline: " + outline); } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java new file mode 100644 index 00000000..b9fa70ba --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java @@ -0,0 +1,52 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.engine.components.content.shape.Stroke; + +import java.awt.Color; +import java.util.Objects; + +/** + * Measured inline shape span inside a paragraph line. + * + *

The semantic {@code InlineShapeRun} is resolved into this payload during + * wrapping: the DSL fill color becomes an AWT {@link Color} and the DSL stroke + * becomes an engine {@link Stroke}, while the {@link ShapeOutline} carries the + * figure geometry (and its intrinsic {@link #width()} / {@link #height()}). The + * PDF backend dispatches on the outline kind to paint the figure.

+ * + * @param outline figure geometry; supplies the span width and height + * @param fillColor optional resolved fill color + * @param stroke optional resolved outline stroke + * @param alignment vertical alignment relative to the surrounding text + * @param baselineOffset extra vertical offset in points; positive moves up + * @param linkOptions optional link metadata + */ +public record ParagraphShapeSpan( + ShapeOutline outline, + Color fillColor, + Stroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions +) implements ParagraphSpan { + /** + * Validates the outline and normalizes alignment defaults. + */ + public ParagraphShapeSpan { + Objects.requireNonNull(outline, "outline"); + alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; + } + + @Override + public double width() { + return outline.width(); + } + + @Override + public double height() { + return outline.height(); + } +} diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java index e5a165db..09537695 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSpan.java @@ -4,10 +4,10 @@ /** * One measured span inside a paragraph line. Sealed because the wrapping - * algorithm can produce either text spans or image spans for the same - * line — both contribute to wrapping width and per-line height. + * algorithm can produce text, image or shape spans for the same line — all + * contribute to wrapping width and per-line height. */ -public sealed interface ParagraphSpan permits ParagraphTextSpan, ParagraphImageSpan { +public sealed interface ParagraphSpan permits ParagraphTextSpan, ParagraphImageSpan, ParagraphShapeSpan { /** * Measured width of this span. * diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java new file mode 100644 index 00000000..eb5ff1c7 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/payloads/PolygonFragmentPayload.java @@ -0,0 +1,37 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.ShapePoint; +import com.demcha.compose.engine.components.content.shape.Stroke; + +import java.awt.Color; +import java.util.List; +import java.util.Objects; + +/** + * PDF payload for a resolved polygon fragment (diamond, triangle, star or any + * vertex ring). The normalized vertices are scaled to the placed fragment's + * size by the render handler. + * + * @param points normalized vertex ring (at least three), in draw order + * @param fillColor optional fill color + * @param stroke optional stroke + * @param linkOptions optional fragment-level link metadata + * @param bookmarkOptions optional fragment-level bookmark metadata + */ +public record PolygonFragmentPayload( + List points, + Color fillColor, + Stroke stroke, + DocumentLinkOptions linkOptions, + DocumentBookmarkOptions bookmarkOptions +) implements PdfSemanticFragmentPayload { + /** + * Copies the vertex ring defensively. + */ + public PolygonFragmentPayload { + Objects.requireNonNull(points, "points"); + points = List.copyOf(points); + } +} diff --git a/src/main/java/com/demcha/compose/document/node/InlineRun.java b/src/main/java/com/demcha/compose/document/node/InlineRun.java index b3c52284..cfceca1b 100644 --- a/src/main/java/com/demcha/compose/document/node/InlineRun.java +++ b/src/main/java/com/demcha/compose/document/node/InlineRun.java @@ -4,11 +4,12 @@ * Marker for a single inline run inside a {@link ParagraphNode}. * *

An inline paragraph is a sequence of runs measured and rendered on the - * same baseline. Today there are two kinds of run: text and image. Both - * participate in the wrapping algorithm so callers can mix small icons or - * badges with styled text without resorting to nested layouts.

+ * same baseline. Today there are three kinds of run: text, image and shape. + * All participate in the wrapping algorithm so callers can mix small icons, + * badges or geometric figures (dots, diamonds, stars, …) with styled text + * without resorting to nested layouts.

* * @author Artem Demchyshyn */ -public sealed interface InlineRun permits InlineTextRun, InlineImageRun { +public sealed interface InlineRun permits InlineTextRun, InlineImageRun, InlineShapeRun { } diff --git a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java new file mode 100644 index 00000000..2ff8591d --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java @@ -0,0 +1,77 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.ShapeOutline; + +import java.util.Objects; + +/** + * One inline shape run inside a {@link ParagraphNode} — a geometric figure + * (circle / ellipse, rectangle, rounded rectangle, diamond, triangle, star, or + * any {@link ShapeOutline}) measured and rendered on the surrounding text + * baseline. + * + *

Inline shapes are measured as part of paragraph wrapping exactly like + * {@link InlineImageRun}: the outline's width and height contribute to span + * placement, line breaking and per-line height, and the figure shares the text + * baseline. The shape is drawn directly from geometry — no raster payload and + * no font glyph — so skill rating dots ({@code Java ●●●●○}), custom bullets and + * inline status markers render regardless of font coverage. The kind is the + * existing {@code ShapeOutline} taxonomy, so any new outline kind is usable + * inline automatically.

+ * + *

At least one of {@code fill} or {@code stroke} must be present, otherwise + * the run would be invisible: a filled figure uses {@code fill} with a + * {@code null} stroke; an outlined figure uses a {@code null} fill with a + * {@code stroke}; the two combined paint a filled-and-outlined figure.

+ * + * @param outline shape geometry; its {@link ShapeOutline#width()} and + * {@link ShapeOutline#height()} are the run's measured size + * @param fill optional fill color; {@code null} leaves the interior empty + * @param stroke optional outline stroke; {@code null} leaves the figure + * without a border + * @param alignment vertical alignment relative to the surrounding text; + * defaults to {@link InlineImageAlignment#CENTER} + * @param baselineOffset extra vertical offset in points applied after + * {@code alignment} resolution; positive values move the + * figure up + * @param linkOptions optional per-run link metadata + * + * @author Artem Demchyshyn + * @since 1.7.0 + */ +public record InlineShapeRun( + ShapeOutline outline, + DocumentColor fill, + DocumentStroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions +) implements InlineRun { + /** + * Validates the outline, requires at least one visible paint, and + * normalizes alignment defaults. + */ + public InlineShapeRun { + Objects.requireNonNull(outline, "outline"); + if (Double.isNaN(baselineOffset) || Double.isInfinite(baselineOffset)) { + throw new IllegalArgumentException("inline shape baselineOffset must be finite: " + baselineOffset); + } + if (fill == null && stroke == null) { + throw new IllegalArgumentException("inline shape must have a fill, a stroke, or both"); + } + alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; + } + + /** + * Convenience constructor for a filled shape with default + * {@link InlineImageAlignment#CENTER} alignment and zero offset. + * + * @param outline shape geometry + * @param fill fill color; must not be {@code null} + */ + public InlineShapeRun(ShapeOutline outline, DocumentColor fill) { + this(outline, Objects.requireNonNull(fill, "fill"), null, InlineImageAlignment.CENTER, 0.0, null); + } +} diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java index 29d89507..9ab51d4f 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java @@ -1,5 +1,9 @@ package com.demcha.compose.document.style; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + /** * Geometric outline of a shape container. Sealed so layout, render, and * snapshot code can pattern-match exhaustively against the supported kinds. @@ -14,7 +18,8 @@ public sealed interface ShapeOutline permits ShapeOutline.Rectangle, ShapeOutline.RoundedRectangle, - ShapeOutline.Ellipse { + ShapeOutline.Ellipse, + ShapeOutline.Polygon { /** * Returns the outline outer width. @@ -83,6 +88,49 @@ record Ellipse(double width, double height) implements ShapeOutline { } } + /** + * Closed polygon outline described by a ring of normalized vertices. The + * vertices live in a unit box (see {@link ShapePoint}) and are scaled to + * {@code width × height} at render time, so one vertex ring renders at any + * size. Diamonds, triangles, stars and arbitrary convex/concave polygons + * are all expressed through this single kind via the factories below. + * + * @param width outer width in points + * @param height outer height in points + * @param points ring of at least three normalized vertices, in draw order + * @since 1.7.0 + */ + record Polygon(double width, double height, List points) implements ShapeOutline { + /** + * Validates dimensions and copies the vertex ring defensively. + */ + public Polygon { + requirePositive("width", width); + requirePositive("height", height); + Objects.requireNonNull(points, "points"); + points = List.copyOf(points); + if (points.size() < 3) { + throw new IllegalArgumentException("polygon needs at least 3 points: " + points.size()); + } + } + } + + /** + * Cardinal direction for directional figures (arrows, chevrons). + * + * @since 1.7.0 + */ + enum Direction { + /** Pointing right. */ + RIGHT, + /** Pointing left. */ + LEFT, + /** Pointing up. */ + UP, + /** Pointing down. */ + DOWN + } + /** * Convenience factory for a circular {@link Ellipse}. * @@ -93,6 +141,251 @@ static Ellipse circle(double diameter) { return new Ellipse(diameter, diameter); } + /** + * Creates a {@link Polygon} from an explicit ring of normalized vertices. + * + * @param width outer width in points + * @param height outer height in points + * @param points ring of at least three normalized vertices, in draw order + * @return polygon outline + */ + static Polygon polygon(double width, double height, List points) { + return new Polygon(width, height, points); + } + + /** + * Creates a four-point diamond (rhombus) inscribed in the box. + * + * @param width outer width in points + * @param height outer height in points + * @return diamond polygon outline + */ + static Polygon diamond(double width, double height) { + return new Polygon(width, height, List.of( + new ShapePoint(0.5, 1.0), + new ShapePoint(1.0, 0.5), + new ShapePoint(0.5, 0.0), + new ShapePoint(0.0, 0.5))); + } + + /** + * Creates an upward-pointing triangle inscribed in the box. + * + * @param width outer width in points + * @param height outer height in points + * @return triangle polygon outline + */ + static Polygon triangle(double width, double height) { + return new Polygon(width, height, List.of( + new ShapePoint(0.5, 1.0), + new ShapePoint(1.0, 0.0), + new ShapePoint(0.0, 0.0))); + } + + /** + * Creates a five-pointed star inscribed in the box. + * + * @param width outer width in points + * @param height outer height in points + * @return five-pointed star polygon outline + */ + static Polygon star(double width, double height) { + return star(width, height, 5); + } + + /** + * Creates an {@code n}-pointed star inscribed in the box, with the first + * point facing up. + * + * @param width outer width in points + * @param height outer height in points + * @param points number of outer points (at least 3) + * @return star polygon outline + */ + static Polygon star(double width, double height, int points) { + if (points < 3) { + throw new IllegalArgumentException("star needs at least 3 points: " + points); + } + double outerRadius = 0.5; + // Inner/outer ratio of a true star polygon (the inner ring sits on the + // chords between outer points); it tends to 1 as the point count grows + // and equals the classic 0.382 at five points. Below five points the + // formula degenerates, so fall back to a fixed spiky ratio. + double innerRatio = points >= 5 + ? Math.cos(2 * Math.PI / points) / Math.cos(Math.PI / points) + : 0.38; + double innerRadius = 0.5 * innerRatio; + double start = Math.PI / 2.0; // first outer vertex faces up + List vertices = new ArrayList<>(points * 2); + for (int i = 0; i < points * 2; i++) { + double radius = (i % 2 == 0) ? outerRadius : innerRadius; + double angle = start + i * Math.PI / points; + double x = clampUnit(0.5 + radius * Math.cos(angle)); + double y = clampUnit(0.5 + radius * Math.sin(angle)); + vertices.add(new ShapePoint(x, y)); + } + return new Polygon(width, height, vertices); + } + + /** + * Creates a block arrow pointing in {@code direction} — a list bullet or an + * inline marker between text ("Step 1 → Step 2"). + * + * @param width outer width in points + * @param height outer height in points + * @param direction the way the arrow points + * @return arrow polygon outline + */ + static Polygon arrow(double width, double height, Direction direction) { + Objects.requireNonNull(direction, "direction"); + double[][] base = { + {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88}, + {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35} + }; + return new Polygon(width, height, directional(base, direction)); + } + + /** + * Creates a right-pointing block arrow. + * + * @param width outer width in points + * @param height outer height in points + * @return right arrow polygon outline + */ + static Polygon arrowRight(double width, double height) { + return arrow(width, height, Direction.RIGHT); + } + + /** + * Creates a left-pointing block arrow. + * + * @param width outer width in points + * @param height outer height in points + * @return left arrow polygon outline + */ + static Polygon arrowLeft(double width, double height) { + return arrow(width, height, Direction.LEFT); + } + + /** + * Creates a chevron ("›") pointing in {@code direction} — a lighter + * directional marker for breadcrumbs and step lists. + * + * @param width outer width in points + * @param height outer height in points + * @param direction the way the chevron points + * @return chevron polygon outline + */ + static Polygon chevron(double width, double height, Direction direction) { + Objects.requireNonNull(direction, "direction"); + double thickness = 0.45; + double[][] base = { + {0.00, 1.00}, {1.00, 0.50}, {0.00, 0.00}, + {thickness, 0.00}, {1.00 - thickness, 0.50}, {thickness, 1.00} + }; + return new Polygon(width, height, directional(base, direction)); + } + + /** + * Creates a checkmark ("✓") figure for "done" items in checklists. + * + * @param width outer width in points + * @param height outer height in points + * @return checkmark polygon outline + */ + static Polygon checkmark(double width, double height) { + double[][] points = { + {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92}, + {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44} + }; + return new Polygon(width, height, toPoints(points)); + } + + /** + * Creates a plus ("+") figure for "add" affordances or checklist markers. + * + * @param width outer width in points + * @param height outer height in points + * @return plus polygon outline + */ + static Polygon plus(double width, double height) { + double low = 0.34; + double high = 0.66; + double[][] points = { + {low, 0.00}, {high, 0.00}, {high, low}, {1.00, low}, + {1.00, high}, {high, high}, {high, 1.00}, {low, 1.00}, + {low, high}, {0.00, high}, {0.00, low}, {low, low} + }; + return new Polygon(width, height, toPoints(points)); + } + + /** + * Creates a regular {@code sides}-gon (pentagon, hexagon, …) inscribed in + * the box, with the first vertex facing up. + * + * @param width outer width in points + * @param height outer height in points + * @param sides number of sides (at least 3) + * @return regular polygon outline + */ + static Polygon regularPolygon(double width, double height, int sides) { + if (sides < 3) { + throw new IllegalArgumentException("regular polygon needs at least 3 sides: " + sides); + } + double start = Math.PI / 2.0; + List vertices = new ArrayList<>(sides); + for (int i = 0; i < sides; i++) { + double angle = start + i * 2.0 * Math.PI / sides; + vertices.add(new ShapePoint( + clampUnit(0.5 + 0.5 * Math.cos(angle)), + clampUnit(0.5 + 0.5 * Math.sin(angle)))); + } + return new Polygon(width, height, vertices); + } + + private static List directional(double[][] base, Direction direction) { + Direction resolved = direction == null ? Direction.RIGHT : direction; + List points = new ArrayList<>(base.length); + for (double[] vertex : base) { + double x = vertex[0]; + double y = vertex[1]; + double tx; + double ty; + switch (resolved) { + case LEFT -> { + tx = 1.0 - x; + ty = y; + } + case UP -> { + tx = y; + ty = x; + } + case DOWN -> { + tx = y; + ty = 1.0 - x; + } + default -> { + tx = x; + ty = y; + } + } + points.add(new ShapePoint(clampUnit(tx), clampUnit(ty))); + } + return points; + } + + private static List toPoints(double[][] raw) { + List points = new ArrayList<>(raw.length); + for (double[] vertex : raw) { + points.add(new ShapePoint(clampUnit(vertex[0]), clampUnit(vertex[1]))); + } + return points; + } + + private static double clampUnit(double value) { + return value < 0.0 ? 0.0 : (value > 1.0 ? 1.0 : value); + } + private static void requirePositive(String label, double value) { if (value <= 0 || Double.isNaN(value) || Double.isInfinite(value)) { throw new IllegalArgumentException(label + " must be finite and positive: " + value); diff --git a/src/main/java/com/demcha/compose/document/style/ShapePoint.java b/src/main/java/com/demcha/compose/document/style/ShapePoint.java new file mode 100644 index 00000000..f56f8862 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/ShapePoint.java @@ -0,0 +1,29 @@ +package com.demcha.compose.document.style; + +/** + * A normalized vertex of a {@link ShapeOutline.Polygon}, expressed in the + * outline's own unit box: {@code x} runs 0 (left) → 1 (right), {@code y} runs + * 0 (bottom) → 1 (top), following the PDF y-up convention. Points are scaled to + * the outline's {@code width × height} at render time, so the same normalized + * polygon renders at any size. + * + * @param x normalized horizontal position in {@code [0, 1]} + * @param y normalized vertical position in {@code [0, 1]} (0 = bottom, 1 = top) + * @author Artem Demchyshyn + * @since 1.7.0 + */ +public record ShapePoint(double x, double y) { + /** + * Validates that both coordinates are finite and within the unit box. + */ + public ShapePoint { + requireUnit("x", x); + requireUnit("y", y); + } + + private static void requireUnit(String label, double value) { + if (Double.isNaN(value) || Double.isInfinite(value) || value < 0.0 || value > 1.0) { + throw new IllegalArgumentException(label + " must be a finite value within [0, 1]: " + value); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java new file mode 100644 index 00000000..07c66085 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java @@ -0,0 +1,148 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineImageAlignment; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.ShapeOutline; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for inline shape runs: the measure → tokenize → span → + * PDF render pipeline must paint geometric figures (dots, diamonds, stars, …) + * without dropping them or substituting glyphs. + */ +class InlineShapeRenderTest { + + private static final DocumentColor ACCENT = DocumentColor.of(new java.awt.Color(40, 90, 180)); + + @Test + void ratingShapesRenderEndToEndKeepingTextWithoutGlyphSubstitution() throws Exception { + byte[] pdf = renderRatingRow(); + assertThat(pdf).isNotEmpty(); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isEqualTo(1); + String text = new PDFTextStripper().getText(document); + assertThat(text).contains("Java"); + assertThat(text).doesNotContain("?"); + } + } + + @Test + void inlineShapesActuallyPaintTheirFillColor() throws Exception { + try (PDDocument document = Loader.loadPDF(renderRatingRow())) { + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 96); + // The accent fill only enters the page through the inline figures — + // the text is default black and the background white — so finding + // accent pixels proves the figures were drawn, not silently dropped. + assertThat(containsColorNear(image, 40, 90, 180, 45)) + .as("inline shapes must paint their accent fill") + .isTrue(); + } + } + + @Test + void linkedInlineShapeEmitsClickableAnnotation() throws Exception { + byte[] pdf; + try (DocumentSession session = GraphCompose.document() + .pageSize(220, 120) + .margin(14, 14, 14, 14) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(paragraph -> paragraph + .inlineText("Home ") + .shape(ShapeOutline.diamond(8, 8), ACCENT, null, + InlineImageAlignment.CENTER, 0.0, + new DocumentLinkOptions("https://example.com"))) + .build(); + pdf = session.toPdfBytes(); + } + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getPage(0).getAnnotations()) + .anyMatch(annotation -> annotation instanceof PDAnnotationLink); + } + } + + @Test + void everyOutlineKindRendersWithoutThrowing() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(280, 160) + .margin(14, 14, 14, 14) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(paragraph -> paragraph + .inlineText("Shapes ") + .shape(new ShapeOutline.Rectangle(8, 8), ACCENT) + .shape(new ShapeOutline.RoundedRectangle(8, 8, 2), ACCENT) + .shape(new ShapeOutline.Ellipse(8, 8), ACCENT, null, + InlineImageAlignment.TEXT_TOP, 0.0, null) + .diamond(8, ACCENT) + .triangle(8, ACCENT) + .star(8, ACCENT) + .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT) + .chevron(8, ShapeOutline.Direction.LEFT, ACCENT) + .shape(ShapeOutline.checkmark(8, 8), ACCENT) + .shape(ShapeOutline.plus(8, 8), ACCENT) + .shape(ShapeOutline.regularPolygon(8, 8, 6), ACCENT)) + .build(); + assertThat(session.toPdfBytes()).isNotEmpty(); + } + } + + private static byte[] renderRatingRow() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 160) + .margin(16, 16, 16, 16) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(paragraph -> paragraph + .name("SkillRating") + .inlineText("Java ") + .dot(7, ACCENT) + .dot(7, ACCENT) + .dot(7, ACCENT) + .dot(7, null, DocumentStroke.of(ACCENT, 0.6)) + .inlineText(" ") + .diamond(8, ACCENT) + .star(9, ACCENT)) + .build(); + return session.toPdfBytes(); + } + } + + private static boolean containsColorNear(BufferedImage image, int r, int g, int b, int tolerance) { + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int rgb = image.getRGB(x, y); + int rr = (rgb >> 16) & 0xFF; + int gg = (rgb >> 8) & 0xFF; + int bb = rgb & 0xFF; + if (Math.abs(rr - r) <= tolerance + && Math.abs(gg - g) <= tolerance + && Math.abs(bb - b) <= tolerance) { + return true; + } + } + } + return false; + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java index 2759ecad..1d54c754 100644 --- a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java @@ -1,12 +1,16 @@ package com.demcha.compose.document.dsl; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.InlineRun; +import com.demcha.compose.document.node.InlineShapeRun; import com.demcha.compose.document.node.InlineTextRun; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.SectionNode; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.ShapeOutline; import org.junit.jupiter.api.Test; import java.awt.Color; @@ -211,4 +215,77 @@ void documentDslRichTextBuildsEquivalentRunSequence() { throw new RuntimeException(e); } } + + @Test + void dotProducesFilledCircleShapeRunWithCenterDefault() { + List runs = RichText.empty().dot(6.0, RED).runs(); + assertThat(runs).hasSize(1); + InlineShapeRun dot = (InlineShapeRun) runs.get(0); + assertThat(dot.outline()).isInstanceOf(ShapeOutline.Ellipse.class); + assertThat(dot.outline().width()).isEqualTo(6.0, within(EPS)); + assertThat(dot.fill()).isEqualTo(RED); + assertThat(dot.stroke()).isNull(); + assertThat(dot.alignment()).isEqualTo(InlineImageAlignment.CENTER); + } + + @Test + void ratingDotsMixWithTextInSourceOrder() { + List runs = RichText.text("Java ") + .dot(5.0, ACCENT) + .dot(5.0, ACCENT) + .dot(5.0, null, DocumentStroke.of(ACCENT, 0.5)) + .runs(); + + assertThat(runs).hasSize(4); + assertThat(runs.get(0)).isInstanceOf(InlineTextRun.class); + assertThat(runs.get(1)).isInstanceOf(InlineShapeRun.class); + + InlineShapeRun outlined = (InlineShapeRun) runs.get(3); + assertThat(outlined.fill()).isNull(); + assertThat(outlined.stroke()).isNotNull(); + } + + @Test + void diamondAndStarFactoriesProducePolygonShapeRuns() { + InlineShapeRun diamond = (InlineShapeRun) RichText.empty().diamond(8, ACCENT).runs().get(0); + InlineShapeRun star = (InlineShapeRun) RichText.empty().star(8, ACCENT).runs().get(0); + + assertThat(diamond.outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(star.outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(((ShapeOutline.Polygon) star.outline()).points()).hasSize(10); + } + + @Test + void shapeAcceptsAnyOutlineAlignmentAndOffset() { + InlineShapeRun run = (InlineShapeRun) RichText.empty() + .shape(new ShapeOutline.Rectangle(10, 6), RED, null, InlineImageAlignment.BASELINE, 1.5, null) + .runs().get(0); + assertThat(run.outline()).isInstanceOf(ShapeOutline.Rectangle.class); + assertThat(run.alignment()).isEqualTo(InlineImageAlignment.BASELINE); + assertThat(run.baselineOffset()).isEqualTo(1.5, within(EPS)); + } + + @Test + void paragraphBuilderDotAppendsShapeRunAfterText() { + ParagraphNode paragraph = new ParagraphBuilder() + .name("Rating") + .inlineText("Java ") + .dot(5.0, ACCENT) + .build(); + + assertThat(paragraph.inlineRuns()).hasSize(2); + assertThat(paragraph.inlineRuns().get(0)).isInstanceOf(InlineTextRun.class); + assertThat(paragraph.inlineRuns().get(1)).isInstanceOf(InlineShapeRun.class); + } + + @Test + void arrowAndChevronFactoriesProduceDirectionalPolygonRuns() { + InlineShapeRun arrow = (InlineShapeRun) RichText.empty() + .arrow(8, ShapeOutline.Direction.RIGHT, ACCENT).runs().get(0); + InlineShapeRun chevron = (InlineShapeRun) RichText.empty() + .chevron(8, ShapeOutline.Direction.LEFT, ACCENT).runs().get(0); + + assertThat(arrow.outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(chevron.outline()).isInstanceOf(ShapeOutline.Polygon.class); + } } diff --git a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java new file mode 100644 index 00000000..ab196d3c --- /dev/null +++ b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java @@ -0,0 +1,87 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.ShapeOutline; +import org.junit.jupiter.api.Test; + +import java.awt.Color; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +class InlineShapeRunTest { + + private static final double EPS = 1e-6; + private static final DocumentColor FILL = DocumentColor.of(new Color(40, 90, 180)); + private static final DocumentStroke STROKE = DocumentStroke.of(DocumentColor.BLACK, 0.5); + + @Test + void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() { + InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(6.0), FILL); + + assertThat(run.outline()).isEqualTo(ShapeOutline.circle(6.0)); + assertThat(run.outline().width()).isEqualTo(6.0, within(EPS)); + assertThat(run.fill()).isSameAs(FILL); + assertThat(run.stroke()).isNull(); + assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER); + assertThat(run.baselineOffset()).isEqualTo(0.0, within(EPS)); + assertThat(run.linkOptions()).isNull(); + } + + @Test + void carriesAnyOutlineKind() { + assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).outline()) + .isInstanceOf(ShapeOutline.Polygon.class); + assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).outline()) + .isInstanceOf(ShapeOutline.Polygon.class); + assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).outline()) + .isInstanceOf(ShapeOutline.Rectangle.class); + } + + @Test + void outlinedOnlyShapeIsAllowed() { + InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(8), null, STROKE, null, 0.0, null); + + assertThat(run.fill()).isNull(); + assertThat(run.stroke()).isSameAs(STROKE); + assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER); + } + + @Test + void nullAlignmentNormalizesToCenter() { + InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(5), FILL, null, null, 0.0, null); + + assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER); + } + + @Test + void invisibleShapeWithoutFillOrStrokeIsRejected() { + assertThatThrownBy(() -> + new InlineShapeRun(ShapeOutline.circle(6), null, null, InlineImageAlignment.CENTER, 0.0, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fill"); + } + + @Test + void nullOutlineIsRejected() { + assertThatThrownBy(() -> new InlineShapeRun(null, FILL)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void nonFiniteBaselineOffsetIsRejected() { + assertThatThrownBy(() -> + new InlineShapeRun(ShapeOutline.circle(6), FILL, null, InlineImageAlignment.CENTER, + Double.POSITIVE_INFINITY, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("baselineOffset"); + } + + @Test + void filledConvenienceConstructorRejectsNullFill() { + assertThatThrownBy(() -> new InlineShapeRun(ShapeOutline.circle(6), null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java new file mode 100644 index 00000000..fdc3662c --- /dev/null +++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java @@ -0,0 +1,149 @@ +package com.demcha.compose.document.style; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +class ShapeOutlineTest { + + private static final double EPS = 1e-6; + + @Test + void circleIsAnEqualSidedEllipse() { + ShapeOutline.Ellipse circle = ShapeOutline.circle(10); + assertThat(circle.width()).isEqualTo(10.0, within(EPS)); + assertThat(circle.height()).isEqualTo(10.0, within(EPS)); + } + + @Test + void diamondHasFourVerticesAndKeepsSize() { + ShapeOutline.Polygon diamond = ShapeOutline.diamond(12, 8); + assertThat(diamond.width()).isEqualTo(12.0, within(EPS)); + assertThat(diamond.height()).isEqualTo(8.0, within(EPS)); + assertThat(diamond.points()).hasSize(4); + } + + @Test + void triangleHasThreeVertices() { + assertThat(ShapeOutline.triangle(10, 10).points()).hasSize(3); + } + + @Test + void starHasTwiceThePointCountVertices() { + assertThat(ShapeOutline.star(10, 10).points()).hasSize(10); + assertThat(ShapeOutline.star(10, 10, 6).points()).hasSize(12); + } + + @Test + void starVerticesStayWithinUnitBox() { + for (ShapePoint point : ShapeOutline.star(10, 10, 7).points()) { + assertThat(point.x()).isBetween(0.0, 1.0); + assertThat(point.y()).isBetween(0.0, 1.0); + } + } + + @Test + void polygonRejectsFewerThanThreePoints() { + assertThatThrownBy(() -> ShapeOutline.polygon(10, 10, + List.of(new ShapePoint(0, 0), new ShapePoint(1, 1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 3"); + } + + @Test + void polygonCopiesItsVertexRingDefensively() { + List mutable = new ArrayList<>(List.of( + new ShapePoint(0, 0), new ShapePoint(1, 0), new ShapePoint(0.5, 1))); + ShapeOutline.Polygon polygon = ShapeOutline.polygon(10, 10, mutable); + mutable.clear(); + assertThat(polygon.points()).hasSize(3); + } + + @Test + void starRejectsFewerThanThreePoints() { + assertThatThrownBy(() -> ShapeOutline.star(10, 10, 2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 3"); + } + + @Test + void arrowAndChevronRejectNullDirection() { + assertThatThrownBy(() -> ShapeOutline.arrow(10, 10, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> ShapeOutline.chevron(10, 10, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void rectangleRejectsNonPositiveDimensions() { + assertThatThrownBy(() -> new ShapeOutline.Rectangle(0, 5)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void shapePointRejectsOutOfRangeOrNonFiniteCoordinates() { + assertThatThrownBy(() -> new ShapePoint(1.5, 0.5)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ShapePoint(-0.1, 0.5)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ShapePoint(0.5, Double.NaN)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void arrowHasSevenVerticesAndDirectionalTip() { + assertThat(ShapeOutline.arrowRight(10, 10).points()).hasSize(7); + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT).points()) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(1.0, within(EPS)); + assertThat(p.y()).isEqualTo(0.5, within(EPS)); + }); + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.LEFT).points()) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(0.0, within(EPS)); + assertThat(p.y()).isEqualTo(0.5, within(EPS)); + }); + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.UP).points()) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(0.5, within(EPS)); + assertThat(p.y()).isEqualTo(1.0, within(EPS)); + }); + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.DOWN).points()) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(0.5, within(EPS)); + assertThat(p.y()).isEqualTo(0.0, within(EPS)); + }); + } + + @Test + void chevronCheckmarkAndPlusHaveExpectedVertexCounts() { + assertThat(ShapeOutline.chevron(10, 10, ShapeOutline.Direction.RIGHT).points()).hasSize(6); + assertThat(ShapeOutline.checkmark(10, 10).points()).hasSize(6); + assertThat(ShapeOutline.plus(10, 10).points()).hasSize(12); + } + + @Test + void regularPolygonHasRequestedSidesAndRejectsTooFew() { + assertThat(ShapeOutline.regularPolygon(10, 10, 6).points()).hasSize(6); + assertThatThrownBy(() -> ShapeOutline.regularPolygon(10, 10, 2)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void everyPolygonFactoryStaysWithinUnitBox() { + List shapes = List.of( + ShapeOutline.arrow(10, 10, ShapeOutline.Direction.DOWN), + ShapeOutline.chevron(10, 10, ShapeOutline.Direction.UP), + ShapeOutline.checkmark(10, 10), + ShapeOutline.plus(10, 10), + ShapeOutline.regularPolygon(10, 10, 7)); + for (ShapeOutline.Polygon shape : shapes) { + for (ShapePoint point : shape.points()) { + assertThat(point.x()).isBetween(0.0, 1.0); + assertThat(point.y()).isBetween(0.0, 1.0); + } + } + } +} From a34e6cf17b9241b5f90245339e4aeaeff1a305a6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Thu, 4 Jun 2026 14:30:36 +0100 Subject: [PATCH 2/2] feat(api): inline checkboxes + swappable checkmark/arrow designs (@since 1.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InlineShapeRun is now a ShapeLayer stack; checkbox(size, checked, ...) draws a rounded frame plus, when checked, a centred tick — checked/unchecked todo markers. - ShapeOutline adds CheckmarkStyle {CLASSIC, HEAVY} and ArrowStyle {BLOCK, TRIANGLE} with checkmark(w,h,style) / arrow(w,h,dir,style) overloads; no-style factories delegate to the defaults, so the look is unchanged. checkbox also takes a raw mark. - RichText / ParagraphBuilder gain matching overloads; dense band geometry lives in package-private ShapeRings. - Example checklist + variants; README, committed preview and CHANGELOG updated. --- CHANGELOG.md | 21 +++ assets/readme/examples/inline-shapes.pdf | Bin 3542 -> 4584 bytes examples/README.md | 11 +- .../features/text/InlineShapesExample.java | 46 +++-- .../PdfParagraphFragmentRenderHandler.java | 39 ++-- .../document/dsl/ParagraphBuilder.java | 95 ++++++++++ .../demcha/compose/document/dsl/RichText.java | 93 +++++++++ .../document/layout/TextFlowSupport.java | 41 ++-- .../layout/payloads/ParagraphShapeSpan.java | 42 ++--- .../layout/payloads/ResolvedShapeLayer.java | 18 ++ .../compose/document/node/InlineShapeRun.java | 177 +++++++++++++++--- .../compose/document/node/ShapeLayer.java | 46 +++++ .../compose/document/style/ShapeOutline.java | 86 ++++++++- .../compose/document/style/ShapeRings.java | 62 ++++++ .../document/dsl/InlineShapeRenderTest.java | 69 +++++++ .../compose/document/dsl/RichTextTest.java | 55 ++++-- .../document/node/InlineShapeRunTest.java | 95 +++++++++- .../document/style/ShapeOutlineTest.java | 49 +++++ .../document/style/ShapeRingsTest.java | 28 +++ 19 files changed, 932 insertions(+), 141 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java create mode 100644 src/main/java/com/demcha/compose/document/node/ShapeLayer.java create mode 100644 src/main/java/com/demcha/compose/document/style/ShapeRings.java create mode 100644 src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a9195c..c0254a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,27 @@ API turns the open cycle into a minor. (every other kind is reachable through `shape(ShapeOutline, ...)`); `ShapeContainerBuilder` exposes matching block outlines. Rectangle, rounded-rectangle and ellipse shape containers are unchanged. +- **Inline checkboxes + composite (multi-layer) inline figures.** An inline + shape run is now a stack of paint layers + (`com.demcha.compose.document.node.ShapeLayer`, `@since 1.7.0`) drawn overlaid + and centred, so a figure can compose several outlines — each with its own + fill/stroke — and still measure and place as one unit on the baseline. + `ParagraphBuilder` / `RichText` gain `checkbox(size, checked, color)` / + `checkbox(size, checked, boxColor, checkColor)` (`@since 1.7.0`): a rounded + frame plus, in the checked state, a centred tick — the todo / checklist marker + for "some items done, some not". The single-outline `InlineShapeRun` + convenience constructors are unchanged; every other kind still renders as one + layer. +- **Swappable tick and arrow designs (the "pick your figure" seam).** + `ShapeOutline` adds `CheckmarkStyle` (`CLASSIC`, `HEAVY`) and `ArrowStyle` + (`BLOCK`, `TRIANGLE`) enums plus the overloads + `checkmark(w, h, CheckmarkStyle)` and `arrow(w, h, Direction, ArrowStyle)` + (`@since 1.7.0`); the no-style factories delegate to `CLASSIC` / `BLOCK`, so + the default look is unchanged. `checkbox(...)`, `RichText.arrow(...)` and + `ParagraphBuilder.arrow(...)` gain matching style overloads, and `checkbox` + also accepts a raw `ShapeOutline` mark for fully custom ticks. Adding a new + design is one enum constant plus its vertex ring — the foundation for letting + a caller choose which tick or arrow to render. ## v1.6.9 — 2026-06-03 diff --git a/assets/readme/examples/inline-shapes.pdf b/assets/readme/examples/inline-shapes.pdf index d457e6587e9d0592d123343344b7013b69ec9afd..4b2acbf52bf742bd09d6eb2b24b501d797a65830 100644 GIT binary patch delta 3970 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 diff --git a/examples/README.md b/examples/README.md index cb877cd3..cf6ca5fe 100644 --- a/examples/README.md +++ b/examples/README.md @@ -60,7 +60,7 @@ are with the canonical DSL, then jump to its detailed section below. | Example | What it shows | Preview · Source | |---|---|---| | [Rich text](#rich-text) | Every `RichText` method (bold / italic / underline / link / colour / accent / size / append) | [PDF](../assets/readme/examples/rich-text-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java) | -| [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) | +| [Inline shapes](#inline-shapes) | `InlineShapeRun` — dots, arrows, chevrons, diamonds, stars, checkmarks and checkboxes drawn as geometry on the text baseline | [PDF](../assets/readme/examples/inline-shapes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineShapesExample.java) | | [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) | | [Nested lists](#nested-lists-v16) | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) | | [Composed table cells](#composed-table-cells-v16) | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) | @@ -414,8 +414,10 @@ visual reference when picking which call to make for inline text. `InlineShapeRun` (`@since 1.7.0`) draws geometric figures on the text baseline from geometry — no font glyph needed — so rating dots, arrows, -chevrons, diamonds, stars, checkmarks and any other `ShapeOutline` work -between text and as list bullets, at any size and colour. +chevrons, diamonds, stars, checkmarks, checkboxes (checked / unchecked +todo markers) and any other `ShapeOutline` work between text and as list +bullets, at any size and colour. The tick and arrow designs are swappable +via `CheckmarkStyle` / `ArrowStyle`. ```java .addRich(rich -> rich @@ -425,7 +427,8 @@ between text and as list bullets, at any size and colour. .arrow(8, ShapeOutline.Direction.RIGHT, accent) .plain(" Published")) // also: dot(size, fill), diamond, triangle, star, chevron, -// or shape(ShapeOutline.checkmark(size, size), fill) for any figure +// checkbox(size, checked, color) for todo markers, and +// arrow(size, dir, ArrowStyle.TRIANGLE, fill) to pick a design variant ``` [📄 View PDF](../assets/readme/examples/inline-shapes.pdf) · diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java index a0b383f0..9b9b9e43 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineShapesExample.java @@ -21,10 +21,12 @@ * Runnable showcase for inline shape runs ({@code @since 1.7.0}). * *

Geometric figures — rating dots, arrows, chevrons, diamonds, stars, - * checkmarks, plus signs, regular polygons — drawn on the text baseline from - * geometry (no font glyphs), used between text and as list bullets. Each row - * pairs the rendered output with the {@code ParagraphBuilder} / {@code RichText} - * call that produced it, so the PDF reads like a quick reference.

+ * checkmarks, plus signs, regular polygons and checkboxes (checked / unchecked + * todo markers) — drawn on the text baseline from geometry (no font glyphs), + * used between text and as list bullets. The closing rows show the swappable + * {@code CheckmarkStyle} / {@code ArrowStyle} designs. Each row pairs the + * rendered output with the {@code ParagraphBuilder} / {@code RichText} call that + * produced it, so the PDF reads like a quick reference.

*/ public final class InlineShapesExample { private static final BusinessTheme THEME = BusinessTheme.modern(); @@ -88,15 +90,17 @@ public static Path generate() throws Exception { .softPanel(PANEL, 6, 12) .spacing(5) .addParagraph(p -> p - .text("shape(ShapeOutline.checkmark(...)/plus(...), fill) — checklist markers") + .text("checkbox(size, checked, color) — todo markers, checked and unchecked") .textStyle(caption()) .margin(DocumentInsets.zero())) - .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN) - .plain(" Figures render from geometry")) - .addRich(rich -> rich.shape(ShapeOutline.checkmark(9, 9), GREEN) - .plain(" They reuse the ShapeOutline taxonomy")) - .addRich(rich -> rich.shape(ShapeOutline.plus(9, 9), ACCENT) - .plain(" A new figure is one factory away"))) + .addRich(rich -> rich.checkbox(10, true, GREEN) + .plain(" A checked box stamps a filled tick inside the frame")) + .addRich(rich -> rich.checkbox(10, true, GREEN) + .plain(" Both states share the same geometry pipeline")) + .addRich(rich -> rich.checkbox(10, false, MUTED) + .plain(" An empty box is the unchecked state")) + .addRich(rich -> rich.checkbox(10, false, MUTED) + .plain(" No font glyph, so it renders anywhere"))) .addSection("Bullets", section -> labelledRow(section, "any ShapeOutline as a list bullet", rich -> rich @@ -105,6 +109,26 @@ public static Path generate() throws Exception { .triangle(7, BRAND).plain(" Triangle ") .arrow(8, ShapeOutline.Direction.RIGHT, BRAND).plain(" Arrow ") .shape(ShapeOutline.regularPolygon(8, 8, 6), MUTED).plain(" Hexagon"))) + .addSection("Variants", section -> section + .softPanel(PANEL, 6, 12) + .spacing(5) + .addParagraph(p -> p + .text("checkmark(w, h, CheckmarkStyle) · arrow(w, h, Direction, ArrowStyle) — swap the design") + .textStyle(caption()) + .margin(DocumentInsets.zero())) + .addRich(rich -> rich + .plain("Tick ") + .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.CLASSIC), GREEN) + .plain(" classic ") + .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.HEAVY), GREEN) + .plain(" heavy Arrow ") + .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.BLOCK, ACCENT) + .plain(" block ") + .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT) + .plain(" triangle")) + .addRich(rich -> rich + .checkbox(11, true, ShapeOutline.CheckmarkStyle.HEAVY, BRAND, BRAND) + .plain(" A checkbox takes any tick variant — here HEAVY"))) .addSection("Footer", section -> section .accentTop(THEME.palette().rule(), 0.6) .padding(new DocumentInsets(8, 0, 0, 0)) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java index f57c52b7..5c46d4e9 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan; +import com.demcha.compose.document.layout.payloads.ResolvedShapeLayer; import com.demcha.compose.document.layout.payloads.ParagraphImageSpan; import com.demcha.compose.document.layout.payloads.ParagraphLine; import com.demcha.compose.document.layout.payloads.ParagraphSpan; @@ -206,23 +207,27 @@ private static void renderShape(PDPageContentStream stream, textAscent, baselineOffsetFromBottom, lineHeight); - float x = (float) cursorX; - float y = (float) bottom; - float w = (float) width; - float h = (float) height; - ShapeOutline outline = span.outline(); - PdfShapeGeometry.fillAndStrokePath(stream, span.fillColor(), span.stroke(), s -> { - if (outline instanceof ShapeOutline.Ellipse) { - PdfEllipseFragmentRenderHandler.drawEllipse(s, x, y, w, h); - } else if (outline instanceof ShapeOutline.Rectangle) { - s.addRect(x, y, w, h); - } else if (outline instanceof ShapeOutline.RoundedRectangle r) { - float radius = (float) Math.min(r.cornerRadius(), Math.min(w, h) / 2.0f); - PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, x, y, w, h, radius, radius, radius, radius); - } else if (outline instanceof ShapeOutline.Polygon p) { - PdfShapeGeometry.addPolygonPath(s, x, y, w, h, p.points()); - } - }); + for (ResolvedShapeLayer layer : span.layers()) { + ShapeOutline outline = layer.outline(); + float lw = (float) outline.width(); + float lh = (float) outline.height(); + // Each layer is centred within the run's bounding box, so a smaller + // checkmark sits inside its larger checkbox frame. + float lx = (float) (cursorX + (width - outline.width()) / 2.0); + float ly = (float) (bottom + (height - outline.height()) / 2.0); + PdfShapeGeometry.fillAndStrokePath(stream, layer.fillColor(), layer.stroke(), s -> { + if (outline instanceof ShapeOutline.Ellipse) { + PdfEllipseFragmentRenderHandler.drawEllipse(s, lx, ly, lw, lh); + } else if (outline instanceof ShapeOutline.Rectangle) { + s.addRect(lx, ly, lw, lh); + } else if (outline instanceof ShapeOutline.RoundedRectangle r) { + float radius = (float) Math.min(r.cornerRadius(), Math.min(lw, lh) / 2.0f); + PdfShapeFragmentRenderHandler.drawRoundedRectangle(s, lx, ly, lw, lh, radius, radius, radius, radius); + } else if (outline instanceof ShapeOutline.Polygon p) { + PdfShapeGeometry.addPolygonPath(s, lx, ly, lw, lh, p.points()); + } + }); + } } } diff --git a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java index 4148f474..d4b3d3eb 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ParagraphBuilder.java @@ -330,6 +330,25 @@ public ParagraphBuilder arrow(double size, ShapeOutline.Direction direction, Doc return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); } + /** + * Adds an inline arrow of the given {@link ShapeOutline.ArrowStyle} — the + * swappable-design overload (block arrow, triangular arrowhead, …). + * + * @param size figure width and height in points + * @param direction the way the arrow points + * @param style the arrow design + * @param fill fill color + * @return this builder + * @since 1.7.0 + */ + public ParagraphBuilder arrow(double size, + ShapeOutline.Direction direction, + ShapeOutline.ArrowStyle style, + DocumentColor fill) { + return shape(ShapeOutline.arrow(size, size, direction, style), fill, null, + InlineImageAlignment.CENTER, 0.0, null); + } + /** * Adds an inline chevron sized {@code size × size} pointing in * {@code direction} — a lighter directional separator for step lists. @@ -388,6 +407,82 @@ public ParagraphBuilder shape(ShapeOutline outline, return this; } + /** + * Adds an inline checkbox — a rounded square frame with an optional centred + * checkmark inside (the checked state), each in its own colour — for todo / + * checklist markers between text. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return this builder + */ + public ParagraphBuilder checkbox(double size, boolean checked, DocumentColor boxColor, DocumentColor checkColor) { + this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, boxColor, checkColor)); + this.text = ""; + return this; + } + + /** + * Adds an inline checkbox using one colour for both the frame and the + * checkmark. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param color frame and checkmark color + * @return this builder + */ + public ParagraphBuilder checkbox(double size, boolean checked, DocumentColor color) { + return checkbox(size, checked, color, color); + } + + /** + * Adds an inline checkbox whose checked-state tick uses the given + * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param markStyle design of the checked-state tick + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return this builder + * @since 1.7.0 + */ + public ParagraphBuilder checkbox(double size, + boolean checked, + ShapeOutline.CheckmarkStyle markStyle, + DocumentColor boxColor, + DocumentColor checkColor) { + this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, markStyle, boxColor, checkColor)); + this.text = ""; + return this; + } + + /** + * Adds an inline checkbox whose checked-state mark is an arbitrary + * {@link ShapeOutline} — the power-user overload. Size the mark to fit the + * frame (≈ {@code 0.6 × size}); it is drawn centred in the box. + * + * @param size box width and height in points + * @param checked whether the mark is shown + * @param mark checked-state mark geometry, already sized; must be non-null + * when {@code checked} is {@code true} + * @param boxColor frame stroke color + * @param checkColor mark fill color + * @return this builder + * @since 1.7.0 + */ + public ParagraphBuilder checkbox(double size, + boolean checked, + ShapeOutline mark, + DocumentColor boxColor, + DocumentColor checkColor) { + this.inlineRuns.add(InlineShapeRun.checkbox(size, checked, mark, boxColor, checkColor)); + this.text = ""; + return this; + } + /** * Replaces inline runs with the contents of a {@link RichText} builder. * diff --git a/src/main/java/com/demcha/compose/document/dsl/RichText.java b/src/main/java/com/demcha/compose/document/dsl/RichText.java index d24cd485..5f9cc19c 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RichText.java +++ b/src/main/java/com/demcha/compose/document/dsl/RichText.java @@ -398,6 +398,26 @@ public RichText arrow(double size, ShapeOutline.Direction direction, DocumentCol return shape(ShapeOutline.arrow(size, size, direction), fill, null, InlineImageAlignment.CENTER, 0.0, null); } + /** + * Appends an inline arrow of the given {@link ShapeOutline.ArrowStyle} — the + * swappable-design overload, so a caller (or a future "pick your arrow" UI) + * can choose a block arrow, a triangular arrowhead, etc. + * + * @param size figure width and height in points + * @param direction the way the arrow points + * @param style the arrow design + * @param fill fill color + * @return this builder + * @since 1.7.0 + */ + public RichText arrow(double size, + ShapeOutline.Direction direction, + ShapeOutline.ArrowStyle style, + DocumentColor fill) { + return shape(ShapeOutline.arrow(size, size, direction, style), fill, null, + InlineImageAlignment.CENTER, 0.0, null); + } + /** * Appends an inline chevron sized {@code size × size} pointing in * {@code direction} — a lighter directional separator for step lists. @@ -452,6 +472,79 @@ public RichText shape(ShapeOutline outline, return this; } + /** + * Appends an inline checkbox — a rounded square frame with an optional + * centred checkmark inside (the checked state), each in its own colour — + * for todo / checklist markers between text. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return this builder + */ + public RichText checkbox(double size, boolean checked, DocumentColor boxColor, DocumentColor checkColor) { + runs.add(InlineShapeRun.checkbox(size, checked, boxColor, checkColor)); + return this; + } + + /** + * Appends an inline checkbox using one colour for both the frame and the + * checkmark. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param color frame and checkmark color + * @return this builder + */ + public RichText checkbox(double size, boolean checked, DocumentColor color) { + return checkbox(size, checked, color, color); + } + + /** + * Appends an inline checkbox whose checked-state tick uses the given + * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param markStyle design of the checked-state tick + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return this builder + * @since 1.7.0 + */ + public RichText checkbox(double size, + boolean checked, + ShapeOutline.CheckmarkStyle markStyle, + DocumentColor boxColor, + DocumentColor checkColor) { + runs.add(InlineShapeRun.checkbox(size, checked, markStyle, boxColor, checkColor)); + return this; + } + + /** + * Appends an inline checkbox whose checked-state mark is an arbitrary + * {@link ShapeOutline} — the power-user overload. Size the mark to fit the + * frame (≈ {@code 0.6 × size}); it is drawn centred in the box. + * + * @param size box width and height in points + * @param checked whether the mark is shown + * @param mark checked-state mark geometry, already sized; must be non-null + * when {@code checked} is {@code true} + * @param boxColor frame stroke color + * @param checkColor mark fill color + * @return this builder + * @since 1.7.0 + */ + public RichText checkbox(double size, + boolean checked, + ShapeOutline mark, + DocumentColor boxColor, + DocumentColor checkColor) { + runs.add(InlineShapeRun.checkbox(size, checked, mark, boxColor, checkColor)); + return this; + } + /** * Returns the accumulated runs as an immutable list. * diff --git a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java index 663a5652..1e4a78e1 100644 --- a/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/TextFlowSupport.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.layout; import com.demcha.compose.document.layout.payloads.ParagraphShapeSpan; +import com.demcha.compose.document.layout.payloads.ResolvedShapeLayer; import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.ParagraphImageSpan; import com.demcha.compose.document.layout.payloads.ParagraphLine; @@ -11,6 +12,7 @@ import com.demcha.compose.document.layout.payloads.PreparedParagraphLayout; import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.node.InlineShapeRun; +import com.demcha.compose.document.node.ShapeLayer; import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.InlineImageRun; import com.demcha.compose.document.node.InlineRun; @@ -24,9 +26,7 @@ import com.demcha.compose.document.style.DocumentTextAutoSize; import com.demcha.compose.document.style.DocumentTextIndent; import com.demcha.compose.document.style.DocumentTextStyle; -import com.demcha.compose.document.style.ShapeOutline; import com.demcha.compose.engine.components.content.ImageData; -import com.demcha.compose.engine.components.content.shape.Stroke; import com.demcha.compose.engine.components.content.text.TextDataBody; import com.demcha.compose.engine.components.content.text.TextIndentStrategy; import com.demcha.compose.engine.components.content.text.TextStyle; @@ -35,7 +35,6 @@ import com.demcha.compose.engine.measurement.TextMeasurementSystem; import com.demcha.compose.engine.text.markdown.MarkDownParser; -import java.awt.Color; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -608,7 +607,7 @@ private static boolean paragraphFitsSingleLine(ParagraphNode node, } else if (run instanceof InlineImageRun imageRun) { width += imageRun.width(); } else if (run instanceof InlineShapeRun shapeRun) { - width += shapeRun.outline().width(); + width += shapeRun.width(); } } return width <= innerWidth; @@ -1338,9 +1337,9 @@ private static ParagraphLine toInlineParagraphLine(List token width += imageToken.width(); } else if (token instanceof InlineShapeToken shapeToken) { spans.add(new ParagraphShapeSpan( - shapeToken.outline(), - shapeToken.fillColor(), - shapeToken.stroke(), + shapeToken.layers(), + shapeToken.width(), + shapeToken.height(), shapeToken.alignment(), shapeToken.baselineOffset(), shapeToken.linkOptions())); @@ -1611,9 +1610,9 @@ private static InlineImageToken of(InlineImageRun run) { } private record InlineShapeToken( - ShapeOutline outline, - Color fillColor, - Stroke stroke, + List layers, + double width, + double height, InlineImageAlignment alignment, double baselineOffset, DocumentLinkOptions linkOptions @@ -1622,20 +1621,18 @@ private record InlineShapeToken( alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; } - @Override - public double width() { - return outline.width(); - } - - private double height() { - return outline.height(); - } - private static InlineShapeToken of(InlineShapeRun run) { + List resolved = new ArrayList<>(run.layers().size()); + for (ShapeLayer layer : run.layers()) { + resolved.add(new ResolvedShapeLayer( + layer.outline(), + layer.fill() == null ? null : layer.fill().color(), + toStroke(layer.stroke()))); + } return new InlineShapeToken( - run.outline(), - run.fill() == null ? null : run.fill().color(), - toStroke(run.stroke()), + List.copyOf(resolved), + run.width(), + run.height(), run.alignment(), run.baselineOffset(), run.linkOptions()); diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java index b9fa70ba..94c4b420 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphShapeSpan.java @@ -2,51 +2,35 @@ import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.node.InlineImageAlignment; -import com.demcha.compose.document.style.ShapeOutline; -import com.demcha.compose.engine.components.content.shape.Stroke; -import java.awt.Color; -import java.util.Objects; +import java.util.List; /** - * Measured inline shape span inside a paragraph line. + * Measured inline shape span inside a paragraph line — a stack of resolved + * {@link ResolvedShapeLayer}s drawn overlaid and centred within the span's + * bounding box, so composite figures (e.g. a checkbox: box + checkmark) place + * on the text baseline as one unit. * - *

The semantic {@code InlineShapeRun} is resolved into this payload during - * wrapping: the DSL fill color becomes an AWT {@link Color} and the DSL stroke - * becomes an engine {@link Stroke}, while the {@link ShapeOutline} carries the - * figure geometry (and its intrinsic {@link #width()} / {@link #height()}). The - * PDF backend dispatches on the outline kind to paint the figure.

- * - * @param outline figure geometry; supplies the span width and height - * @param fillColor optional resolved fill color - * @param stroke optional resolved outline stroke + * @param layers resolved paint layers, back-to-front + * @param width bounding width in points + * @param height bounding height in points * @param alignment vertical alignment relative to the surrounding text * @param baselineOffset extra vertical offset in points; positive moves up * @param linkOptions optional link metadata */ public record ParagraphShapeSpan( - ShapeOutline outline, - Color fillColor, - Stroke stroke, + List layers, + double width, + double height, InlineImageAlignment alignment, double baselineOffset, DocumentLinkOptions linkOptions ) implements ParagraphSpan { /** - * Validates the outline and normalizes alignment defaults. + * Copies the layer stack defensively and normalizes alignment defaults. */ public ParagraphShapeSpan { - Objects.requireNonNull(outline, "outline"); + layers = List.copyOf(layers); alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; } - - @Override - public double width() { - return outline.width(); - } - - @Override - public double height() { - return outline.height(); - } } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java b/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java new file mode 100644 index 00000000..9142cd91 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ResolvedShapeLayer.java @@ -0,0 +1,18 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.engine.components.content.shape.Stroke; + +import java.awt.Color; + +/** + * One resolved paint layer of a {@link ParagraphShapeSpan}: an outline figure + * whose fill colour and stroke are already resolved to AWT / engine primitives, + * ready for the PDF backend. + * + * @param outline figure geometry + * @param fillColor optional resolved fill color + * @param stroke optional resolved outline stroke + */ +public record ResolvedShapeLayer(ShapeOutline outline, Color fillColor, Stroke stroke) { +} diff --git a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java index 2ff8591d..a2b0a607 100644 --- a/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java +++ b/src/main/java/com/demcha/compose/document/node/InlineShapeRun.java @@ -4,33 +4,30 @@ import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.ShapeOutline; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** - * One inline shape run inside a {@link ParagraphNode} — a geometric figure - * (circle / ellipse, rectangle, rounded rectangle, diamond, triangle, star, or - * any {@link ShapeOutline}) measured and rendered on the surrounding text - * baseline. + * One inline shape run inside a {@link ParagraphNode} — a stack of geometric + * {@link ShapeLayer}s (circle / ellipse, rectangle, rounded rectangle, diamond, + * triangle, star, arrow, chevron, checkmark, plus, polygon, …) measured and + * rendered on the surrounding text baseline. * *

Inline shapes are measured as part of paragraph wrapping exactly like - * {@link InlineImageRun}: the outline's width and height contribute to span - * placement, line breaking and per-line height, and the figure shares the text - * baseline. The shape is drawn directly from geometry — no raster payload and - * no font glyph — so skill rating dots ({@code Java ●●●●○}), custom bullets and - * inline status markers render regardless of font coverage. The kind is the - * existing {@code ShapeOutline} taxonomy, so any new outline kind is usable - * inline automatically.

+ * {@link InlineImageRun}: the run's bounding {@link #width()} / {@link #height()} + * (the widest / tallest layer) contribute to span placement, line breaking and + * per-line height, and the figure shares the text baseline. Each layer is drawn + * directly from geometry — no raster payload and no font glyph — so skill rating + * dots, custom bullets, arrows between text and checkboxes render regardless of + * font coverage.

* - *

At least one of {@code fill} or {@code stroke} must be present, otherwise - * the run would be invisible: a filled figure uses {@code fill} with a - * {@code null} stroke; an outlined figure uses a {@code null} fill with a - * {@code stroke}; the two combined paint a filled-and-outlined figure.

+ *

Most figures are a single layer (a dot, an arrow). Composite figures stack + * layers overlaid and centred: a checkbox is a box layer plus an optional + * checkmark layer, each with its own colour.

* - * @param outline shape geometry; its {@link ShapeOutline#width()} and - * {@link ShapeOutline#height()} are the run's measured size - * @param fill optional fill color; {@code null} leaves the interior empty - * @param stroke optional outline stroke; {@code null} leaves the figure - * without a border + * @param layers one or more paint layers, drawn back-to-front and centred in the + * run's bounding box * @param alignment vertical alignment relative to the surrounding text; * defaults to {@link InlineImageAlignment#CENTER} * @param baselineOffset extra vertical offset in points applied after @@ -42,36 +39,156 @@ * @since 1.7.0 */ public record InlineShapeRun( - ShapeOutline outline, - DocumentColor fill, - DocumentStroke stroke, + List layers, InlineImageAlignment alignment, double baselineOffset, DocumentLinkOptions linkOptions ) implements InlineRun { /** - * Validates the outline, requires at least one visible paint, and + * Copies the layer stack defensively, requires at least one layer, and * normalizes alignment defaults. */ public InlineShapeRun { - Objects.requireNonNull(outline, "outline"); + Objects.requireNonNull(layers, "layers"); + layers = List.copyOf(layers); + if (layers.isEmpty()) { + throw new IllegalArgumentException("inline shape needs at least one layer"); + } if (Double.isNaN(baselineOffset) || Double.isInfinite(baselineOffset)) { throw new IllegalArgumentException("inline shape baselineOffset must be finite: " + baselineOffset); } - if (fill == null && stroke == null) { - throw new IllegalArgumentException("inline shape must have a fill, a stroke, or both"); - } alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; } /** - * Convenience constructor for a filled shape with default + * Single-layer convenience constructor. + * + * @param outline figure geometry + * @param fill optional fill color + * @param stroke optional outline stroke + * @param alignment vertical alignment relative to surrounding text + * @param baselineOffset extra vertical shift in points; positive moves up + * @param linkOptions optional inline link metadata + */ + public InlineShapeRun(ShapeOutline outline, + DocumentColor fill, + DocumentStroke stroke, + InlineImageAlignment alignment, + double baselineOffset, + DocumentLinkOptions linkOptions) { + this(List.of(new ShapeLayer(outline, fill, stroke)), alignment, baselineOffset, linkOptions); + } + + /** + * Single filled-layer convenience constructor with default * {@link InlineImageAlignment#CENTER} alignment and zero offset. * - * @param outline shape geometry + * @param outline figure geometry * @param fill fill color; must not be {@code null} */ public InlineShapeRun(ShapeOutline outline, DocumentColor fill) { this(outline, Objects.requireNonNull(fill, "fill"), null, InlineImageAlignment.CENTER, 0.0, null); } + + /** + * Returns the bounding width of the run — the widest layer. + * + * @return bounding width in points + */ + public double width() { + double max = 0.0; + for (ShapeLayer layer : layers) { + max = Math.max(max, layer.outline().width()); + } + return max; + } + + /** + * Returns the bounding height of the run — the tallest layer. + * + * @return bounding height in points + */ + public double height() { + double max = 0.0; + for (ShapeLayer layer : layers) { + max = Math.max(max, layer.outline().height()); + } + return max; + } + + /** + * Creates an inline checkbox with the default + * {@link ShapeOutline.CheckmarkStyle#CLASSIC} tick — a rounded square frame + * with an optional centred checkmark inside (the checked state), each in its + * own colour. The frame is stroke-only; the checkmark, when present, is a + * smaller filled figure centred inside the frame. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return checkbox shape run + */ + public static InlineShapeRun checkbox(double size, + boolean checked, + DocumentColor boxColor, + DocumentColor checkColor) { + return checkbox(size, checked, ShapeOutline.CheckmarkStyle.CLASSIC, boxColor, checkColor); + } + + /** + * Creates an inline checkbox whose checked-state tick uses the given + * {@link ShapeOutline.CheckmarkStyle} — the "pick your tick" overload. The + * mark is sized to fit the frame automatically; an unchecked box ignores the + * style and renders the frame alone. + * + * @param size box width and height in points + * @param checked whether the checkmark is shown + * @param markStyle design of the checked-state tick + * @param boxColor frame stroke color + * @param checkColor checkmark fill color + * @return checkbox shape run + * @since 1.7.0 + */ + public static InlineShapeRun checkbox(double size, + boolean checked, + ShapeOutline.CheckmarkStyle markStyle, + DocumentColor boxColor, + DocumentColor checkColor) { + Objects.requireNonNull(markStyle, "markStyle"); + double inner = size * 0.62; + ShapeOutline mark = checked ? ShapeOutline.checkmark(inner, inner, markStyle) : null; + return checkbox(size, checked, mark, boxColor, checkColor); + } + + /** + * Creates an inline checkbox whose checked-state mark is an arbitrary + * {@link ShapeOutline} — the power-user overload for any glyph (a custom + * tick, a dash, a cross, …). The mark is drawn centred in the frame at its + * own size, so size it to fit (≈ {@code 0.6 × size}); an unchecked box + * renders the frame alone and the {@code mark} is ignored. + * + * @param size box width and height in points + * @param checked whether the mark is shown + * @param mark checked-state mark geometry, already sized; must be non-null + * when {@code checked} is {@code true} + * @param boxColor frame stroke color + * @param checkColor mark fill color + * @return checkbox shape run + * @since 1.7.0 + */ + public static InlineShapeRun checkbox(double size, + boolean checked, + ShapeOutline mark, + DocumentColor boxColor, + DocumentColor checkColor) { + DocumentStroke frame = DocumentStroke.of(boxColor, Math.max(0.5, size * 0.09)); + List layers = new ArrayList<>(2); + layers.add(new ShapeLayer(new ShapeOutline.RoundedRectangle(size, size, size * 0.18), null, frame)); + if (checked) { + Objects.requireNonNull(mark, "mark"); + layers.add(new ShapeLayer(mark, checkColor)); + } + return new InlineShapeRun(layers, InlineImageAlignment.CENTER, 0.0, null); + } } diff --git a/src/main/java/com/demcha/compose/document/node/ShapeLayer.java b/src/main/java/com/demcha/compose/document/node/ShapeLayer.java new file mode 100644 index 00000000..54aafafc --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/ShapeLayer.java @@ -0,0 +1,46 @@ +package com.demcha.compose.document.node; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.ShapeOutline; + +import java.util.Objects; + +/** + * One paint layer of an {@link InlineShapeRun}: a {@link ShapeOutline} figure + * with its own fill and/or stroke. + * + *

Layers are drawn overlaid, each centred within the run's bounding box, so + * composite inline figures are expressed as a stack — a checkbox is a box layer + * plus an optional checkmark layer, each with its own colour; a single dot or + * arrow is just one layer.

+ * + * @param outline figure geometry; its {@link ShapeOutline#width()} / + * {@link ShapeOutline#height()} size this layer + * @param fill optional fill color; {@code null} leaves the interior empty + * @param stroke optional outline stroke; {@code null} leaves no border + * + * @author Artem Demchyshyn + * @since 1.7.0 + */ +public record ShapeLayer(ShapeOutline outline, DocumentColor fill, DocumentStroke stroke) { + /** + * Validates the outline and requires at least one visible paint. + */ + public ShapeLayer { + Objects.requireNonNull(outline, "outline"); + if (fill == null && stroke == null) { + throw new IllegalArgumentException("shape layer must have a fill, a stroke, or both"); + } + } + + /** + * Creates a filled layer with no stroke. + * + * @param outline figure geometry + * @param fill fill color; must not be {@code null} + */ + public ShapeLayer(ShapeOutline outline, DocumentColor fill) { + this(outline, Objects.requireNonNull(fill, "fill"), null); + } +} diff --git a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java index 9ab51d4f..ec551978 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java @@ -131,6 +131,37 @@ enum Direction { DOWN } + /** + * Selectable design of a checkmark ("✓") figure — the swappable "tick" + * variant used by {@link #checkmark(double, double, CheckmarkStyle)} and by + * inline checkbox factories. Adding a new look is one enum constant plus its + * vertex ring, so callers (and a future "pick your tick" UI) choose a style + * by name rather than hand-building geometry. + * + * @since 1.7.0 + */ + enum CheckmarkStyle { + /** The default tick: a slim six-vertex checkmark band. */ + CLASSIC, + /** A bolder tick with a visibly thicker band. */ + HEAVY + } + + /** + * Selectable design of an arrow figure — the swappable "arrow" variant used + * by {@link #arrow(double, double, Direction, ArrowStyle)}. Each style is a + * normalized vertex ring pointed right and rotated to {@link Direction} at + * build time, so a new arrow look is one enum constant plus its ring. + * + * @since 1.7.0 + */ + enum ArrowStyle { + /** The default arrow: a seven-vertex block arrow with a shaft. */ + BLOCK, + /** A solid triangular arrowhead ("▶") with no shaft. */ + TRIANGLE + } + /** * Convenience factory for a circular {@link Ellipse}. * @@ -237,10 +268,32 @@ static Polygon star(double width, double height, int points) { * @return arrow polygon outline */ static Polygon arrow(double width, double height, Direction direction) { + return arrow(width, height, direction, ArrowStyle.BLOCK); + } + + /** + * Creates an arrow of the given {@link ArrowStyle} pointing in + * {@code direction} — the swappable-design overload. {@link ArrowStyle#BLOCK} + * reproduces {@link #arrow(double, double, Direction)} exactly. + * + * @param width outer width in points + * @param height outer height in points + * @param direction the way the arrow points + * @param style the arrow design + * @return arrow polygon outline + * @since 1.7.0 + */ + static Polygon arrow(double width, double height, Direction direction, ArrowStyle style) { Objects.requireNonNull(direction, "direction"); - double[][] base = { - {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88}, - {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35} + Objects.requireNonNull(style, "style"); + double[][] base = switch (style) { + case BLOCK -> new double[][] { + {0.00, 0.65}, {0.55, 0.65}, {0.55, 0.88}, + {1.00, 0.50}, {0.55, 0.12}, {0.55, 0.35}, {0.00, 0.35} + }; + case TRIANGLE -> new double[][] { + {0.00, 0.00}, {1.00, 0.50}, {0.00, 1.00} + }; }; return new Polygon(width, height, directional(base, direction)); } @@ -294,11 +347,30 @@ static Polygon chevron(double width, double height, Direction direction) { * @return checkmark polygon outline */ static Polygon checkmark(double width, double height) { - double[][] points = { - {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92}, - {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44} + return checkmark(width, height, CheckmarkStyle.CLASSIC); + } + + /** + * Creates a checkmark ("✓") of the given {@link CheckmarkStyle} — the + * swappable-design overload. {@link CheckmarkStyle#CLASSIC} reproduces + * {@link #checkmark(double, double)} exactly. + * + * @param width outer width in points + * @param height outer height in points + * @param style the checkmark design + * @return checkmark polygon outline + * @since 1.7.0 + */ + static Polygon checkmark(double width, double height, CheckmarkStyle style) { + Objects.requireNonNull(style, "style"); + List ring = switch (style) { + case CLASSIC -> toPoints(new double[][] { + {0.45, 0.00}, {1.00, 0.72}, {0.86, 0.92}, + {0.42, 0.34}, {0.16, 0.58}, {0.04, 0.44} + }); + case HEAVY -> toPoints(ShapeRings.checkmarkBand(0.13)); }; - return new Polygon(width, height, toPoints(points)); + return new Polygon(width, height, ring); } /** diff --git a/src/main/java/com/demcha/compose/document/style/ShapeRings.java b/src/main/java/com/demcha/compose/document/style/ShapeRings.java new file mode 100644 index 00000000..3b5416a5 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/ShapeRings.java @@ -0,0 +1,62 @@ +package com.demcha.compose.document.style; + +/** + * Package-private geometry helpers that compute raw vertex rings for the more + * involved {@link ShapeOutline} figures, keeping the vector math out of the + * public factory surface. + * + *

Coordinates are in the unit box (x right, y up) and may land slightly + * outside {@code [0, 1]}; {@code ShapeOutline} clamps and wraps them into + * {@link ShapePoint}s. Each method here is a candidate home for future design + * variants (a thinner tick, a rounded tick, …).

+ */ +final class ShapeRings { + + private ShapeRings() { + } + + /** + * Builds a constant-width checkmark band of perpendicular half-thickness + * {@code half} around a fixed left-tip → elbow → right-tip centreline, with a + * mitred elbow and flat-cut tips. Larger {@code half} reads as a bolder tick. + * + * @param half perpendicular half-thickness of the band, in unit-box units + * @return six ring vertices in draw order: outer elbow, right tip (outer then + * inner), inner elbow, left tip (inner then outer) + */ + static double[][] checkmarkBand(double half) { + double[] left = {0.12, 0.50}; + double[] elbow = {0.42, 0.18}; + double[] right = {0.92, 0.84}; + double[] toRight = unit(right[0] - elbow[0], right[1] - elbow[1]); + double[] toLeft = unit(left[0] - elbow[0], left[1] - elbow[1]); + double[] normalRight = outwardNormal(toRight); + double[] normalLeft = outwardNormal(toLeft); + double[] bisector = unit(normalRight[0] + normalLeft[0], normalRight[1] + normalLeft[1]); + double miter = half / (bisector[0] * normalRight[0] + bisector[1] * normalRight[1]); + return new double[][] { + {elbow[0] + bisector[0] * miter, elbow[1] + bisector[1] * miter}, // outer elbow + {right[0] + normalRight[0] * half, right[1] + normalRight[1] * half}, // right tip, outer + {right[0] - normalRight[0] * half, right[1] - normalRight[1] * half}, // right tip, inner + {elbow[0] - bisector[0] * miter, elbow[1] - bisector[1] * miter}, // inner elbow + {left[0] - normalLeft[0] * half, left[1] - normalLeft[1] * half}, // left tip, inner + {left[0] + normalLeft[0] * half, left[1] + normalLeft[1] * half} // left tip, outer + }; + } + + /** Returns the unit vector along {@code (x, y)}, or the zero vector for zero length. */ + private static double[] unit(double x, double y) { + double length = Math.hypot(x, y); + return length == 0 ? new double[] {0.0, 0.0} : new double[] {x / length, y / length}; + } + + /** Returns the normal of {@code u} flipped to point toward the bottom (convex) side. */ + private static double[] outwardNormal(double[] u) { + double nx = u[1]; + double ny = -u[0]; + if (ny > 0) { + return new double[] {-nx, -ny}; + } + return new double[] {nx, ny}; + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java index 07c66085..9fd02e1c 100644 --- a/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/InlineShapeRenderTest.java @@ -26,6 +26,7 @@ class InlineShapeRenderTest { private static final DocumentColor ACCENT = DocumentColor.of(new java.awt.Color(40, 90, 180)); + private static final DocumentColor CHECK = DocumentColor.of(new java.awt.Color(34, 130, 92)); @Test void ratingShapesRenderEndToEndKeepingTextWithoutGlyphSubstitution() throws Exception { @@ -106,6 +107,74 @@ void everyOutlineKindRendersWithoutThrowing() throws Exception { } } + @Test + void checkboxRendersCheckedStateWithMoreInkThanUnchecked() throws Exception { + int checked = countColorNear(renderCheckbox(true), 34, 130, 92, 45); + int unchecked = countColorNear(renderCheckbox(false), 34, 130, 92, 45); + + // The empty box paints its frame stroke; the checked box stamps a filled + // tick inside the same frame, so it must add ink — that is exactly the + // "marked vs unmarked" distinction a checklist needs. + assertThat(unchecked).as("the unchecked frame still paints").isGreaterThan(0); + assertThat(checked).as("the checked tick adds ink inside the frame").isGreaterThan(unchecked); + } + + @Test + void checkmarkAndArrowVariantsRenderEndToEnd() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(260, 140) + .margin(14, 14, 14, 14) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(paragraph -> paragraph + .inlineText("Variants ") + .checkbox(12, true, ShapeOutline.CheckmarkStyle.HEAVY, CHECK, CHECK) + .arrow(9, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT) + .shape(ShapeOutline.checkmark(9, 9, ShapeOutline.CheckmarkStyle.HEAVY), CHECK)) + .build(); + assertThat(session.toPdfBytes()).isNotEmpty(); + } + } + + private static byte[] renderCheckbox(boolean checked) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(160, 90) + .margin(14, 14, 14, 14) + .create()) { + session.dsl() + .pageFlow() + .name("Flow") + .addParagraph(paragraph -> paragraph + .inlineText("Task ") + .checkbox(16, checked, CHECK, CHECK)) + .build(); + return session.toPdfBytes(); + } + } + + private static int countColorNear(byte[] pdf, int r, int g, int b, int tolerance) throws Exception { + try (PDDocument document = Loader.loadPDF(pdf)) { + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + int count = 0; + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int rgb = image.getRGB(x, y); + int rr = (rgb >> 16) & 0xFF; + int gg = (rgb >> 8) & 0xFF; + int bb = rgb & 0xFF; + if (Math.abs(rr - r) <= tolerance + && Math.abs(gg - g) <= tolerance + && Math.abs(bb - b) <= tolerance) { + count++; + } + } + } + return count; + } + } + private static byte[] renderRatingRow() throws Exception { try (DocumentSession session = GraphCompose.document() .pageSize(320, 160) diff --git a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java index 1d54c754..258b8a51 100644 --- a/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/RichTextTest.java @@ -221,10 +221,10 @@ void dotProducesFilledCircleShapeRunWithCenterDefault() { List runs = RichText.empty().dot(6.0, RED).runs(); assertThat(runs).hasSize(1); InlineShapeRun dot = (InlineShapeRun) runs.get(0); - assertThat(dot.outline()).isInstanceOf(ShapeOutline.Ellipse.class); - assertThat(dot.outline().width()).isEqualTo(6.0, within(EPS)); - assertThat(dot.fill()).isEqualTo(RED); - assertThat(dot.stroke()).isNull(); + assertThat(dot.layers().get(0).outline()).isInstanceOf(ShapeOutline.Ellipse.class); + assertThat(dot.layers().get(0).outline().width()).isEqualTo(6.0, within(EPS)); + assertThat(dot.layers().get(0).fill()).isEqualTo(RED); + assertThat(dot.layers().get(0).stroke()).isNull(); assertThat(dot.alignment()).isEqualTo(InlineImageAlignment.CENTER); } @@ -241,8 +241,8 @@ void ratingDotsMixWithTextInSourceOrder() { assertThat(runs.get(1)).isInstanceOf(InlineShapeRun.class); InlineShapeRun outlined = (InlineShapeRun) runs.get(3); - assertThat(outlined.fill()).isNull(); - assertThat(outlined.stroke()).isNotNull(); + assertThat(outlined.layers().get(0).fill()).isNull(); + assertThat(outlined.layers().get(0).stroke()).isNotNull(); } @Test @@ -250,9 +250,9 @@ void diamondAndStarFactoriesProducePolygonShapeRuns() { InlineShapeRun diamond = (InlineShapeRun) RichText.empty().diamond(8, ACCENT).runs().get(0); InlineShapeRun star = (InlineShapeRun) RichText.empty().star(8, ACCENT).runs().get(0); - assertThat(diamond.outline()).isInstanceOf(ShapeOutline.Polygon.class); - assertThat(star.outline()).isInstanceOf(ShapeOutline.Polygon.class); - assertThat(((ShapeOutline.Polygon) star.outline()).points()).hasSize(10); + assertThat(diamond.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(star.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(((ShapeOutline.Polygon) star.layers().get(0).outline()).points()).hasSize(10); } @Test @@ -260,7 +260,7 @@ void shapeAcceptsAnyOutlineAlignmentAndOffset() { InlineShapeRun run = (InlineShapeRun) RichText.empty() .shape(new ShapeOutline.Rectangle(10, 6), RED, null, InlineImageAlignment.BASELINE, 1.5, null) .runs().get(0); - assertThat(run.outline()).isInstanceOf(ShapeOutline.Rectangle.class); + assertThat(run.layers().get(0).outline()).isInstanceOf(ShapeOutline.Rectangle.class); assertThat(run.alignment()).isEqualTo(InlineImageAlignment.BASELINE); assertThat(run.baselineOffset()).isEqualTo(1.5, within(EPS)); } @@ -285,7 +285,38 @@ void arrowAndChevronFactoriesProduceDirectionalPolygonRuns() { InlineShapeRun chevron = (InlineShapeRun) RichText.empty() .chevron(8, ShapeOutline.Direction.LEFT, ACCENT).runs().get(0); - assertThat(arrow.outline()).isInstanceOf(ShapeOutline.Polygon.class); - assertThat(chevron.outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(arrow.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(chevron.layers().get(0).outline()).isInstanceOf(ShapeOutline.Polygon.class); + } + + @Test + void checkboxAppendsCheckedAndUncheckedShapeRuns() { + InlineShapeRun checked = (InlineShapeRun) RichText.empty().checkbox(10, true, ACCENT).runs().get(0); + InlineShapeRun unchecked = (InlineShapeRun) RichText.empty().checkbox(10, false, ACCENT).runs().get(0); + + assertThat(checked.layers()).hasSize(2); + assertThat(unchecked.layers()).hasSize(1); + } + + @Test + void checkboxStyleAndRawMarkOverloadsReachInlineShapeRun() { + InlineShapeRun styled = (InlineShapeRun) RichText.empty() + .checkbox(10, true, ShapeOutline.CheckmarkStyle.HEAVY, ACCENT, ACCENT).runs().get(0); + InlineShapeRun raw = (InlineShapeRun) RichText.empty() + .checkbox(10, true, ShapeOutline.plus(6, 6), ACCENT, ACCENT).runs().get(0); + + assertThat(styled.layers()).hasSize(2); + assertThat(raw.layers().get(1).outline()).isInstanceOf(ShapeOutline.Polygon.class); + } + + @Test + void arrowStyleOverloadProducesChosenArrowDesign() { + InlineShapeRun block = (InlineShapeRun) RichText.empty() + .arrow(8, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.BLOCK, ACCENT).runs().get(0); + InlineShapeRun triangle = (InlineShapeRun) RichText.empty() + .arrow(8, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE, ACCENT).runs().get(0); + + assertThat(((ShapeOutline.Polygon) block.layers().get(0).outline()).points()).hasSize(7); + assertThat(((ShapeOutline.Polygon) triangle.layers().get(0).outline()).points()).hasSize(3); } } diff --git a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java index ab196d3c..72a2895d 100644 --- a/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java +++ b/src/test/java/com/demcha/compose/document/node/InlineShapeRunTest.java @@ -20,11 +20,13 @@ class InlineShapeRunTest { @Test void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() { InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(6.0), FILL); + ShapeLayer layer = run.layers().get(0); - assertThat(run.outline()).isEqualTo(ShapeOutline.circle(6.0)); - assertThat(run.outline().width()).isEqualTo(6.0, within(EPS)); - assertThat(run.fill()).isSameAs(FILL); - assertThat(run.stroke()).isNull(); + assertThat(run.layers()).hasSize(1); + assertThat(layer.outline()).isEqualTo(ShapeOutline.circle(6.0)); + assertThat(layer.outline().width()).isEqualTo(6.0, within(EPS)); + assertThat(layer.fill()).isSameAs(FILL); + assertThat(layer.stroke()).isNull(); assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER); assertThat(run.baselineOffset()).isEqualTo(0.0, within(EPS)); assertThat(run.linkOptions()).isNull(); @@ -32,20 +34,21 @@ void filledShapeConvenienceConstructorKeepsOutlineAndDefaults() { @Test void carriesAnyOutlineKind() { - assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).outline()) + assertThat(new InlineShapeRun(ShapeOutline.diamond(8, 8), FILL).layers().get(0).outline()) .isInstanceOf(ShapeOutline.Polygon.class); - assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).outline()) + assertThat(new InlineShapeRun(ShapeOutline.star(8, 8), FILL).layers().get(0).outline()) .isInstanceOf(ShapeOutline.Polygon.class); - assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).outline()) + assertThat(new InlineShapeRun(new ShapeOutline.Rectangle(8, 4), FILL).layers().get(0).outline()) .isInstanceOf(ShapeOutline.Rectangle.class); } @Test void outlinedOnlyShapeIsAllowed() { InlineShapeRun run = new InlineShapeRun(ShapeOutline.circle(8), null, STROKE, null, 0.0, null); + ShapeLayer layer = run.layers().get(0); - assertThat(run.fill()).isNull(); - assertThat(run.stroke()).isSameAs(STROKE); + assertThat(layer.fill()).isNull(); + assertThat(layer.stroke()).isSameAs(STROKE); assertThat(run.alignment()).isEqualTo(InlineImageAlignment.CENTER); } @@ -84,4 +87,78 @@ void filledConvenienceConstructorRejectsNullFill() { assertThatThrownBy(() -> new InlineShapeRun(ShapeOutline.circle(6), null)) .isInstanceOf(NullPointerException.class); } + + @Test + void checkedCheckboxStacksFrameAndMarkLayers() { + InlineShapeRun box = InlineShapeRun.checkbox(12, true, DocumentColor.BLACK, FILL); + + assertThat(box.layers()).hasSize(2); + ShapeLayer frame = box.layers().get(0); + assertThat(frame.outline()).isInstanceOf(ShapeOutline.RoundedRectangle.class); + assertThat(frame.fill()).isNull(); + assertThat(frame.stroke()).isNotNull(); + ShapeLayer mark = box.layers().get(1); + assertThat(mark.outline()).isInstanceOf(ShapeOutline.Polygon.class); + assertThat(mark.fill()).isSameAs(FILL); + assertThat(box.width()).isEqualTo(12.0, within(EPS)); + assertThat(box.height()).isEqualTo(12.0, within(EPS)); + } + + @Test + void uncheckedCheckboxIsFrameOnly() { + InlineShapeRun box = InlineShapeRun.checkbox(12, false, DocumentColor.BLACK, FILL); + + assertThat(box.layers()).hasSize(1); + assertThat(box.layers().get(0).fill()).isNull(); + assertThat(box.layers().get(0).stroke()).isNotNull(); + } + + @Test + void defaultCheckboxUsesClassicTick() { + ShapeOutline preset = InlineShapeRun.checkbox(12, true, DocumentColor.BLACK, FILL) + .layers().get(1).outline(); + ShapeOutline classic = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.CLASSIC, + DocumentColor.BLACK, FILL) + .layers().get(1).outline(); + + assertThat(((ShapeOutline.Polygon) preset).points()) + .isEqualTo(((ShapeOutline.Polygon) classic).points()); + } + + @Test + void checkboxStyleOverloadSwapsTheTickDesign() { + ShapeOutline classic = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.CLASSIC, + DocumentColor.BLACK, FILL) + .layers().get(1).outline(); + ShapeOutline heavy = InlineShapeRun.checkbox(12, true, ShapeOutline.CheckmarkStyle.HEAVY, + DocumentColor.BLACK, FILL) + .layers().get(1).outline(); + + assertThat(((ShapeOutline.Polygon) heavy).points()) + .isNotEqualTo(((ShapeOutline.Polygon) classic).points()); + } + + @Test + void rawMarkCheckboxUsesGivenOutlineAndGuardsNullWhenChecked() { + ShapeOutline mark = ShapeOutline.plus(7, 7); + InlineShapeRun box = InlineShapeRun.checkbox(12, true, mark, DocumentColor.BLACK, FILL); + assertThat(box.layers().get(1).outline()).isSameAs(mark); + + assertThatThrownBy(() -> + InlineShapeRun.checkbox(12, true, (ShapeOutline) null, DocumentColor.BLACK, FILL)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("mark"); + + // An unchecked box ignores the mark, so a null mark is tolerated. + assertThat(InlineShapeRun.checkbox(12, false, (ShapeOutline) null, DocumentColor.BLACK, FILL).layers()) + .hasSize(1); + } + + @Test + void checkboxStyleOverloadRejectsNullStyle() { + assertThatThrownBy(() -> + InlineShapeRun.checkbox(12, true, (ShapeOutline.CheckmarkStyle) null, DocumentColor.BLACK, FILL)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("markStyle"); + } } diff --git a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java index fdc3662c..8f0c334b 100644 --- a/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java +++ b/src/test/java/com/demcha/compose/document/style/ShapeOutlineTest.java @@ -146,4 +146,53 @@ void everyPolygonFactoryStaysWithinUnitBox() { } } } + + @Test + void checkmarkDefaultEqualsClassicStyle() { + assertThat(ShapeOutline.checkmark(10, 10).points()) + .isEqualTo(ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.CLASSIC).points()); + } + + @Test + void heavyCheckmarkDiffersFromClassicButKeepsSixVerticesInBox() { + ShapeOutline.Polygon classic = ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.CLASSIC); + ShapeOutline.Polygon heavy = ShapeOutline.checkmark(10, 10, ShapeOutline.CheckmarkStyle.HEAVY); + + assertThat(heavy.points()).hasSize(6); + assertThat(heavy.points()).isNotEqualTo(classic.points()); + for (ShapePoint point : heavy.points()) { + assertThat(point.x()).isBetween(0.0, 1.0); + assertThat(point.y()).isBetween(0.0, 1.0); + } + } + + @Test + void arrowDefaultEqualsBlockStyle() { + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT).points()) + .isEqualTo(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, + ShapeOutline.ArrowStyle.BLOCK).points()); + } + + @Test + void triangleArrowHasThreeVerticesAndDirectionalTip() { + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, ShapeOutline.ArrowStyle.TRIANGLE).points()) + .hasSize(3) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(1.0, within(EPS)); + assertThat(p.y()).isEqualTo(0.5, within(EPS)); + }); + assertThat(ShapeOutline.arrow(10, 10, ShapeOutline.Direction.UP, ShapeOutline.ArrowStyle.TRIANGLE).points()) + .anySatisfy(p -> { + assertThat(p.x()).isEqualTo(0.5, within(EPS)); + assertThat(p.y()).isEqualTo(1.0, within(EPS)); + }); + } + + @Test + void checkmarkAndArrowStyleOverloadsRejectNullStyle() { + assertThatThrownBy(() -> ShapeOutline.checkmark(10, 10, null)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> ShapeOutline.arrow(10, 10, ShapeOutline.Direction.RIGHT, null)) + .isInstanceOf(NullPointerException.class); + } } diff --git a/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java new file mode 100644 index 00000000..24b0b3f3 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/style/ShapeRingsTest.java @@ -0,0 +1,28 @@ +package com.demcha.compose.document.style; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShapeRingsTest { + + @Test + void checkmarkBandReturnsSixTwoCoordinateVertices() { + double[][] ring = ShapeRings.checkmarkBand(0.13); + + assertThat(ring.length).isEqualTo(6); + for (double[] vertex : ring) { + assertThat(vertex.length).isEqualTo(2); + } + } + + @Test + void thickerBandPushesTheOuterElbowLower() { + // Vertex 0 is the outer elbow (bottom of the tick); a thicker band must + // push it lower — a stable proxy that {@code half} actually widens it. + double[][] thin = ShapeRings.checkmarkBand(0.08); + double[][] thick = ShapeRings.checkmarkBand(0.16); + + assertThat(thick[0][1]).isLessThan(thin[0][1]); + } +}