From 7e63d85b373def662c5f2909cf3ca1f486413305 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 03:42:18 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(svg):=20ShapeOutline.Path=20clipper=20?= =?UTF-8?q?=E2=80=94=20clip=20container=20children=20to=20a=20native-curve?= =?UTF-8?q?=20silhouette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShapeOutline.Path joins the sealed outline family (curve-capable sibling of Polygon); validates dims + MoveTo-first segments, copy-protected - ShapeContainerBuilder.path(w, h, segments) and path(w, h, svgPath) (beta) — turn any icon/logo into a content mask under ClipPolicy.CLIP_PATH - outline fill/stroke rides the existing PathFragmentPayload pipeline; the clip handler emits the same addPathSegments geometry — fill, clip and addPath all agree on native curves - VectorPathExample gains a heart-silhouette clip demo; +4 tests --- CHANGELOG.md | 10 +++ assets/readme/examples/vector-path.pdf | Bin 2584 -> 2848 bytes .../features/shapes/VectorPathExample.java | 13 +++- .../PdfShapeClipBeginRenderHandler.java | 5 +- .../document/dsl/ShapeContainerBuilder.java | 38 +++++++++++ .../definitions/ShapeContainerDefinition.java | 12 ++++ .../compose/document/style/ShapeOutline.java | 55 +++++++++++++++- .../dsl/ShapeContainerBuilderTest.java | 62 ++++++++++++++++++ 8 files changed, 192 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07796361..57d54b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,16 @@ Entries land here as they merge. every flow builder authors design shapes directly, and `dashed(on, off, ...)` makes the stroke dashed with the same `DocumentDashPattern` contract as lines — the pattern follows the curve. +- **Path-outline clipper** (`@since 1.8.0`). `ShapeOutline.Path` joins the + sealed outline family as the curve-capable sibling of `Polygon`, so a + shape container can clip its children to — and fill / stroke along — an + arbitrary native-curve silhouette. `ShapeContainerBuilder.path(w, h, + segments)` takes raw `DocumentPathSegment`s; `path(w, h, svgPath)` (beta) + clips to an imported SVG path, turning any icon or logo into a content + mask under `ClipPolicy.CLIP_PATH`. The outline rides the existing + vector-path fragment pipeline (one source of truth for native curves) and + the clip handler emits the same `addPathSegments` geometry, so fill, clip, + and `addPath(...)` all agree. - **SVG path import** (`@since 1.8.0`, **beta** — annotated `@Beta` while the surface hardens against real-world exporter output). `SvgPath.parse(d)` / `parse(d, viewBox...)` in the new `document.svg` package lowers the full diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf index 51223b720069294a0a9ab1a3798980682b4562dd..dd73303c248c4979eac2196c55a84a556787a659 100644 GIT binary patch delta 2620 zcmb7FX*3iH1C0=4X@tTj#+o#m8OAIb8p906*4SlIwh@uF$v$O&#+I>bXsu z=CW)0?3Xg$ZdtTMcw~$E>^0Bmk!k4bwdo4$)e{xk#Asyodh~a>sZsGY8}@>E0DT}L z>wIHyLOxgm@1+$Bps~^m6^9?ld0;||MV+obzI%eT>rv7h(82UNS$pC~6HB|jF_e|F z(c0Y@F&E`K91<`ls}Poe5ROYyoP!MTL~QYf}^t4XsFF`8GUA2LaSAy$qFR1@E^ zh1fPj1@@5>VeZbG#Iz6kt=u1`Xtz7wL#9guX#b^g>|?tc+C};JO0gT=r4y~v-|hQ z-Fu<)H#fsN#+4RGg}3SBFB(W*Udo1Gna#Y>Pbi~?OMQ2rH+M4y#n5Zo(YBPaY1xnO zb?S?Vf2cOH8fdD4>n$LxWhG@I++y%^{*m3?$u%Soar72L!oFGf3Ko|v@e8^5XSR8< zipgASic}^r`$i*Qr9heJvem<9@h97_0 zG%n!iX5Z9yNg{X!l<{^K#mz5x*;n_>QtoPx!%}Yj`k{KO{|+Z~I%1IRh;bv$eXGmx zfO9g=1R!#7%7L(d@)g1UGpDJ$*TK0y4u`upxu~OMZRZ{7U-RYdDjy#tbGCIQnsQ6> zWl-e=TCDi`KVSoclgxF>$mRy5SpL!95jH7mc3Gs=urCJqtx7-YfM8j51*&Fk(m)>N14l9~n7c|NLT zds_rGJh-CX>ayQt3t#;JUxLf!G%>!wJRwP2^HLG=@->+IR19Mz-3No~z=C2QD`8Fd zSiM&T`yK&@%(Od&DY1;evX}|6v|T4di3*2b&sN;tehnvX9XtO8Ht3{pQN~jmxJ46} zqc?q|^y57|DOgA0uNBqxfZ-cTETiL^V7Rr`aaQ*gYX(1<1RuZZQOL4-{YCvF^k+!EXp#(;9Qb znvpC|w>^8M#r8q-aKhwwJ-z^s@M^Sd`XuZo-K0H>ZI{0sHIcd`6UI<2&)kr1k?rJ0 zg>?*xSlGi$F(xx}RriqI>Q%s(?c#dG3a)~M2ye#~X^H;S)8-h6c8nk`K3k{K2e&>< zH)jfFrH^M8DH~@5)DcIze*bl^?M!y#AI(v8wj48 zgHk$jk*{dIV5ECn@Jga0!jfA1e~>78(4x9@FtynG9f zD@FS5;y(`2R=-z>KWk6IxzlPcr(nV5d6BX~ZCFn>lj zQtDdg%i8mO7v)BF!N=766k~tZiEOZ6o287-9gOgQvVtl{k-TR+1m5)>ZJp!4g&Xc( zBum+Fhpv_+4x6_8QU2~{ZVZjbQ4+|Wch1_OYYd*1d6xe0>9kpPHo zSb!%8V&&#z6XFMeSb|{MzmN1ZNKxQH-RUvzki#Uv=KdTTfNKgoNwCIQ}sh_Y?DL3Q?xXwv2*Iye2cpSCm}zn z8XQ*@JU8e|W93<|yi>4ES$X0~3o5cyLwwv&d}(s3>(R>L_oZrDekS6Bs}I-i?Az+xU+N7`Q_c-!b9F3(l=5}#pIL7KwT=R=zp;_J(yj3vs-?`# z<$j>6ahIkxQ z3ynh}&`2mk2adxVB6SQA+ITntjnYBr;7~d+Lp)3yNq}n;47BiCT827Ms1{Tkfkzt{ z=!5>%0{s7YFam^5i=lo8y6xc^3UY>mU`R%m5KOa2^cV=p*Ps`a$>{ zg_B+Z6^9$*zy}$mE2ASYu!0`-$8m$-ttF&_LtKMGLW4Xh04*IT0-&mjw;}-k3lk>7 A_y7O^ delta 2405 zcmb7?X*ARe1IC36V~bq-GVx9**BG-I=GrNfb&@6fn907**q8r=RE)e6y4KJjj3te- zmE|HOVvwz(xaD3QOyz`x3`Y1%IX?+bEV-`=n?5rPPHcE3zZlrS7hrR;bUDh(H&gaw zRIc}v{T+_nxP6P8WWGr3z+~U#&A_g#ZB2CXk{6+te4)2Nsu|8%9pMQr^EKZ07$|WR zCfgIU*l4~-zRAt^sM-KYhvWPV`qwQ?M^Q}Qd3JouQB&8dOHMjc(Eq}-kwaw}shffu zfWOz}QRNJKaG%_Wu$OaL9re}w8`IT3KP^khC$Fbk;I@JHtuR&$S}8)|p+$R$7HZt; zY5`PLY|}kNm>Rr+4dM7Vx+pEFN2ZL(1A0DF4`PCErpcYx^asW>r7R>Z)zb=&<)D0X z9*GegK`;Ddx^~5oE%tN!}0D2GRIm{Yo9u<`u zktcHCUU(G&D4q~IXA&SHo#?q~n*It%-2T16Bo(<|JBMeik(Vm!_gXvDDRS>!A}XE;NsGqj0-4&zq}ULY?%5a+8|1zf;%VUCNE@RaTqMxFOHvKomM8A zwk~$rOe~m^<;>1=PSGf1N zm=}j-sI(VeJJYJzL0{MBAtY70K|H&34wf0|vM$+WrDpd^Kmq+*lTm4)Dy}qXVFj_x zl~8>O5PO{m?Qa>Kf)BKFnr>hSA`|J{a8pQ{uv6XXvl<@F?>|)r1}J?p8~S1jd=niE zR1MWyzA6>QrX6f#N?_@rNzPIa1wyG0O#!Ehu*5At`x;lMCka(DnW5Q2mwz%b{8NHPTG zKn%V}4FkjMA#gYtW*rntB}YSG)}ek>vL)F+ZxJL4pfO-$WAJ~e;^6qwLBh*mW%!AM zf^TVzPUxrr{nVOfh9Fz%x!+tr$z_)R9DAhS04Tq6WhMj^{Q?U5rPF!UQ5G#H@oh5Cl*TfIU%Z^t(gzD-}wfTS~x5kaXNv%?5b7vK8o;(92 zR_n``1%Lba@!aVtxgJSwJuv7Zrx@gBj|jrQ)ZGw*{fb>7gqUo99k&th;&V+Y{XIG3 zWL+oyrhW<2 z9}a+;>RcKrSv6WN93OrK>dRP7%+?w z5kZB(e{2LW%*GPpfkRtbV6j-VrJ25!g(cG5424D_kpRY04`GJU(>E})FhC*=;23jr zEXERT{Udr>^o^XnsY#M?MpDIWS*~in`U0YP SyI@tYJ`xFrLaiLE!T$jS22haz diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java index 086d659b..0e0c3cbb 100644 --- a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java +++ b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java @@ -79,7 +79,7 @@ public static Path generate() throws Exception { Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf"); try (DocumentSession document = GraphCompose.document(pdfFile) - .pageSize(420, 1010) + .pageSize(420, 1130) .margin(DocumentInsets.of(28)) .create()) { document.pageFlow(page -> page @@ -152,6 +152,17 @@ public static Path generate() throws Exception { .closePath() .fill(BRAND_AXIS) .margin(DocumentInsets.bottom(16))) + .addParagraph("SVG-path clip — content clipped to a heart silhouette (CLIP_PATH)") + .addContainer(card -> card + .name("HeartClip") + .path(96, 96, SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24)) + .clipPolicy(com.demcha.compose.document.style.ClipPolicy.CLIP_PATH) + // A full-box gradient layer renders heart-shaped: + // the container clips its children to the outline. + .layer(new com.demcha.compose.document.dsl.ShapeBuilder() + .size(96, 96) + .fill(BRAND_AXIS) + .build())) .addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath") .addPath(path -> path .name("Ribbon") 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 fd64f555..2a1f7e4d 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 @@ -28,7 +28,8 @@ * {@link ClipPolicy#CLIP_BOUNDS} the clip path is the axis-aligned outline * rectangle; for {@link ClipPolicy#CLIP_PATH} it is the geometric outline * (ellipse for circle/ellipse, uniform or per-corner rounded rectangle for - * rounded-rect, polygon for diamonds / arrows / stars).

+ * rounded-rect, polygon for diamonds / arrows / stars, and a native-curve + * path for free-form {@code ShapeOutline.Path} silhouettes).

* * @author Artem Demchyshyn */ @@ -120,6 +121,8 @@ public void render(PlacedFragment fragment, stream.addRect(x, y, width, height); } else if (outline instanceof ShapeOutline.Polygon p) { PdfShapeGeometry.addPolygonPath(stream, x, y, width, height, p.points()); + } else if (outline instanceof ShapeOutline.Path path) { + PdfShapeGeometry.addPathSegments(stream, x, y, width, height, path.segments()); } else { throw new IllegalStateException("Unknown outline: " + outline); } 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 b1c28c50..3d4b25b0 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ShapeContainerBuilder.java @@ -193,6 +193,44 @@ public ShapeContainerBuilder chevron(double width, double height, ShapeOutline.D return this; } + /** + * Sets a free-form path outline — children clip to (and the outline fills / + * strokes along) the native-curve silhouette. Segments use the same + * normalized unit box as {@code addPath(...)} ({@code (0,0)} bottom-left, + * {@code y} up). + * + * @param width outer width in points + * @param height outer height in points + * @param segments normalized path segments, starting with a move-to + * @return this builder + * @since 1.8.0 + */ + public ShapeContainerBuilder path(double width, double height, + java.util.List segments) { + this.outline = ShapeOutline.path(width, height, segments); + return this; + } + + /** + * Sets a free-form path outline from a parsed SVG path — clip a container's + * children to an imported icon or logo silhouette. The SVG path is already + * normalized to the unit box with the y-axis flipped, so only the box size + * remains to choose (use {@code svgPath.aspectRatio()} to keep proportions). + * + * @param width outer width in points + * @param height outer height in points + * @param svgPath parsed SVG path geometry + * @return this builder + * @since 1.8.0 + */ + @com.demcha.compose.document.api.Beta + public ShapeContainerBuilder path(double width, double height, + com.demcha.compose.document.svg.SvgPath svgPath) { + Objects.requireNonNull(svgPath, "svgPath"); + this.outline = ShapeOutline.path(width, height, svgPath.segments()); + return this; + } + /** * Replaces the outline with a pre-built {@link ShapeOutline} value. * 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 fd831594..c2fed070 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 @@ -141,6 +141,18 @@ public List emitFragments(PreparedNode prepa width, height, new PolygonFragmentPayload(p.points(), awtFill, stroke, null, null)); + } else if (outline instanceof ShapeOutline.Path path) { + // The outline fill/stroke rides the same vector-path fragment + // pipeline as PathNode — native curves, one source of truth. + outlineFragment = new LayoutFragment( + placement.path(), + 0, + padLeft, + padBottom, + width, + height, + new PathFragmentPayload(path.segments(), awtFill, null, stroke, null, + null, null, null, null, null)); } else { throw new IllegalStateException("Unsupported shape outline: " + outline); } 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 94a297cd..eb144909 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java @@ -20,7 +20,8 @@ public sealed interface ShapeOutline permits ShapeOutline.RoundedRectangle, ShapeOutline.RoundedRectanglePerCorner, ShapeOutline.Ellipse, - ShapeOutline.Polygon { + ShapeOutline.Polygon, + ShapeOutline.Path { /** * Returns the outline outer width. @@ -140,6 +141,44 @@ record Polygon(double width, double height, List points) implements } } + /** + * Free-form outline described by normalized {@link DocumentPathSegment}s — + * the curve-capable sibling of {@link Polygon}. Segments live in a unit + * box ({@code (0,0)} bottom-left, {@code y} up, the + * {@link com.demcha.compose.document.node.PathNode} convention) and are + * scaled to {@code width × height} at render time, so one segment list + * clips and fills at any size. Use it to clip a container's children to an + * arbitrary vector — a heart, a logo silhouette, an imported SVG path — + * via {@link com.demcha.compose.document.style.ClipPolicy#CLIP_PATH}. + * + * @param width outer width in points + * @param height outer height in points + * @param segments normalized path segments, starting with a + * {@link DocumentPathSegment.MoveTo} + * @since 1.8.0 + */ + record Path(double width, double height, List segments) + implements ShapeOutline { + /** + * Validates dimensions and the segment list; copy-protects the segments. + */ + public Path { + requirePositive("width", width); + requirePositive("height", height); + Objects.requireNonNull(segments, "segments"); + segments = List.copyOf(segments); + if (segments.size() < 2) { + throw new IllegalArgumentException( + "path outline needs at least a MoveTo and one drawing segment: " + segments.size()); + } + if (!(segments.get(0) instanceof DocumentPathSegment.MoveTo)) { + throw new IllegalArgumentException( + "path outline must start with a MoveTo segment, found: " + + segments.get(0).getClass().getSimpleName()); + } + } + } + /** * Cardinal direction for directional figures (arrows, chevrons). * @@ -225,6 +264,20 @@ static Polygon polygon(double width, double height, List points) { return new Polygon(width, height, points); } + /** + * Creates a {@link Path} outline from normalized path segments — a + * free-form clip / fill silhouette with native curves. + * + * @param width outer width in points + * @param height outer height in points + * @param segments normalized path segments, starting with a move-to + * @return path outline + * @since 1.8.0 + */ + static Path path(double width, double height, List segments) { + return new Path(width, height, segments); + } + /** * Creates a four-point diamond (rhombus) inscribed in the box. * diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java index 1ca84442..24c592ff 100644 --- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java @@ -164,6 +164,68 @@ void perCornerRoundedRectOutlineReachesTheClipPayload() { } } + @Test + void pathOutlineReachesTheClipPayloadAndRenders() throws Exception { + // A free-form path outline (a diamond via segments) must travel to the + // clip-begin payload as a ShapeOutline.Path, fill its outline, and the + // document must render without error. + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(20)) + .create()) { + + session.add(new ShapeContainerBuilder() + .name("Gem") + .path(120.0, 90.0, java.util.List.of( + com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.5, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 0.5), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.5, 0.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 0.5), + com.demcha.compose.document.style.DocumentPathSegment.close())) + .fillColor(BRAND) + .center(spacer("Inside", 50.0, 16.0)) + .build()); + + List fragments = session.layoutGraph().fragments(); + int begin = indexOfPayload(fragments, ShapeClipBeginPayload.class); + assertThat(begin).isGreaterThanOrEqualTo(0); + ShapeOutline outline = ((ShapeClipBeginPayload) fragments.get(begin).payload()).outline(); + assertThat(outline).isInstanceOf(ShapeOutline.Path.class); + assertThat(((ShapeOutline.Path) outline).segments()).hasSize(5); + + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + + @Test + void svgPathBridgeProducesAPathOutline() { + // path(w, h, SvgPath) clips a container to an imported icon silhouette. + var icon = com.demcha.compose.document.svg.SvgPath.parse("M0 0 H10 V10 H0 Z", 0, 0, 10, 10); + var node = new ShapeContainerBuilder() + .name("IconClip") + .path(64.0, 64.0, icon) + .center(spacer("x", 20, 20)) + .build(); + + assertThat(node.outline()).isInstanceOf(ShapeOutline.Path.class); + assertThat(((ShapeOutline.Path) node.outline()).segments()) + .isEqualTo(icon.segments()); + } + + @Test + void pathOutlineValidatesSegments() { + assertThatThrownBy(() -> new ShapeContainerBuilder() + .name("Bad") + .path(80.0, 80.0, java.util.List.of( + com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 1.0))) + .center(spacer("x", 10, 10)) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must start with a MoveTo"); + } + @Test void positionOffsetMovesLayerInScreenSpaceUnits() { try (DocumentSession session = GraphCompose.document() From c129244e3d44eea749d7e6c0eff4fe70bc4613e6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 10:02:15 +0100 Subject: [PATCH 2/4] fix(svg): render ShapeOutline.Path as an inline shape too (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial PR review caught a missed third instanceof-over-ShapeOutline: PdfParagraphFragmentRenderHandler.renderShape threw IllegalStateException for Path, reachable via the public paragraph.shape(ShapeOutline, color) / RichText.shape(...) surfaces (a layout-then-render-time crash). - add the Path branch to the inline-shape renderer (same addPathSegments) - ShapeOutlineRenderCoverageTest: reflect over getPermittedSubclasses() and drive every permit through BOTH the container clip and inline-shape surfaces — fails red without the fix, and blocks the next permit from repeating the miss - Javadoc caveat on ShapeOutline.Path: non-zero winding, multi-subpath holes, close() for a connected stroke - multi-subpath donut render test --- .../PdfParagraphFragmentRenderHandler.java | 2 + .../compose/document/style/ShapeOutline.java | 7 ++ .../ShapeOutlineRenderCoverageTest.java | 103 ++++++++++++++++++ .../dsl/ShapeContainerBuilderTest.java | 33 ++++++ 4 files changed, 145 insertions(+) create mode 100644 src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java 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 640a7251..e67ae8ed 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 @@ -163,6 +163,8 @@ private static void renderShape(PDPageContentStream stream, (float) Math.min(c.bottomLeft(), maxRadius)); } else if (outline instanceof ShapeOutline.Polygon p) { PdfShapeGeometry.addPolygonPath(s, lx, ly, lw, lh, p.points()); + } else if (outline instanceof ShapeOutline.Path path) { + PdfShapeGeometry.addPathSegments(s, lx, ly, lw, lh, path.segments()); } else { throw new IllegalStateException("Unknown inline outline: " + outline); } 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 eb144909..2f3e2424 100644 --- a/src/main/java/com/demcha/compose/document/style/ShapeOutline.java +++ b/src/main/java/com/demcha/compose/document/style/ShapeOutline.java @@ -151,6 +151,13 @@ record Polygon(double width, double height, List points) implements * arbitrary vector — a heart, a logo silhouette, an imported SVG path — * via {@link com.demcha.compose.document.style.ClipPolicy#CLIP_PATH}. * + *

Fill and clip use the non-zero winding rule and implicitly close each + * subpath, so a silhouette with several {@code MoveTo} rings cuts a hole + * (a donut, the bowl of an "O") only where an inner ring winds opposite the + * outer one. An outline stroke, in contrast, follows the segments + * literally and does not auto-close — end the path with a close segment for + * a connected stroked border.

+ * * @param width outer width in points * @param height outer height in points * @param segments normalized path segments, starting with a diff --git a/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java b/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java new file mode 100644 index 00000000..270ac871 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/architecture/ShapeOutlineRenderCoverageTest.java @@ -0,0 +1,103 @@ +package com.demcha.compose.document.architecture; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.document.style.ShapeOutline; +import com.demcha.compose.document.style.ShapePoint; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Exhaustiveness guard: every {@link ShapeOutline} permit must render through + * both outline-consuming surfaces — the shape-container clip path and + * the inline-shape run — without throwing. Each surface dispatches on the + * outline kind with an {@code instanceof} chain that ends in an + * {@code IllegalStateException}; this test reflects over + * {@code getPermittedSubclasses()} so the next permit added to the sealed type + * cannot silently miss a render branch (the lesson from the + * {@code ShapeOutline.Path} clipper, where the inline-shape switch was missed). + */ +class ShapeOutlineRenderCoverageTest { + + /** One representative instance per permitted ShapeOutline kind. */ + private static final Map, ShapeOutline> REPRESENTATIVES = Map.of( + ShapeOutline.Rectangle.class, new ShapeOutline.Rectangle(40, 24), + ShapeOutline.RoundedRectangle.class, new ShapeOutline.RoundedRectangle(40, 24, 6), + ShapeOutline.RoundedRectanglePerCorner.class, + new ShapeOutline.RoundedRectanglePerCorner(40, 24, DocumentCornerRadius.right(6)), + ShapeOutline.Ellipse.class, new ShapeOutline.Ellipse(40, 24), + ShapeOutline.Polygon.class, ShapeOutline.diamond(40, 24), + ShapeOutline.Path.class, ShapeOutline.path(40, 24, List.of( + DocumentPathSegment.moveTo(0.5, 1.0), + DocumentPathSegment.lineTo(1.0, 0.0), + DocumentPathSegment.lineTo(0.0, 0.0), + DocumentPathSegment.close()))); + + @Test + void everyPermittedOutlineHasARepresentative() { + Set> permits = Set.of(ShapeOutline.class.getPermittedSubclasses()); + Set> covered = REPRESENTATIVES.keySet(); + // If this fails, a new ShapeOutline permit was added — give it a + // representative here AND a render branch in BOTH surfaces below. + assertThat(covered).containsExactlyInAnyOrderElementsOf(permits); + } + + @Test + void everyOutlineClipsInAContainerWithoutThrowing() throws Exception { + for (ShapeOutline outline : REPRESENTATIVES.values()) { + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 160) + .margin(DocumentInsets.of(16)) + .create()) { + session.add(new ShapeContainerBuilder() + .name("Clip" + outline.getClass().getSimpleName()) + .outline(outline) + .clipPolicy(ClipPolicy.CLIP_PATH) + .fillColor(DocumentColor.rgb(20, 80, 95)) + .center(spacer()) + .build()); + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)) + .as("clip render of " + outline.getClass().getSimpleName()) + .isEqualTo("%PDF-"); + } + } + } + + @Test + void everyOutlineRendersAsAnInlineShapeWithoutThrowing() throws Exception { + for (ShapeOutline outline : REPRESENTATIVES.values()) { + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 160) + .margin(DocumentInsets.of(16)) + .create()) { + session.dsl().pageFlow().name("Flow") + .addParagraph(p -> p + .text("inline ") + .shape(outline, DocumentColor.rgb(196, 30, 58))) + .build(); + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)) + .as("inline render of " + outline.getClass().getSimpleName()) + .isEqualTo("%PDF-"); + } + } + } + + private static com.demcha.compose.document.node.DocumentNode spacer() { + return new com.demcha.compose.document.dsl.SpacerBuilder().size(12, 12).build(); + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java index 24c592ff..e49ef382 100644 --- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java @@ -213,6 +213,39 @@ void svgPathBridgeProducesAPathOutline() { .isEqualTo(icon.segments()); } + @Test + void multiSubpathPathOutlineRenders() throws Exception { + // A donut silhouette: an outer ring and an inner counter-wound ring + // (two MoveTo subpaths). Non-zero winding cuts the hole; the document + // must render without error — guards multi-MoveTo clip geometry. + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 200) + .margin(DocumentInsets.of(20)) + .create()) { + + session.add(new ShapeContainerBuilder() + .name("Donut") + .path(100.0, 100.0, java.util.List.of( + com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.0, 0.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 0.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(1.0, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.0, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.close(), + // inner ring, opposite winding → hole + com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.3, 0.3), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.3, 0.7), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.7, 0.7), + com.demcha.compose.document.style.DocumentPathSegment.lineTo(0.7, 0.3), + com.demcha.compose.document.style.DocumentPathSegment.close())) + .fillColor(BRAND) + .center(spacer("x", 20, 20)) + .build()); + + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + @Test void pathOutlineValidatesSegments() { assertThatThrownBy(() -> new ShapeContainerBuilder() From 3d1be5a250cb5f85761f2cf3980b988f46a3ce63 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 14:28:01 +0100 Subject: [PATCH 3/4] docs(examples): SVG-path clip catalog tile + photo-clip silhouette example Adds an SVG-path clip tile to the flagship feature catalog and a runnable PhotoClipExample (photo clipped to circle / SVG heart / star), wired into GenerateAllExamples and ShowcaseMetadata per the example-registration convention. --- .../demcha/examples/GenerateAllExamples.java | 2 + .../features/shapes/PhotoClipExample.java | 152 ++++++++++++++++++ .../flagships/FeatureCatalogExample.java | 15 ++ .../examples/support/ShowcaseMetadata.java | 3 +- 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 19697f81..8062dd7b 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -7,6 +7,7 @@ import com.demcha.examples.features.docx.WordExportExample; import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.lists.NestedListExample; +import com.demcha.examples.features.shapes.PhotoClipExample; import com.demcha.examples.features.shapes.ShapeContainerExample; import com.demcha.examples.features.shapes.VectorPathExample; import com.demcha.examples.features.snapshots.LayoutSnapshotRegressionExample; @@ -131,6 +132,7 @@ public static void main(String[] args) throws Exception { // v1.5 visual primitives System.out.println("Generated: " + ShapeContainerExample.generate()); System.out.println("Generated: " + VectorPathExample.generate()); + System.out.println("Generated: " + PhotoClipExample.generate()); System.out.println("Generated: " + SvgIconGalleryExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java new file mode 100644 index 00000000..9d88e321 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/shapes/PhotoClipExample.java @@ -0,0 +1,152 @@ +package com.demcha.examples.features.shapes; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ImageBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.image.DocumentImageData; +import com.demcha.compose.document.image.DocumentImageFitMode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.ClipPolicy; +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.svg.SvgPath; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.io.InputStream; +import java.nio.file.Path; +import java.util.Objects; + +/** + * Clip one rectangular photo to a free-form silhouette — the v1.8 + * {@code ShapeOutline.Path} clip. Three "cookie-cutters" (a circle, an SVG + * heart, a star) cut the same photo into three shapes. + * + *

The construction logic is always the same three moves:

+ *
    + *
  1. outline — the shape that does the cutting (mandatory);
  2. + *
  3. clipPolicy(CLIP_PATH) — "cut children to the silhouette" + * (already the container default, written here for clarity);
  4. + *
  5. a layer — the photo, sized to the same box and + * {@code COVER}-filled so it reaches every edge for the clip to bite.
  6. + *
+ * + * @author Artem Demchyshyn + */ +public final class PhotoClipExample { + + private static final DocumentColor INK = DocumentColor.rgb(34, 38, 50); + private static final DocumentColor GOLD = DocumentColor.rgb(196, 153, 76); + + /** Material Icons "favorite" heart (Apache 2.0), viewBox 0 0 24 24. */ + private static final String HEART_D = + "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3" + + "c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5" + + "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"; + + private static final double BOX = 150; + + private PhotoClipExample() { + } + + /** + * Renders the sheet: one photo, three silhouettes. + * + * @return path to the generated PDF + * @throws Exception if rendering or resource IO fails + */ + public static Path generate() throws Exception { + Path pdf = ExampleOutputPaths.prepare("features/shapes", "photo-clip.pdf"); + DocumentImageData photo = photo(); + + try (DocumentSession document = GraphCompose.document(pdf) + .pageSize(560, 300) + .margin(DocumentInsets.of(28)) + .create()) { + document.pageFlow(page -> page + .addParagraph(p -> p + .text("Photo clipped to a silhouette") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18).withColor(INK))) + .addParagraph(p -> p + .text("One rectangular photo, three cookie-cutters. The image fills " + + "each box (COVER); the outline cuts it to shape, native curves " + + "stay crisp at any zoom.") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9.5) + .withColor(DocumentColor.rgb(90, 96, 105))) + .padding(DocumentInsets.bottom(12))) + .addRow(row -> row.spacing(20).evenWeights() + .addSection(s -> s.spacing(6) + .add(circleClip(photo)) + .addParagraph("circle(150)")) + .addSection(s -> s.spacing(6) + .add(heartClip(photo)) + .addParagraph("path(150, 150, SvgPath heart)")) + .addSection(s -> s.spacing(6) + .add(starClip(photo)) + .addParagraph("star(150, 150)")))); + document.buildPdf(); + } + return pdf; + } + + /** Circle cutter — an ellipse clip, available before v1.8. */ + private static DocumentNode circleClip(DocumentImageData photo) { + return new ShapeContainerBuilder() + .name("CirclePhoto") + .circle(BOX) // 1. outline = the cutter (mandatory) + .clipPolicy(ClipPolicy.CLIP_PATH) // 2. cut children to the silhouette + .stroke(DocumentStroke.of(GOLD, 2)) // (optional) gold rim along the cut + .center(cover(photo)) // 3. the photo, COVER-filling the box + .build(); + } + + /** Heart cutter — the v1.8 free-form path clip (native Béziers). */ + private static DocumentNode heartClip(DocumentImageData photo) { + return new ShapeContainerBuilder() + .name("HeartPhoto") + .path(BOX, BOX, SvgPath.parse(HEART_D, 0, 0, 24, 24)) + .clipPolicy(ClipPolicy.CLIP_PATH) + .stroke(DocumentStroke.of(GOLD, 2)) + .center(cover(photo)) + .build(); + } + + /** Five-point star cutter. */ + private static DocumentNode starClip(DocumentImageData photo) { + return new ShapeContainerBuilder() + .name("StarPhoto") + .star(BOX, BOX) + .clipPolicy(ClipPolicy.CLIP_PATH) + .stroke(DocumentStroke.of(GOLD, 2)) + .center(cover(photo)) + .build(); + } + + /** + * The photo sized to fill the WHOLE box with {@code COVER}, so it reaches + * every edge of the silhouette and the clip has something to cut. A smaller + * size or {@code CONTAIN} would leave gaps inside the shape; {@code STRETCH} + * would distort the photo. + */ + private static DocumentNode cover(DocumentImageData photo) { + return new ImageBuilder() + .source(photo) + .size(BOX, BOX) + .fitMode(DocumentImageFitMode.COVER) + .build(); + } + + private static DocumentImageData photo() throws Exception { + try (InputStream in = Objects.requireNonNull( + PhotoClipExample.class.getResourceAsStream("/engine-hero.jpg"), + "engine-hero.jpg missing from examples/src/main/resources/")) { + return DocumentImageData.fromBytes(in.readAllBytes()); + } + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java index 5c004b3a..0d575f65 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -346,6 +346,21 @@ public static Path generate() throws Exception { .svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24)) .fillColor(DocumentColor.rgb(196, 30, 58)))); + feature(flow, "SVG-path clip — content clipped to a curve silhouette (CLIP_PATH)", """ + section.addContainer(card -> card + .path(72, 72, SvgPath.parse(HEART_D, 0, 0, 24, 24)) + .clipPolicy(ClipPolicy.CLIP_PATH) // full-box gradient renders heart-shaped + .layer(new ShapeBuilder().size(72, 72) + .fill(DocumentPaint.linear(GOLD, TEAL)).build()))""", + demo -> demo.addContainer(card -> card + .name("HeartClip") + .path(72, 72, SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24)) + .clipPolicy(ClipPolicy.CLIP_PATH) + .layer(new com.demcha.compose.document.dsl.ShapeBuilder() + .size(72, 72) + .fill(DocumentPaint.linear(GOLD, TEAL)) + .build()))); + feature(flow, "SVG icons (beta) — multicolour files centred on tile cards", """ SvgIcon icon = SvgIcon.read(Path.of("icons/apple.svg")); // layers + resolved paints card.roundedRect(74, 64, 8) // fixed box = the tile diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 14ed92b2..40be43aa 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -97,6 +97,7 @@ record Entry(String title, String description, List tags, String codeUrl feature("shapes", "shape-container", "Shape-as-Container", "Rounded rect, ellipse, circle containers with ClipPolicy and layered children.", "shapes", "clip"); feature("svg", "svg-icon-gallery", "SVG Icon Gallery", "34 real-world multicolour svgrepo icons through SvgIcon.parse — native vector layers, the whole set 156 KB of sources.", "svg", "icons", "v1.8"); feature("shapes", "vector-path", "Vector Paths (Bézier)", "addPath(...) — free-form design shapes with native cubic Bézier curves: stroked waves, filled blobs, mixed line/curve ribbons. No tessellation.", "shapes", "bezier", "v1.8"); + feature("shapes", "photo-clip", "Photo Clip (silhouette)", "A raster photo clipped to a free-form silhouette — circle, SVG heart, star — via ShapeContainer.path(...) + ClipPolicy.CLIP_PATH; the image COVER-fills each box so the native-curve outline crops it crisply at any zoom.", "shapes", "clip", "v1.8"); feature("transforms", "transforms", "Layers + Transforms", "rotate / scale on every leaf builder + LayerStack with explicit z-index.", "transforms", "layers"); feature("text", "rich-text-showcase", "Rich Text", "Inline runs with bold / italic / colour / link options, markdown parsing.", "text", "rich"); feature("text", "section-presets", "Section Presets", "Pre-baked section bands, accent strips, soft panels for templates.", "text", "sections"); @@ -204,7 +205,7 @@ private static void feature(String group, String id, String title, String desc, case "lists" -> "lists/NestedListExample.java"; case "tables" -> id.contains("composed") ? "tables/ComposedTableCellExample.java" : "tables/TableAdvancedExample.java"; case "canvas" -> "canvas/CanvasLayerExample.java"; - case "shapes" -> "shapes/ShapeContainerExample.java"; + case "shapes" -> id.equals("photo-clip") ? "shapes/PhotoClipExample.java" : "shapes/ShapeContainerExample.java"; case "transforms" -> "transforms/TransformsExample.java"; case "text" -> id.equals("section-presets") ? "text/SectionPresetsExample.java" : "text/RichTextShowcaseExample.java"; case "themes" -> "themes/CustomBusinessThemeExample.java"; From fb89f615c3a194481ac2568797b46b963c4acb43 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 14:28:27 +0100 Subject: [PATCH 4/4] =?UTF-8?q?test(style):=20native-curve=20clip=20covera?= =?UTF-8?q?ge=20=E2=80=94=20cubic=20segment=20+=20inline=20visual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CubicTo clip test (curve survives to the clip payload, not flattened) and a self-contained ShapeClipPathVisualTest that samples the rendered pixels (triangle clip ~50% coverage, base-at-bottom) to guard a dropped/empty clip, wrong scale, or y-flip without a committed pixel baseline. --- .../dsl/ShapeContainerBuilderTest.java | 39 ++++++++ .../visual/ShapeClipPathVisualTest.java | 98 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java diff --git a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java index e49ef382..d08e67eb 100644 --- a/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/ShapeContainerBuilderTest.java @@ -198,6 +198,45 @@ void pathOutlineReachesTheClipPayloadAndRenders() throws Exception { } } + @Test + void cubicCurveOutlineKeepsItsCurveSegmentThroughTheClipPipeline() throws Exception { + // Every other path-clip test is line-only. A native cubic Bézier + // (curveTo) must survive to the clip payload as a CubicTo segment — + // NOT flattened to lines — and render without error, since native + // curves are the whole point of ShapeOutline.Path. This asserts the + // clip *payload*; ShapeClipPathVisualTest covers the rendered pixels. + try (DocumentSession session = GraphCompose.document() + .pageSize(200, 160) + .margin(DocumentInsets.of(16)) + .create()) { + + session.add(new ShapeContainerBuilder() + .name("Petal") + .path(120.0, 90.0, java.util.List.of( + com.demcha.compose.document.style.DocumentPathSegment.moveTo(0.5, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.cubicTo( + 1.1, 0.9, 1.0, 0.1, 0.5, 0.0), + com.demcha.compose.document.style.DocumentPathSegment.cubicTo( + 0.0, 0.1, -0.1, 0.9, 0.5, 1.0), + com.demcha.compose.document.style.DocumentPathSegment.close())) + .fillColor(BRAND) + .center(spacer("Inside", 40.0, 16.0)) + .build()); + + List fragments = session.layoutGraph().fragments(); + int begin = indexOfPayload(fragments, ShapeClipBeginPayload.class); + assertThat(begin).isGreaterThanOrEqualTo(0); + ShapeOutline outline = ((ShapeClipBeginPayload) fragments.get(begin).payload()).outline(); + assertThat(outline).isInstanceOf(ShapeOutline.Path.class); + assertThat(((ShapeOutline.Path) outline).segments()) + .as("the cubic segment must reach the clip payload, not be flattened to lines") + .anyMatch(s -> s instanceof com.demcha.compose.document.style.DocumentPathSegment.CubicTo); + + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + @Test void svgPathBridgeProducesAPathOutline() { // path(w, h, SvgPath) clips a container to an imported icon silhouette. diff --git a/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java b/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java new file mode 100644 index 00000000..e2de80b9 --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/ShapeClipPathVisualTest.java @@ -0,0 +1,98 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Deterministic visual check that a {@code ShapeOutline.Path} clip actually + * shapes the rendered pixels — the gap the {@code %PDF-} smoke tests cannot + * cover. A full-box red layer clipped to an upward triangle must paint roughly + * half the box (a triangle is half its bounding box) with the wide base at the + * BOTTOM. So a dropped clip (≈100% red), an empty clip (≈0% red), a wrong scale, + * and a y-flip (apex/base swapped) all fail this test. + * + *

Self-contained on purpose: it samples the in-memory render instead of + * diffing a committed PNG baseline, so it stays deterministic and + * anti-aliasing-tolerant across platforms without a blessed pixel reference.

+ */ +class ShapeClipPathVisualTest { + + private static final int BOX = 120; + + @Test + void pathClipShapesTheRenderedPixelsRightSideUp() throws Exception { + BufferedImage page = PdfVisualRegression.standard() + .renderPages(renderTriangleClip()) + .get(0); + + int w = page.getWidth(); + int h = page.getHeight(); + long red = 0; + long redTop = 0; + long redBottom = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + if (isRed(page.getRGB(x, y))) { + red++; + if (y < h / 2) { + redTop++; + } else { + redBottom++; + } + } + } + } + + double fraction = (double) red / ((long) w * h); + assertThat(fraction) + .as("clipped red triangle should cover ~half the box " + + "(clip applied, right scale) — got %.3f", fraction) + .isBetween(0.30, 0.70); + assertThat(redBottom) + .as("wide base at the bottom, narrow apex at the top — guards a y-flip") + .isGreaterThan(redTop * 2); + } + + /** A 120x120 container clipping a full-box red layer to an upward triangle. */ + private static byte[] renderTriangleClip() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(BOX, BOX) + .margin(DocumentInsets.zero()) + .create()) { + session.add(new ShapeContainerBuilder() + .name("TriangleClip") + .path(BOX, BOX, List.of( + DocumentPathSegment.moveTo(0.5, 1.0), // apex, top (y grows up) + DocumentPathSegment.lineTo(1.0, 0.0), // base, bottom-right + DocumentPathSegment.lineTo(0.0, 0.0), // base, bottom-left + DocumentPathSegment.close())) + .clipPolicy(ClipPolicy.CLIP_PATH) + .layer(new ShapeBuilder() + .size(BOX, BOX) + .fillColor(DocumentColor.rgb(220, 20, 20)) + .build()) + .build()); + return session.toPdfBytes(); + } + } + + private static boolean isRed(int rgb) { + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + return r > 140 && g < 110 && b < 110; + } +}