From 83c71e6f1938da877759fdc640b3acae10f970d2 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 15:04:47 +0100 Subject: [PATCH] feat(svg): gradient paints end-to-end - linear/radial fills and strokes as native PDF shadings - DocumentPaint gains endpoint-exact LinearAxis and RadialCircle forms - PathNode/PathBuilder grow fill(paint) + strokePaint(paint); solid paints normalise to the flat-colour path (byte-identical non-gradient output) - gradient fills clip to the path and emit axial/radial shadings; gradient strokes ride a shading-pattern stroking colour (inlined subtree - nested dicts would dangle as null indirect refs after save) - SvgIconReader resolves url(#id) gradients: userSpaceOnUse and objectBoundingBox units, gradientTransform, percent offsets, multi-stop, one href hop; loud failures for focal/spreadMethod/stop-opacity - SvgIcon#node(width) node form; addSvgIcon delegates; rows accept ShapeContainerNode (same atomic overlay composite as LayerStackNode) - examples simplified onto icon.node(); vector-path gains a gradient block --- CHANGELOG.md | 20 +- assets/readme/examples/vector-path.pdf | Bin 1913 -> 2350 bytes .../features/shapes/VectorPathExample.java | 30 +- .../features/svg/SvgIconGalleryExample.java | 31 +- .../flagships/FeatureCatalogExample.java | 29 +- .../PdfPathFragmentRenderHandler.java | 55 ++- .../fixed/pdf/handlers/PdfShadingSupport.java | 97 ++++- .../document/dsl/AbstractFlowBuilder.java | 14 +- .../compose/document/dsl/PathBuilder.java | 33 +- .../compose/document/dsl/RowBuilder.java | 5 + .../layout/definitions/PathDefinition.java | 29 +- .../layout/payloads/PathFragmentPayload.java | 10 + .../compose/document/node/PathNode.java | 59 ++- .../compose/document/style/DocumentPaint.java | 90 ++++- .../compose/document/svg/SvgGradients.java | 349 ++++++++++++++++++ .../demcha/compose/document/svg/SvgIcon.java | 96 ++++- .../compose/document/svg/SvgIconReader.java | 99 +++-- .../fixed/pdf/PdfPathGradientTest.java | 152 ++++++++ .../compose/document/dsl/PathBuilderTest.java | 35 ++ .../compose/document/svg/SvgIconTest.java | 190 ++++++++++ 20 files changed, 1309 insertions(+), 114 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgGradients.java create mode 100644 src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathGradientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c1926cf43..7bb2e9722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,8 +84,24 @@ Entries land here as they merge. transforms (affine maps are exact on Bézier control points), and `fill` / `stroke` / `stroke-width` styling with SVG inheritance and defaults — into ordered layers, and `addSvgIcon(icon, width)` stacks them - back-to-front on the page. The XML reader refuses DOCTYPEs (no XXE); - gradients, CSS, text and filters stay deliberately out of scope. + back-to-front on the page. `SvgIcon#node(width)` packages the same layers + as one ready-to-place node whose box is exactly the icon box, so it + anchors true inside `ShapeContainer` / `LayerStack` nine-point grids (and + rows now accept `ShapeContainerNode` children directly — it is the same + atomic overlay composite as the already-allowed `LayerStackNode`). + **Gradients render natively**: `linearGradient` / `radialGradient` + referenced via `url(#id)` — on fills *and strokes* — map to PDF axial / + radial shadings with exact endpoints (`userSpaceOnUse` and + `objectBoundingBox` units, `gradientTransform`, percentage offsets, + multi-stop stitching, one `href` hop for split definitions); gradient + strokes ride a shading-pattern stroking colour. Underneath, + `DocumentPaint` gains endpoint-exact `LinearAxis` / `RadialCircle` forms + and `PathNode` / `PathBuilder` grow `fill(paint)` / `strokePaint(paint)` + with solid paints normalising to the flat-colour path (byte-identical + output for non-gradient documents). The XML reader refuses DOCTYPEs (no + XXE); CSS, text, filters, focal radials, non-pad `spreadMethod` and + translucent stops stay deliberately out of scope — the reader fails + loudly rather than rendering them wrong. - **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color, values...)` draws a filled mini-area silhouette on the text baseline, and `sparklineLine(w, h, thickness, color, values...)` a constant-thickness line diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf index 5ae1744260688075b5a189a55c7c604b774328ed..0ab247f33f5a1926168ec58cb623a1fa60d4e25c 100644 GIT binary patch delta 2124 zcmb7CM^qDt0;EU_MVcrm5ITmEgpeizfe=|bp%+0Zk!C_D22|K6N-t6aiV!Kn#{!|K zR6*F#o78}bioycYlqdp~pG%k(=9}e)Y6*4(`0Gt>U~*9QgI!==&A>LFkU_H0*lwq}TLtN9@6mcNgM$ zH-_PojtGW4T`FfZiE=r(^Yd%>G|w`(1YWuJ>_|jidPi~Gk)}XDsqGqte8$}D33I;C zfl2j8a!&myzDfy8Nvh&Dft5KhMBZKeRdSvDSOt?%jMZbI!I%tNL&-znUTM-nsu9)S}Mu_BqeL?mru)Z zn%1LUH>6~G1*8SxR$49|*uLZe&Pz{)wsviS+-bF+ON=Pgxapk?5l}O4zr3zE=jqcq zCHdYY()#k0wN`0W)aa)Gq6H=5U}59?3#!R(^&8Wj?{up*T6LQDZ|UMmP+dW>3n7~Pn`R~eyD;2ri|8U%qd!sMbxuZ4#@>#bo>Ee(y{#1F ztGTxv_8h?_M?{^!E(fZuuB=Lt8Zcv_CBbPrnFKY@2 zB1_f0U1bb@$jtPs2ZAnvK;|}$@~IqRuWqBTHfWnx!3B7zPEkZ0p|m3|lym}Z3AU%O zpBV2QpSyqpM7MWzaarL;`sFiO{YpH~)5H6q|H>dt1a`ky^TK7}-^= z20p*8Oxk+eL2WZtS>>2`9k#`#tz3EM_aM(?Tw@W`DCM?AUTG%kuEpyMsGX{?T#HI4 zZT&{xC7(4)1!;>i_8+|V!yd7eWv3nV&c~IZSSdWeon!B=ZTyY9m>ASW>d|sjF5|dV zW8~SZk&Uh_hs};Qw&`CXhd25PZ{$udP>Zzccz*bKgz&1AB^pU-->$REw&Ce(nP{d2 zOjd++6k%p<7A%1^Mfi!end#`1XjY7ZmSpsZbuQebBtSRZW42dPb#)2r+R4|R?v0>Jh_C^Qc(ph!g^0VXDZsOU(7A5k0-d!OkvM0x{}{8_Na zmorqT0Tgyze$9tV@6sQ?wQvh0BXTp}@T>4d=T>fRz;dDf^&!+@GxGbLQs3WQxyoLeTFN=D3c25`Vepy~oQJS|j#l(px^BRq$Qjshy zyYh4v@cf0&AoDnG@_6l}4y7TtpN_vu9BdtHddu~3bxO@to|uX`-#y%Am!?KP%Frkk zWyAUn01IV`mhUWQ8b8yWJB^>01Q7-1Or9l6Dtj*C_y$AAzT`BlN3q3kbG$ztBuF^q z6Va2G%9h~%Mh6NuMP>;eFK*GP4lj3?kDDgdCN`)0fbK|{Vt>jee->D+4{C4Y*dyNvjTRqF)Yt4bodWTn!I+Q_C%lGtgvDB8j4J>otm zsb73iaM^c=Z4+dtHkUrApi}JCaB%Dp1#jstAD+;%a0~R=*p3@`q>qH!R>jkk&#^lq z3y-og4;Jw>|NZsB&XfXOT9Bu_M849kj91Mvx zL*Oh>D5L=lfrP_RQ1ovJi-ns2|1ARl|7H06?|mLlgi8RhbI3IU@SjUskpc;^KyL^T z3eOb+>mBR0k>-&Uvv_5d@-CscrA_9GZtEirpUK>?KLwE+hqp5@W;kpg19k19E5G9J S8WcqhAW#TELj&h<4)9+ed&$87 delta 1738 zcmbV}Yc!OL9>xicd0kFvCgsw8$1NL~*NhqOxNY+e(`n=q+ohy(c@Y~K6Kb1A$~bLl z%b;@E9Gc{MrcHyGB4kLZJ=k*VCdwr#x#hCk>#VcZIqRHH=ga?j)_T_Zefa&WHI~&g z0u?MQ$pW#mKCi&LB~yo@Dp~ZIk7l&Pw^5m^Q{=pb_X{g+`T zdDM4jY1Eu^hT73*T=Me`>zVhZv!#nAZbOaV@^4Z;=_teyTFDV+)aQ}Afm7=PNSan4 zwPtxHE0fTx!M?Ed0HRSw7nzWS@W3u3etY=TZ{ZJ_n&S9Jb?U+~t!~^(MRRvDXg9ps zkOvOx1y8=fHulPO>LP}zVue(%q*zvp!p^zc8}ibDiJbEz_!Y_c<`da${--f>mHY0V z@r&bcqIulUO0~}7zdAf?d7NMrd-~gJW4g-LGU~%r+C`CJ*>*n5Qa+b;|T` z13Ij>Z_L`I4(SSyigHVFB=L)q(mhi36rOdZkvi#zyYL1ixkhCqv&Ob;Fp^1E--7F= zzoWI4X1^GOkShOHdehL;j%K$th_#{Fu?<>+*Ocuaf7dePv&+9<%h(#V8F8*<&gY-? z;@IZDt?=4?$8S0I_Q#9q-umri{m%^u54|S`4k}Ml{I}M1R>|C`A zQkt3Ch-4};T_U0QC`8f8VAz6{b~T5nf>_98<}joCn$cZ>`CPZK?>_4akGdSd!?HrrcLSXMt;|D-|DQFMMvs(rOiQo_5!N(w3__u*ih zsA!B3m!))=aTd-fQo#cj0r=xO=UcD8%5E=ly4}2Pm0weyGfNPp{gyHID%GqsCYg+P z>9|)1q#mCO^Qt~WoJiEW+nOnxs(8T2&hY!*SO;77B~63GG---kJ;S79s@L(BQ?W%S z2rEP7zfvyJ4_?N{^RoAZMH0cd#hvH6O^S+*;k}wJE$xvr+v>52o0IMto16@x6@KeG zjafj|%v$4wX|k7)@XX-B3x{m2a}+8%-rPa}S+RgrwCv zub;48+@u6c=PcwK(uxevFy)ZbVh#z$S^ z-Azn2S`(H9H7xFU^H#REqAPCW>Fyhsa(}8nRdv;B^_f^da3c<3OY*c+kA)8e%sT`h1{CLW&E0ck#^-=IywRrX;`BU9U8^bQR-p_d zxF_$V6#fr2z#5GTj^(h?fJoqLiw+D2U=RZTOj}!kxF3xQ<%EG2uoXc3{?rK+B)SEL zdvYQHqAN%dtSag9NLGNoJ@8M08jz6hb!h}hcG!|~^o=e3^<=q{-gO*ZF@Uc5D&?J? z$}R0`ri9xFtR3!dULNo6o_k4K_!h|SPD}-u^ zP$*1>6Um86wnAtKiO#fSSkkF<2x3qvBnU<<8DEb*_UG(ovCkrj08*iw(Qxu?0US^oY-hwe7%3rrwc!9qp9LQX->R7?KW a|1c5D31D*)*k8F86qpJa8alZ#fj page @@ -111,6 +120,25 @@ public static Path generate() throws Exception { .margin(DocumentInsets.bottom(16))) .addParagraph("Whole-file icon — SvgIcon.read/parse stacks every layer") .addSvgIcon(SvgIcon.parse(TWO_TONE_BADGE_SVG), 64) + .addParagraph("Gradient paints — strokePaint and fill ride native PDF shadings") + .addPath(path -> path + .name("GradientWave") + .size(364, 52) + .moveTo(0.0, 0.5) + .curveTo(0.25, 1.1, 0.25, -0.1, 0.5, 0.5) + .curveTo(0.75, 1.1, 0.75, -0.1, 1.0, 0.5) + .stroke(DocumentStroke.of(VIOLET, 2.6)) + .strokePaint(BRAND_AXIS) + .margin(DocumentInsets.bottom(8))) + .addPath(path -> path + .name("GradientBlob") + .size(96, 56) + .moveTo(0.5, 1.0) + .curveTo(1.12, 0.94, 0.96, 0.08, 0.5, 0.0) + .curveTo(0.04, 0.08, -0.12, 0.94, 0.5, 1.0) + .closePath() + .fill(BRAND_AXIS) + .margin(DocumentInsets.bottom(16))) .addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath") .addPath(path -> path .name("Ribbon") diff --git a/examples/src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java b/examples/src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java index 26f0e4a07..1b745ba3f 100644 --- a/examples/src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java +++ b/examples/src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java @@ -2,9 +2,7 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; -import com.demcha.compose.document.dsl.LayerStackBuilder; import com.demcha.compose.document.dsl.ParagraphBuilder; -import com.demcha.compose.document.dsl.PathBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerAlign; @@ -123,9 +121,7 @@ public static Path generate() throws Exception { page.addRow(row -> { row.spacing(10).evenWeights().margin(DocumentInsets.bottom(10)); for (String name : chunk) { - // Rows host sections; the fixed-size card rides inside one. - row.addSection("Tile" + name.replace('-', '_'), - s -> s.add(card(name))); + row.add(card(name)); } // Pad the last row so its cells line up with the full rows. for (int filler = chunk.size(); filler < COLUMNS; filler++) { @@ -149,33 +145,20 @@ private static DocumentNode card(String name) { .roundedRect(CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS) .fillColor(CARD_FILL) .stroke(DocumentStroke.of(CARD_BORDER, 0.8)) - .position(iconStack(name, id), 0, -PLAQUE_HEIGHT / 2.0, LayerAlign.CENTER) + .position(iconStack(name), 0, -PLAQUE_HEIGHT / 2.0, LayerAlign.CENTER) .bottomCenter(plaque(name, id)) .build(); } /** - * Builds the icon as a standalone layer stack whose box is exactly the - * icon's contain-fit size, so the card's CENTER anchor lands true. - * (The flow-level {@code addSvgIcon(...)} sugar targets flows; a card - * layer needs the node form of the same composition.) + * Builds the icon as a standalone node via {@link SvgIcon#node(double)} + * — its box is exactly the icon's contain-fit size, so the card's + * CENTER anchor lands true. */ - private static DocumentNode iconStack(String name, String id) { + private static DocumentNode iconStack(String name) { SvgIcon icon = loadIcon(name); double width = Math.min(ICON_BOX, ICON_BOX * icon.aspectRatio()); - double height = width / icon.aspectRatio(); - LayerStackBuilder stack = new LayerStackBuilder().name("Icon" + id); - for (int i = 0; i < icon.layers().size(); i++) { - SvgIcon.Layer layer = icon.layers().get(i); - stack.layer(new PathBuilder() - .name("SvgLayer" + i) - .size(width, height) - .svg(layer.geometry()) - .fillColor(layer.fill()) - .stroke(layer.stroke()) - .build()); - } - return stack.build(); + return icon.node(width); } /** 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 122e11eaf..af8690c31 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -348,13 +348,13 @@ public static Path generate() throws Exception { 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 - .position(iconNode(icon), 0, -7, LayerAlign.CENTER) // anchor centres the icon's - .bottomCenter(plaque("APPLE")) // own tight box inside the card""", + card.roundedRect(74, 64, 8) // fixed box = the tile + .position(icon.node(34), 0, -7, LayerAlign.CENTER) // icon.node keeps its tight + .bottomCenter(plaque("APPLE")) // box, so CENTER lands true""", demo -> demo.addRow(r -> { r.spacing(8).evenWeights(); for (String[] entry : CATALOG_ICONS) { - r.addSection("Tile" + entry[1], s -> s.add(iconCard(entry[0], entry[1]))); + r.add(iconCard(entry[0], entry[1])); } })); @@ -499,34 +499,21 @@ private static SvgIcon catalogIcon(String name) { } /** - * Mini tile for the icon row: fixed rounded card, the icon centred in the - * body as a tight-box layer stack, a label plaque across the bottom. The - * stack keeps the icon's exact contain-fit size — that is what makes the - * card's CENTER anchor land true. + * Mini tile for the icon row: fixed rounded card, the icon centred in + * the body via {@link SvgIcon#node(double)} (its tight box is what makes + * the card's CENTER anchor land true), a label plaque across the bottom. */ private static com.demcha.compose.document.node.DocumentNode iconCard(String name, String label) { SvgIcon icon = catalogIcon(name); double box = 34; double width = Math.min(box, box * icon.aspectRatio()); - double height = width / icon.aspectRatio(); - var stack = new com.demcha.compose.document.dsl.LayerStackBuilder().name("Icon" + label); - for (int i = 0; i < icon.layers().size(); i++) { - SvgIcon.Layer layer = icon.layers().get(i); - stack.layer(new com.demcha.compose.document.dsl.PathBuilder() - .name("SvgLayer" + i) - .size(width, height) - .svg(layer.geometry()) - .fillColor(layer.fill()) - .stroke(layer.stroke()) - .build()); - } double plaqueHeight = 14; return new com.demcha.compose.document.dsl.ShapeContainerBuilder() .name("IconCard" + label) .roundedRect(74, 64, 8) .fillColor(DocumentColor.rgb(248, 249, 251)) .stroke(DocumentStroke.of(DocumentColor.rgb(228, 231, 236), 0.8)) - .position(stack.build(), 0, -plaqueHeight / 2.0, + .position(icon.node(width), 0, -plaqueHeight / 2.0, com.demcha.compose.document.node.LayerAlign.CENTER) .bottomCenter(new com.demcha.compose.document.dsl.ShapeContainerBuilder() .name("Plaque" + label) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java index b127b0a97..9ce9ae7e8 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.PathFragmentPayload; import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; import java.io.IOException; @@ -12,6 +13,11 @@ * Renders fixed vector-path fragments with native PDF line and cubic-Bézier * operators — curves stay smooth at any zoom level. * + *

Gradient fills clip to the path and paint a native shading; gradient + * strokes set a shading-pattern stroking colour (pattern type 2) so the + * outline itself carries the gradient. Flat-colour paths bypass both and + * take the exact pre-gradient code path, byte for byte.

+ * * @author Artem Demchyshyn * @since 1.8.0 */ @@ -41,8 +47,51 @@ public void render(PlacedFragment fragment, float y = (float) fragment.y(); float width = (float) fragment.width(); float height = (float) fragment.height(); - PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), - payload.dashPattern(), - s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments())); + + if (payload.fillPaint() == null && payload.strokePaint() == null) { + PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), + payload.dashPattern(), + s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments())); + return; + } + + // Gradient route: fill and stroke are separate passes because each + // may independently be a flat colour or a shading. + stream.saveGraphicsState(); + try { + if (payload.fillPaint() != null) { + // Clip in a nested state so the clip never leaks into the + // stroke pass (mirrors the shape handler). + stream.saveGraphicsState(); + try { + PdfShapeGeometry.addPathSegments(stream, x, y, width, height, payload.segments()); + stream.clip(); + stream.shadingFill(PdfShadingSupport.build(payload.fillPaint(), x, y, width, height)); + } finally { + stream.restoreGraphicsState(); + } + } else if (payload.fillColor() != null) { + PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), null, null, + s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments())); + } + + boolean hasStrokeWidth = payload.stroke() != null && payload.stroke().width() > 0; + if (payload.strokePaint() != null && hasStrokeWidth) { + PDResources resources = environment.document() + .getPage(fragment.pageIndex()).getResources(); + stream.setStrokingColor(PdfShadingSupport.strokePattern( + payload.strokePaint(), resources, x, y, width, height)); + stream.setLineWidth((float) payload.stroke().width()); + PdfShapeGeometry.applyDashPattern(stream, payload.dashPattern()); + PdfShapeGeometry.addPathSegments(stream, x, y, width, height, payload.segments()); + stream.stroke(); + } else if (hasStrokeWidth && payload.stroke().strokeColor() != null) { + PdfShapeGeometry.fillAndStrokePath(stream, null, payload.stroke(), + payload.dashPattern(), + s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments())); + } + } finally { + stream.restoreGraphicsState(); + } } } diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShadingSupport.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShadingSupport.java index 3b32b7f71..fabee258d 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShadingSupport.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShadingSupport.java @@ -2,10 +2,14 @@ import com.demcha.compose.document.style.DocumentPaint; import org.apache.pdfbox.cos.*; +import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.function.PDFunction; import org.apache.pdfbox.pdmodel.common.function.PDFunctionType2; import org.apache.pdfbox.pdmodel.common.function.PDFunctionType3; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.graphics.color.PDPattern; +import org.apache.pdfbox.pdmodel.graphics.pattern.PDShadingPattern; import org.apache.pdfbox.pdmodel.graphics.shading.PDShading; import org.apache.pdfbox.pdmodel.graphics.shading.PDShadingType2; import org.apache.pdfbox.pdmodel.graphics.shading.PDShadingType3; @@ -22,8 +26,12 @@ * the paint's angle (0° = left→right, 90° = bottom→top), long enough to cover * the whole box; a {@link DocumentPaint.Radial} maps to {@code /ShadingType 3} * centred at the paint's normalized centre with a radius reaching the farthest - * box corner. Two stops become one exponential function; more stops become a - * stitching function over evenly encoded sub-intervals.

+ * box corner. {@link DocumentPaint.LinearAxis} carries explicit normalized + * endpoints and {@link DocumentPaint.RadialCircle} an explicit centre plus + * radius (a fraction of the box width); both translate verbatim — that is the + * exact-extent path SVG gradients ride in on. Two stops become one + * exponential function; more stops become a stitching function over evenly + * encoded sub-intervals.

* * @author Artem Demchyshyn * @since 1.8.0 @@ -52,10 +60,95 @@ static PDShading build(DocumentPaint paint, float x, float y, float width, float if (paint instanceof DocumentPaint.Radial radial) { return radial(radial, x, y, width, height); } + if (paint instanceof DocumentPaint.LinearAxis axis) { + return axialFromEndpoints(axis, x, y, width, height); + } + if (paint instanceof DocumentPaint.RadialCircle circle) { + return radialFromCircle(circle, x, y, width, height); + } throw new IllegalArgumentException( "solid paints are normalised before emission and never reach the shading path"); } + /** + * Wraps the shading for a gradient paint in a PDF shading pattern + * (pattern type 2) registered on the page resources, returning the + * pattern colour to use as a stroking colour. Pattern space is the + * default page space, which matches the absolute coordinates + * {@link #build} already emits — no pattern matrix needed. + * + * @param paint gradient paint + * @param resources page resources the pattern registers on + * @param x box left, page coordinates + * @param y box bottom, page coordinates + * @param width box width + * @param height box height + * @return pattern colour for {@code setStrokingColor} + */ + static PDColor strokePattern(DocumentPaint paint, PDResources resources, + float x, float y, float width, float height) { + PDShadingPattern pattern = new PDShadingPattern(); + PDShading shading = build(paint, x, y, width, height); + // Inline the whole shading subtree (dict, function, stitching + // sub-functions): nested free-standing dicts would be promoted to + // indirect objects the writer never emits, leaving /Shading and + // /Function dangling as null references after reload. + inlineDeep(shading.getCOSObject()); + pattern.setShading(shading); + COSName name = resources.add(pattern); + return new PDColor(name, new PDPattern(null)); + } + + private static void inlineDeep(COSBase base) { + if (base instanceof COSDictionary dict) { + dict.setDirect(true); + for (COSBase value : dict.getValues()) { + inlineDeep(value); + } + } else if (base instanceof COSArray array) { + array.setDirect(true); + for (COSBase item : array) { + inlineDeep(item); + } + } + } + + private static PDShading axialFromEndpoints(DocumentPaint.LinearAxis axis, + float x, float y, float width, float height) { + PDShadingType2 shading = new PDShadingType2(new COSDictionary()); + shading.setShadingType(PDShading.SHADING_TYPE2); + shading.setColorSpace(PDDeviceRGB.INSTANCE); + COSArray coords = new COSArray(); + coords.add(new COSFloat((float) (x + axis.x0() * width))); + coords.add(new COSFloat((float) (y + axis.y0() * height))); + coords.add(new COSFloat((float) (x + axis.x1() * width))); + coords.add(new COSFloat((float) (y + axis.y1() * height))); + shading.setCoords(coords); + shading.setFunction(stopsFunction(axis.stops())); + shading.setExtend(bothExtend()); + return shading; + } + + private static PDShading radialFromCircle(DocumentPaint.RadialCircle circle, + float x, float y, float width, float height) { + // Radius scales by width per the RadialCircle contract; with the + // aspect-preserving icon frame this keeps circles circular. + PDShadingType3 shading = new PDShadingType3(new COSDictionary()); + shading.setShadingType(PDShading.SHADING_TYPE3); + shading.setColorSpace(PDDeviceRGB.INSTANCE); + COSArray coords = new COSArray(); + coords.add(new COSFloat((float) (x + circle.cx() * width))); + coords.add(new COSFloat((float) (y + circle.cy() * height))); + coords.add(new COSFloat(0f)); + coords.add(new COSFloat((float) (x + circle.cx() * width))); + coords.add(new COSFloat((float) (y + circle.cy() * height))); + coords.add(new COSFloat((float) (circle.r() * width))); + shading.setCoords(coords); + shading.setFunction(stopsFunction(circle.stops())); + shading.setExtend(bothExtend()); + return shading; + } + private static PDShading axial(DocumentPaint.Linear linear, float x, float y, float width, float height) { double radians = Math.toRadians(linear.angleDegrees()); diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index 713e3c134..8f7a23bd9 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -556,19 +556,7 @@ public T addPath(Consumer spec) { @com.demcha.compose.document.api.Beta public T addSvgIcon(com.demcha.compose.document.svg.SvgIcon icon, double width) { Objects.requireNonNull(icon, "icon"); - double height = width / icon.aspectRatio(); - return addLayerStack(stack -> { - for (int i = 0; i < icon.layers().size(); i++) { - com.demcha.compose.document.svg.SvgIcon.Layer layer = icon.layers().get(i); - stack.layer(new PathBuilder() - .name("SvgLayer" + i) - .size(width, height) - .svg(layer.geometry()) - .fillColor(layer.fill()) - .stroke(layer.stroke()) - .build()); - } - }); + return add(icon.node(width)); } /** diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java index 52a338511..6abdf682a 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -5,6 +5,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.svg.SvgPath; @@ -44,7 +45,9 @@ public final class PathBuilder { private double width; private double height; private DocumentColor fillColor; + private DocumentPaint fillPaint; private DocumentStroke stroke; + private DocumentPaint strokePaint; private DocumentInsets padding = DocumentInsets.zero(); private DocumentInsets margin = DocumentInsets.zero(); private DocumentDashPattern dashPattern = DocumentDashPattern.NONE; @@ -193,6 +196,19 @@ public PathBuilder fillColor(Color fillColor) { return this; } + /** + * Sets a gradient fill — wins over {@link #fillColor(DocumentColor)}. + * A {@link DocumentPaint.Solid} behaves exactly like the flat colour. + * + * @param fillPaint gradient or solid paint, or {@code null} to clear + * @return this builder + * @since 1.8.0 + */ + public PathBuilder fill(DocumentPaint fillPaint) { + this.fillPaint = fillPaint; + return this; + } + /** * Sets the outline stroke. * @@ -204,6 +220,20 @@ public PathBuilder stroke(DocumentStroke stroke) { return this; } + /** + * Paints the outline with a gradient. The {@link #stroke(DocumentStroke)} + * still supplies the width (and the flat-colour fallback for backends + * that cannot render gradients), so set both. + * + * @param strokePaint gradient paint for the outline, or {@code null} to clear + * @return this builder + * @since 1.8.0 + */ + public PathBuilder strokePaint(DocumentPaint strokePaint) { + this.strokePaint = strokePaint; + return this; + } + /** * Makes the stroke dashed using alternating on/off lengths in points * (the same contract as {@code LineBuilder.dashed}). Affects only the @@ -263,6 +293,7 @@ public PathBuilder margin(DocumentInsets margin) { * added, or the box is not positive */ public PathNode build() { - return new PathNode(name, width, height, segments, fillColor, stroke, padding, margin, dashPattern); + return new PathNode(name, width, height, segments, fillColor, fillPaint, + stroke, strokePaint, padding, margin, dashPattern); } } diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index b4a193617..6c590d186 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -58,6 +58,11 @@ private static boolean isAllowedRowChild(DocumentNode child) { || child instanceof BarcodeNode || child instanceof SectionNode || child instanceof ContainerNode + // ShapeContainerNode is the same shape of thing as + // LayerStackNode below: a fixed atomic box of overlay layers + // (it reuses LayerStackNode.Layer), so it slots into a row + // column without competing for the horizontal band. + || child instanceof ShapeContainerNode // LayerStackNode is an atomic overlay composite: its layers // share the same bounding box and do not compete with the // parent row's horizontal band, so it is safe to drop into a diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java index 2285539fd..2dc33d358 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java @@ -3,7 +3,10 @@ import com.demcha.compose.document.layout.*; import com.demcha.compose.document.layout.payloads.PathFragmentPayload; import com.demcha.compose.document.node.PathNode; +import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.DocumentStroke; +import java.awt.Color; import java.util.List; import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; @@ -52,6 +55,26 @@ public List emitFragments(PreparedNode prepared, if (width <= EPS || height <= EPS) { return List.of(); } + // Solid paints normalise to flat colours so the render path (and its + // byte output) is identical to plain fillColor/stroke; only true + // gradients travel as paints. Mirrors ShapeDefinition. + Color fill; + DocumentPaint fillGradient = null; + if (node.fillPaint() instanceof DocumentPaint.Solid solid) { + fill = solid.color().color(); + } else if (node.fillPaint() != null) { + fillGradient = node.fillPaint(); + fill = null; + } else { + fill = node.fillColor() == null ? null : node.fillColor().color(); + } + DocumentStroke stroke = node.stroke(); + DocumentPaint strokeGradient = null; + if (node.strokePaint() instanceof DocumentPaint.Solid solid) { + stroke = DocumentStroke.of(solid.color(), stroke.width()); + } else if (node.strokePaint() != null) { + strokeGradient = node.strokePaint(); + } return List.of(new LayoutFragment( placement.path(), 0, @@ -61,8 +84,10 @@ public List emitFragments(PreparedNode prepared, height, new PathFragmentPayload( node.segments(), - node.fillColor() == null ? null : node.fillColor().color(), - toStroke(node.stroke()), + fill, + fillGradient, + toStroke(stroke), + strokeGradient, null, null, node.dashPattern()))); diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java index 14b5a660a..902b96e0a 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkOptions; import com.demcha.compose.document.style.DocumentDashPattern; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.engine.components.content.shape.Stroke; @@ -16,9 +17,16 @@ * scaled to the placed fragment's size by the render handler, which emits * native PDF line and cubic-Bézier operators. * + *

Solid paints never travel here — the definition normalises them into + * {@code fillColor} / the stroke colour, so only true gradients reach the + * handler and flat-colour output stays byte-identical.

+ * * @param segments normalized path segments, starting with a move-to * @param fillColor optional fill color (non-zero winding rule) + * @param fillPaint optional gradient fill; wins over {@code fillColor} * @param stroke optional stroke + * @param strokePaint optional gradient stroke paint; the stroke still + * supplies the width * @param linkOptions optional fragment-level link metadata * @param bookmarkOptions optional fragment-level bookmark metadata * @param dashPattern dash pattern for the stroke; @@ -29,7 +37,9 @@ public record PathFragmentPayload( List segments, Color fillColor, + DocumentPaint fillPaint, Stroke stroke, + DocumentPaint strokePaint, DocumentLinkOptions linkOptions, DocumentBookmarkOptions bookmarkOptions, DocumentDashPattern dashPattern diff --git a/src/main/java/com/demcha/compose/document/node/PathNode.java b/src/main/java/com/demcha/compose/document/node/PathNode.java index 8578bb839..8f8e29bd5 100644 --- a/src/main/java/com/demcha/compose/document/node/PathNode.java +++ b/src/main/java/com/demcha/compose/document/node/PathNode.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; @@ -22,13 +23,21 @@ * curves stay perfectly smooth at any zoom level instead of being * tessellated into straight pieces.

* - * @param name node name used in snapshots and layout graph paths - * @param width resolved box width - * @param height resolved box height - * @param segments normalized path segments; must start with a - * {@link DocumentPathSegment.MoveTo} - * @param fillColor optional fill colour (non-zero winding rule) - * @param stroke optional outline stroke + *

Fills and strokes each take an optional gradient: {@code fillPaint} + * wins over {@code fillColor}, and {@code strokePaint} paints the outline + * through a PDF shading pattern while {@code stroke} keeps supplying the + * width (and the flat-colour fallback for backends without gradients).

+ * + * @param name node name used in snapshots and layout graph paths + * @param width resolved box width + * @param height resolved box height + * @param segments normalized path segments; must start with a + * {@link DocumentPathSegment.MoveTo} + * @param fillColor optional fill colour (non-zero winding rule) + * @param fillPaint optional gradient fill; wins over {@code fillColor} + * @param stroke optional outline stroke + * @param strokePaint optional gradient for the outline; requires + * {@code stroke} for the width * @param padding inner padding * @param margin outer margin * @param dashPattern dash pattern for the stroke; defaults to @@ -42,13 +51,16 @@ public record PathNode( double height, List segments, DocumentColor fillColor, + DocumentPaint fillPaint, DocumentStroke stroke, + DocumentPaint strokePaint, DocumentInsets padding, DocumentInsets margin, DocumentDashPattern dashPattern ) implements DocumentNode { /** - * Validates dimensions and the segment list; copy-protects the segments. + * Validates dimensions, the segment list, and the paint pairing; + * copy-protects the segments. */ public PathNode { name = name == null ? "" : name; @@ -72,6 +84,37 @@ public record PathNode( if (height <= 0 || Double.isNaN(height) || Double.isInfinite(height)) { throw new IllegalArgumentException("height must be finite and positive: " + height); } + if (strokePaint != null && stroke == null) { + throw new IllegalArgumentException( + "strokePaint needs a stroke to define the outline width — set stroke(...) too"); + } + } + + /** + * Compatibility constructor without paints — flat fill colour and flat + * stroke only, the common authoring case. + * + * @param name node name + * @param width resolved box width + * @param height resolved box height + * @param segments normalized path segments + * @param fillColor optional fill colour + * @param stroke optional outline stroke + * @param padding inner padding + * @param margin outer margin + * @param dashPattern dash pattern for the stroke + */ + public PathNode(String name, + double width, + double height, + List segments, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentInsets padding, + DocumentInsets margin, + DocumentDashPattern dashPattern) { + this(name, width, height, segments, fillColor, null, stroke, null, + padding, margin, dashPattern); } @Override diff --git a/src/main/java/com/demcha/compose/document/style/DocumentPaint.java b/src/main/java/com/demcha/compose/document/style/DocumentPaint.java index 034629a9c..23a57a86d 100644 --- a/src/main/java/com/demcha/compose/document/style/DocumentPaint.java +++ b/src/main/java/com/demcha/compose/document/style/DocumentPaint.java @@ -17,7 +17,9 @@ * @author Artem Demchyshyn * @since 1.8.0 */ -public sealed interface DocumentPaint permits DocumentPaint.Solid, DocumentPaint.Linear, DocumentPaint.Radial { +public sealed interface DocumentPaint + permits DocumentPaint.Solid, DocumentPaint.Linear, DocumentPaint.Radial, + DocumentPaint.LinearAxis, DocumentPaint.RadialCircle { /** * Representative colour for backends that cannot render gradients. @@ -115,6 +117,92 @@ public DocumentColor primaryColor() { } } + /** + * Linear gradient along an explicit axis. Endpoints are normalized to the + * painted box ({@code 0,0} = bottom-left, {@code 1,1} = top-right, y up) + * and may lie outside {@code [0,1]} — an SVG gradient whose axis starts + * beyond a small shape's bounds maps here verbatim. The axis extent is + * exact: colour runs from the first stop at {@code (x0, y0)} to the last + * stop at {@code (x1, y1)} and clamps beyond (pad spread). + * + * @param stops ordered colour stops, offsets in [0,1]; at least two + * @param x0 axis start x, normalized to the box width + * @param y0 axis start y, normalized to the box height + * @param x1 axis end x, normalized to the box width + * @param y1 axis end y, normalized to the box height + * @since 1.8.0 + */ + record LinearAxis(List stops, double x0, double y0, double x1, double y1) + implements DocumentPaint { + /** + * Copy-protects stops and validates the axis. + */ + public LinearAxis { + Objects.requireNonNull(stops, "stops"); + stops = List.copyOf(stops); + if (stops.size() < 2) { + throw new IllegalArgumentException("linear gradient needs at least two stops"); + } + requireFinite(x0, "x0"); + requireFinite(y0, "y0"); + requireFinite(x1, "x1"); + requireFinite(y1, "y1"); + if (x0 == x1 && y0 == y1) { + throw new IllegalArgumentException( + "gradient axis is degenerate: both endpoints are (" + x0 + ", " + y0 + ")"); + } + } + + @Override + public DocumentColor primaryColor() { + return stops.get(0).color(); + } + } + + /** + * Radial gradient with an explicit radius. Centre coordinates are + * normalized to the painted box (y up, may lie outside {@code [0,1]}); + * the radius is a fraction of the box width, so a circle stays + * a circle when the box preserves the source's aspect ratio (the SVG + * icon frame contract). Colour clamps beyond the last stop (pad spread). + * + * @param stops ordered colour stops, offsets in [0,1]; at least two + * @param cx centre x, normalized to the box width + * @param cy centre y, normalized to the box height + * @param r radius as a fraction of the box width; positive + * @since 1.8.0 + */ + record RadialCircle(List stops, double cx, double cy, double r) + implements DocumentPaint { + /** + * Copy-protects stops and validates the circle. + */ + public RadialCircle { + Objects.requireNonNull(stops, "stops"); + stops = List.copyOf(stops); + if (stops.size() < 2) { + throw new IllegalArgumentException("radial gradient needs at least two stops"); + } + requireFinite(cx, "cx"); + requireFinite(cy, "cy"); + requireFinite(r, "r"); + if (r <= 0) { + throw new IllegalArgumentException("radial gradient radius must be positive: " + r); + } + } + + @Override + public DocumentColor primaryColor() { + return stops.get(0).color(); + } + } + + private static void requireFinite(double value, String what) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + throw new IllegalArgumentException(what + " must be finite: " + value); + } + } + /** * One gradient colour stop. * diff --git a/src/main/java/com/demcha/compose/document/svg/SvgGradients.java b/src/main/java/com/demcha/compose/document/svg/SvgGradients.java new file mode 100644 index 000000000..ddc8640a5 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/svg/SvgGradients.java @@ -0,0 +1,349 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentPaint; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Gradient side of the icon reader: collects {@code } / + * {@code } definitions and resolves a {@code url(#id)} + * reference into a {@link DocumentPaint} in the icon's normalized space. + * + *

Coordinate mapping follows the SVG gradient units: {@code + * objectBoundingBox} (the default) maps endpoints through the referencing + * shape's normalized bounding box, {@code userSpaceOnUse} pushes them + * through the element's accumulated affine and the icon frame — the same + * pipeline the geometry itself rides, so a gradient lands exactly where the + * browser puts it. {@code gradientTransform} (the affine subset: + * translate / scale / rotate / matrix) applies to the endpoints first. + * One level of {@code href} / {@code xlink:href} indirection supplies + * stops for the split-definition style Inkscape and Figma emit.

+ * + *

Out of PDF reach and loudly refused: focal radials ({@code fx} / + * {@code fy}), {@code spreadMethod} other than pad, and translucent stops + * ({@code stop-opacity}).

+ */ +final class SvgGradients { + + private SvgGradients() { + } + + /** + * Collects every gradient definition in the document by id, wherever it + * sits (the {@code } subtree is not walked for geometry, but + * gradients may also appear inline). + */ + static Map collect(Element root) { + Map byId = new HashMap<>(); + NodeList all = root.getElementsByTagName("*"); + for (int i = 0; i < all.getLength(); i++) { + if (all.item(i) instanceof Element element) { + String name = localName(element); + if (("linearGradient".equals(name) || "radialGradient".equals(name)) + && !element.getAttribute("id").isEmpty()) { + byId.put(element.getAttribute("id"), element); + } + } + } + return byId; + } + + /** + * Extracts the gradient id from a {@code url(#id)} paint value, or + * returns {@code null} if the value is not a url reference. + */ + static String urlId(String paintValue) { + String v = paintValue.trim(); + if (!v.startsWith("url(") || !v.endsWith(")")) { + return null; + } + String ref = v.substring(4, v.length() - 1).trim(); + if (ref.startsWith("'") || ref.startsWith("\"")) { + ref = ref.substring(1, ref.length() - 1).trim(); + } + if (!ref.startsWith("#")) { + throw new IllegalArgumentException( + "unsupported paint reference '" + paintValue + "' — only url(#id) gradients resolve"); + } + return ref.substring(1); + } + + /** + * Resolves a gradient element into a paint in normalized icon space. + * + * @param gradient the {@code } / {@code } + * @param all every gradient by id (for one-level href stops) + * @param elementMatrix accumulated affine of the referencing element + * @param box icon frame {@code [minX, minY, width, height]} + * @param geometry referencing shape's normalized geometry (bounding + * box source for objectBoundingBox units) + * @return resolved paint; solid when the axis degenerates per SVG rules + */ + static DocumentPaint paint(Element gradient, Map all, + double[] elementMatrix, double[] box, SvgPath geometry) { + String spread = gradient.getAttribute("spreadMethod").trim(); + if (!spread.isEmpty() && !spread.equals("pad")) { + throw new IllegalArgumentException( + "unsupported spreadMethod '" + spread + "' — only pad maps to PDF shadings"); + } + List stops = stops(gradient, all); + boolean userSpace = "userSpaceOnUse".equals(gradient.getAttribute("gradientUnits").trim()); + double[] gt = SvgIconReader.compose(SvgIconReader.identity(), + gradient.getAttribute("gradientTransform")); + + if ("linearGradient".equals(localName(gradient))) { + double[] p0 = point(gradient, "x1", "y1", 0.0, 0.0, userSpace, box); + double[] p1 = point(gradient, "x2", "y2", 1.0, 0.0, userSpace, box); + p0 = apply(gt, p0); + p1 = apply(gt, p1); + double[] n0 = normalize(p0, userSpace, elementMatrix, box, geometry); + double[] n1 = normalize(p1, userSpace, elementMatrix, box, geometry); + if (n0[0] == n1[0] && n0[1] == n1[1]) { + // SVG: a degenerate axis paints the last stop as a flat fill. + return DocumentPaint.solid(stops.get(stops.size() - 1).color()); + } + return new DocumentPaint.LinearAxis(stops, n0[0], n0[1], n1[0], n1[1]); + } + + if (!gradient.getAttribute("fx").isEmpty() || !gradient.getAttribute("fy").isEmpty()) { + throw new IllegalArgumentException( + "focal radial gradients (fx/fy) have no PDF analogue and are not supported"); + } + double[] centre = point(gradient, "cx", "cy", 0.5, 0.5, userSpace, box); + double r = length(gradient, "r", 0.5, userSpace, box); + centre = apply(gt, centre); + r = r * scaleOf(gt); + double[] n = normalize(centre, userSpace, elementMatrix, box, geometry); + double radius = normalizeRadius(r, userSpace, elementMatrix, box, geometry); + return new DocumentPaint.RadialCircle(stops, n[0], n[1], radius); + } + + // ------------------------------------------------------------------ + // Stops + // ------------------------------------------------------------------ + + private static List stops(Element gradient, Map all) { + List stops = readOwnStops(gradient); + if (stops.isEmpty()) { + Element target = href(gradient, all); + if (target != null) { + stops = readOwnStops(target); + } + } + if (stops.isEmpty()) { + throw new IllegalArgumentException("gradient '" + gradient.getAttribute("id") + + "' carries no elements (one href hop searched)"); + } + if (stops.size() == 1) { + // Single stop = flat colour per SVG; duplicate so the paint + // contract (two stops minimum) holds. + stops = List.of(stops.get(0), + new DocumentPaint.Stop(1.0, stops.get(0).color())); + } + return stops; + } + + private static List readOwnStops(Element gradient) { + List stops = new ArrayList<>(); + NodeList children = gradient.getChildNodes(); + double previous = 0.0; + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (!(child instanceof Element stop) || !"stop".equals(localName(stop))) { + continue; + } + double offset = fraction(attrOrStyle(stop, "offset"), 0.0); + // SVG clamps offsets into [0,1] and forces them non-decreasing. + offset = Math.max(previous, Math.min(1.0, Math.max(0.0, offset))); + previous = offset; + String opacity = attrOrStyle(stop, "stop-opacity"); + if (opacity != null && fraction(opacity, 1.0) < 1.0) { + throw new IllegalArgumentException( + "gradient stop-opacity is not supported — flatten transparency before import"); + } + String colorValue = attrOrStyle(stop, "stop-color"); + DocumentColor color = colorValue == null + ? DocumentColor.rgb(0, 0, 0) + : SvgIconReader.color(colorValue, DocumentColor.rgb(0, 0, 0)); + if (color == null) { + throw new IllegalArgumentException( + "stop-color 'none' is not a paintable gradient stop"); + } + stops.add(new DocumentPaint.Stop(offset, color)); + } + return stops; + } + + private static Element href(Element gradient, Map all) { + String ref = gradient.getAttribute("href").trim(); + if (ref.isEmpty()) { + ref = gradient.getAttribute("xlink:href").trim(); + } + if (ref.startsWith("#")) { + return all.get(ref.substring(1)); + } + return null; + } + + // ------------------------------------------------------------------ + // Coordinates + // ------------------------------------------------------------------ + + private static double[] point(Element gradient, String xAttr, String yAttr, + double defaultX, double defaultY, + boolean userSpace, double[] box) { + return new double[]{ + coordinate(gradient.getAttribute(xAttr), defaultX, userSpace, box[2]), + coordinate(gradient.getAttribute(yAttr), defaultY, userSpace, box[3])}; + } + + /** + * One gradient coordinate: bare numbers are user units (userSpaceOnUse) + * or bounding-box fractions; percentages are fractions in both unit + * systems (of the viewport dimension in user space). + */ + private static double coordinate(String value, double defaultFraction, + boolean userSpace, double viewportSize) { + String v = value == null ? "" : value.trim(); + if (v.isEmpty()) { + return userSpace ? defaultFraction * viewportSize : defaultFraction; + } + if (v.endsWith("%")) { + double fraction = Double.parseDouble(v.substring(0, v.length() - 1)) / 100.0; + return userSpace ? fraction * viewportSize : fraction; + } + return Double.parseDouble(v); + } + + private static double length(Element gradient, String attr, double defaultFraction, + boolean userSpace, double[] box) { + // Per SVG, percentage lengths resolve against the normalized diagonal. + double diagonal = Math.sqrt((box[2] * box[2] + box[3] * box[3]) / 2.0); + String v = gradient.getAttribute(attr).trim(); + if (v.isEmpty()) { + return userSpace ? defaultFraction * diagonal : defaultFraction; + } + if (v.endsWith("%")) { + double fraction = Double.parseDouble(v.substring(0, v.length() - 1)) / 100.0; + return userSpace ? fraction * diagonal : fraction; + } + return Double.parseDouble(v); + } + + /** + * Maps a gradient point into normalized icon space: user-space points + * ride the element affine and the icon frame (y flipped); bounding-box + * points interpolate the shape's normalized bbox (SVG bbox y runs down). + */ + private static double[] normalize(double[] p, boolean userSpace, + double[] elementMatrix, double[] box, SvgPath geometry) { + if (userSpace) { + double[] u = apply(elementMatrix, p); + return new double[]{ + (u[0] - box[0]) / box[2], + (box[1] + box[3] - u[1]) / box[3]}; + } + double[] bounds = bounds(geometry); + double top = bounds[1] + bounds[3]; + return new double[]{ + bounds[0] + p[0] * bounds[2], + top - p[1] * bounds[3]}; + } + + private static double normalizeRadius(double r, boolean userSpace, + double[] elementMatrix, double[] box, SvgPath geometry) { + if (userSpace) { + return r * scaleOf(elementMatrix) / box[2]; + } + // objectBoundingBox: r is a fraction of the bbox "diagonal mean" + // sqrt((w² + h²) / 2); convert through user units into a fraction of + // the frame width (the RadialCircle contract). + double[] bounds = bounds(geometry); + double bw = bounds[2]; + double bh = bounds[3] * (box[3] / box[2]); + return r * Math.sqrt((bw * bw + bh * bh) / 2.0); + } + + /** Normalized bounding box of the geometry: {@code [x, y, w, h]}, y up. */ + private static double[] bounds(SvgPath geometry) { + double minX = Double.POSITIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (com.demcha.compose.document.style.DocumentPathSegment segment : geometry.segments()) { + for (double[] p : points(segment)) { + minX = Math.min(minX, p[0]); + minY = Math.min(minY, p[1]); + maxX = Math.max(maxX, p[0]); + maxY = Math.max(maxY, p[1]); + } + } + return new double[]{minX, minY, Math.max(1e-9, maxX - minX), Math.max(1e-9, maxY - minY)}; + } + + private static double[][] points(com.demcha.compose.document.style.DocumentPathSegment segment) { + if (segment instanceof com.demcha.compose.document.style.DocumentPathSegment.MoveTo m) { + return new double[][]{{m.x(), m.y()}}; + } + if (segment instanceof com.demcha.compose.document.style.DocumentPathSegment.LineTo l) { + return new double[][]{{l.x(), l.y()}}; + } + if (segment instanceof com.demcha.compose.document.style.DocumentPathSegment.CubicTo c) { + return new double[][]{ + {c.control1X(), c.control1Y()}, + {c.control2X(), c.control2Y()}, + {c.x(), c.y()}}; + } + return new double[0][]; + } + + /** Uniform scale factor of an affine: sqrt(|det|), exact for similarity maps. */ + private static double scaleOf(double[] m) { + return Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])); + } + + private static double[] apply(double[] m, double[] p) { + return new double[]{ + m[0] * p[0] + m[2] * p[1] + m[4], + m[1] * p[0] + m[3] * p[1] + m[5]}; + } + + private static double fraction(String value, double defaultValue) { + if (value == null || value.isBlank()) { + return defaultValue; + } + String v = value.trim(); + if (v.endsWith("%")) { + return Double.parseDouble(v.substring(0, v.length() - 1)) / 100.0; + } + return Double.parseDouble(v); + } + + private static String attrOrStyle(Element element, String property) { + String attr = element.getAttribute(property).trim(); + if (!attr.isEmpty()) { + return attr; + } + String style = element.getAttribute("style"); + for (String declaration : style.split(";")) { + int colon = declaration.indexOf(':'); + if (colon > 0 && declaration.substring(0, colon).trim().equals(property)) { + return declaration.substring(colon + 1).trim(); + } + } + return null; + } + + private static String localName(Element element) { + String name = element.getNodeName(); + int colon = name.indexOf(':'); + return colon < 0 ? name : name.substring(colon + 1); + } +} diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java index 8c463dffd..a24d33db1 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java @@ -1,13 +1,17 @@ package com.demcha.compose.document.svg; import com.demcha.compose.document.api.Beta; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,25 +22,31 @@ * polygon}, lowered to path data), {@code } nesting with accumulated * {@code transform} attributes ({@code translate} / {@code scale} / * {@code rotate} / {@code matrix} — affine maps are exact on Bézier control - * points), and per-element {@code fill} / {@code stroke} / - * {@code stroke-width} styling with SVG's inheritance and defaults - * (missing {@code fill} paints black, {@code fill="none"} skips the fill). + * points), per-element {@code fill} / {@code stroke} / {@code stroke-width} + * styling with SVG's inheritance and defaults (missing {@code fill} paints + * black, {@code fill="none"} skips the fill), and {@code linearGradient} / + * {@code radialGradient} paints referenced via {@code url(#id)} — on fills + * and strokes alike, rendered as native PDF shadings. * *

Each layer is one {@link SvgPath} with its resolved paint, in document - * order — render them back-to-front. The DSL does exactly that: - * {@code flow.addSvgIcon(icon, 48)} stacks the layers into the page at the + * order — render them back-to-front. {@link #node(double)} packages the + * layers as one ready-to-place node, and the DSL sugar + * {@code flow.addSvgIcon(icon, 48)} drops that node into the page at the * requested width with the icon's own aspect ratio.

* *

Out of scope (deliberately, this is an icon reader, not a browser): - * gradients, CSS stylesheets and classes, text, masks, clip paths, - * filters, {@code } references, nested {@code } viewBoxes (inner - * frames recurse but their coordinates stay in the outer space), and - * animations. The XML reader refuses - * DOCTYPEs, so external-entity tricks cannot reach the file system.

+ * CSS stylesheets and classes, text, masks, clip paths, filters, + * {@code } references, nested {@code } viewBoxes (inner frames + * recurse but their coordinates stay in the outer space), animations, and + * the gradient corners that have no PDF analogue (focal points, + * {@code spreadMethod} other than pad, stop opacity). The XML reader + * refuses DOCTYPEs, so external-entity tricks cannot reach the file + * system.

* *
{@code
  * SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
- * flow.addSvgIcon(logo, 48);
+ * flow.addSvgIcon(logo, 48);          // flow sugar
+ * card.center(logo.node(48));         // node form for layer anchors
  * }
* *

Beta: the SVG surface is new in 1.8.0 and marked {@link Beta} @@ -122,20 +132,76 @@ public double aspectRatio() { return sourceWidth / sourceHeight; } + /** + * Packages the icon as one ready-to-place node: a layer stack of path + * nodes at the given width, height following the icon's aspect ratio. + * The stack's box is exactly the icon box, so it anchors true inside + * {@code ShapeContainer} / {@code LayerStack} nine-point grids — the + * node-form sibling of the {@code addSvgIcon(icon, width)} flow sugar. + * + * @param width target width in points; must be positive + * @return layer stack rendering this icon at {@code width} points + * @throws IllegalArgumentException if {@code width} is not positive + * @since 1.8.0 + */ + public LayerStackNode node(double width) { + if (!(width > 0) || Double.isInfinite(width)) { + throw new IllegalArgumentException("icon width must be finite and positive: " + width); + } + double height = width / aspectRatio(); + List stack = new ArrayList<>(layers.size()); + for (int i = 0; i < layers.size(); i++) { + Layer layer = layers.get(i); + stack.add(new LayerStackNode.Layer(new PathNode( + "SvgLayer" + i, + width, + height, + layer.geometry().segments(), + layer.fill(), + layer.fillPaint(), + layer.stroke(), + layer.strokePaint(), + null, + null, + null))); + } + return new LayerStackNode("SvgIcon", stack, null, null); + } + /** * One drawable layer: normalized geometry plus its resolved paint. + * Gradient paints, when present, win over the flat colours; the flat + * colours stay populated as the degradation target for backends that + * cannot render gradients. * - * @param geometry normalized path geometry (shared icon frame) - * @param fill fill colour, or {@code null} for no fill - * @param stroke outline stroke, or {@code null} for no stroke + * @param geometry normalized path geometry (shared icon frame) + * @param fill fill colour, or {@code null} for no fill + * @param fillPaint gradient fill, or {@code null} for flat / no fill + * @param stroke outline stroke, or {@code null} for no stroke + * @param strokePaint gradient stroke paint, or {@code null} for flat * @since 1.8.0 */ - public record Layer(SvgPath geometry, DocumentColor fill, DocumentStroke stroke) { + public record Layer(SvgPath geometry, + DocumentColor fill, + DocumentPaint fillPaint, + DocumentStroke stroke, + DocumentPaint strokePaint) { /** * Validates the geometry reference. */ public Layer { Objects.requireNonNull(geometry, "geometry"); } + + /** + * Compatibility constructor for flat-colour layers. + * + * @param geometry normalized path geometry + * @param fill fill colour, or {@code null} + * @param stroke outline stroke, or {@code null} + */ + public Layer(SvgPath geometry, DocumentColor fill, DocumentStroke stroke) { + this(geometry, fill, null, stroke, null); + } } } diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java index f5a1ead0e..0bc8b3098 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.svg; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import org.w3c.dom.Document; import org.w3c.dom.Element; @@ -14,15 +15,17 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; /** * Internal DOM walker behind {@link SvgIcon#parse(String)}: secure XML setup * (DOCTYPE refused, so XXE cannot reach the file system), viewBox * resolution, recursive {@code } traversal with affine accumulation and * paint inheritance, shape-to-path lowering (every basic shape becomes - * synthesized path data fed through the one tested parser), and the icon + * synthesized path data fed through the one tested parser), the icon * colour subset ({@code #rgb}, {@code #rrggbb}, {@code rgb(r,g,b)}, - * {@code none}, {@code currentColor} → default ink). + * {@code none}, {@code currentColor} → default ink), and {@code url(#id)} + * gradient references resolved through {@link SvgGradients}. */ final class SvgIconReader { @@ -35,9 +38,12 @@ static SvgIcon read(String svgXml) { throw new IllegalArgumentException("not an SVG document: root element is <" + root.getNodeName() + ">"); } double[] box = viewBox(root); + Map gradients = SvgGradients.collect(root); List layers = new ArrayList<>(); - walk(root, identity(), new Paint(DocumentColor.rgb(0, 0, 0), null, 1.0), box, layers); + walk(root, identity(), + new Paint(new PaintValue(DocumentColor.rgb(0, 0, 0), null), PaintValue.NONE, 1.0), + box, gradients, layers); if (layers.isEmpty()) { throw new IllegalArgumentException("SVG document contains no drawable geometry"); } @@ -104,13 +110,26 @@ private static void requirePositive(double width, double height, String source) // Tree walk // ------------------------------------------------------------------ + /** + * One inheritable paint slot: a flat colour, a gradient element awaiting + * geometry context, or nothing. + */ + private record PaintValue(DocumentColor color, Element gradient) { + static final PaintValue NONE = new PaintValue(null, null); + + boolean visible() { + return color != null || gradient != null; + } + } + /** Inherited paint state: SVG fills default to black, strokes to none. */ - private record Paint(DocumentColor fill, DocumentColor strokeColor, double strokeWidth) { + private record Paint(PaintValue fill, PaintValue stroke, double strokeWidth) { } private static void walk(Element element, double[] transform, Paint inherited, - double[] box, List out) { - Paint paint = stylize(element, inherited); + double[] box, Map gradients, + List out) { + Paint paint = stylize(element, inherited, gradients); double[] matrix = compose(transform, element.getAttribute("transform")); String name = localName(element); @@ -129,12 +148,33 @@ private static void walk(Element element, double[] transform, Paint inherited, }; if (d != null && !d.isBlank()) { - DocumentStroke stroke = paint.strokeColor() == null || paint.strokeWidth() <= 0 - ? null - : DocumentStroke.of(paint.strokeColor(), paint.strokeWidth()); - if (paint.fill() != null || stroke != null) { + boolean strokeVisible = paint.stroke().visible() && paint.strokeWidth() > 0; + if (paint.fill().visible() || strokeVisible) { SvgPath geometry = SvgPath.parseTransformed(d, matrix, box[0], box[1], box[2], box[3]); - out.add(new SvgIcon.Layer(geometry, paint.fill(), stroke)); + + // Gradients resolve here, where the shape's geometry (the + // objectBoundingBox reference) and accumulated affine exist. + // The flat colour keeps the gradient's first stop so backends + // without shadings degrade per the DocumentPaint contract. + DocumentColor fillColor = paint.fill().color(); + DocumentPaint fillPaint = null; + if (paint.fill().gradient() != null) { + fillPaint = SvgGradients.paint(paint.fill().gradient(), gradients, + matrix, box, geometry); + fillColor = fillPaint.primaryColor(); + } + DocumentStroke stroke = null; + DocumentPaint strokePaint = null; + if (strokeVisible) { + if (paint.stroke().gradient() != null) { + strokePaint = SvgGradients.paint(paint.stroke().gradient(), gradients, + matrix, box, geometry); + stroke = DocumentStroke.of(strokePaint.primaryColor(), paint.strokeWidth()); + } else { + stroke = DocumentStroke.of(paint.stroke().color(), paint.strokeWidth()); + } + } + out.add(new SvgIcon.Layer(geometry, fillColor, fillPaint, stroke, strokePaint)); } } @@ -145,7 +185,7 @@ private static void walk(Element element, double[] transform, Paint inherited, for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); if (child instanceof Element childElement) { - walk(childElement, matrix, paint, box, out); + walk(childElement, matrix, paint, box, gradients, out); } } } @@ -155,24 +195,41 @@ private static void walk(Element element, double[] transform, Paint inherited, // Styling // ------------------------------------------------------------------ - private static Paint stylize(Element element, Paint inherited) { - DocumentColor fill = inherited.fill(); - DocumentColor strokeColor = inherited.strokeColor(); + private static Paint stylize(Element element, Paint inherited, Map gradients) { + PaintValue fill = inherited.fill(); + PaintValue stroke = inherited.stroke(); double strokeWidth = inherited.strokeWidth(); String fillAttr = attrOrStyle(element, "fill"); if (fillAttr != null) { - fill = color(fillAttr, inherited.fill()); + fill = paintValue(fillAttr, inherited.fill(), gradients); } String strokeAttr = attrOrStyle(element, "stroke"); if (strokeAttr != null) { - strokeColor = color(strokeAttr, inherited.strokeColor()); + stroke = paintValue(strokeAttr, inherited.stroke(), gradients); } String widthAttr = attrOrStyle(element, "stroke-width"); if (widthAttr != null) { strokeWidth = Double.parseDouble(widthAttr.replace("px", "").trim()); } - return new Paint(fill, strokeColor, strokeWidth); + return new Paint(fill, stroke, strokeWidth); + } + + /** Resolves one paint attribute: url(#id) gradient, flat colour, or none. */ + private static PaintValue paintValue(String value, PaintValue current, + Map gradients) { + String id = SvgGradients.urlId(value); + if (id != null) { + Element gradient = gradients.get(id); + if (gradient == null) { + throw new IllegalArgumentException("paint '" + value.trim() + + "' references no / with id '" + + id + "'"); + } + return new PaintValue(null, gradient); + } + DocumentColor color = color(value, current.color()); + return color == null ? PaintValue.NONE : new PaintValue(color, null); } private static String attrOrStyle(Element element, String property) { @@ -190,7 +247,7 @@ private static String attrOrStyle(Element element, String property) { return null; } - private static DocumentColor color(String value, DocumentColor current) { + static DocumentColor color(String value, DocumentColor current) { String v = value.trim().toLowerCase(Locale.ROOT); if (v.equals("none")) { return null; @@ -292,12 +349,12 @@ private static double num(Element element, String attribute) { // Transforms // ------------------------------------------------------------------ - private static double[] identity() { + static double[] identity() { return new double[]{1, 0, 0, 1, 0, 0}; } /** Composes {@code transform="…"} ops onto the parent matrix, left to right. */ - private static double[] compose(double[] parent, String transformAttribute) { + static double[] compose(double[] parent, String transformAttribute) { String attr = transformAttribute == null ? "" : transformAttribute.trim(); if (attr.isEmpty()) { return parent; diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathGradientTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathGradientTest.java new file mode 100644 index 000000000..f56847406 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfPathGradientTest.java @@ -0,0 +1,152 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PathBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.svg.SvgIcon; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.graphics.pattern.PDAbstractPattern; +import org.apache.pdfbox.pdmodel.graphics.pattern.PDShadingPattern; +import org.apache.pdfbox.pdmodel.graphics.shading.PDShading; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that path gradients reach the PDF natively: gradient fills as + * shading resources (clipped {@code sh}), gradient strokes as shading + * patterns in the stroking colour space — and that flat-colour paths emit + * neither, keeping pre-gradient output byte-identical. + */ +class PdfPathGradientTest { + + private static final DocumentColor VIOLET = DocumentColor.rgb(167, 139, 250); + private static final DocumentColor DEEP = DocumentColor.rgb(97, 40, 217); + private static final DocumentPaint AXIS = new DocumentPaint.LinearAxis(List.of( + new DocumentPaint.Stop(0.0, VIOLET), + new DocumentPaint.Stop(1.0, DEEP)), 0.0, 0.0, 1.0, 1.0); + + /** The brand-mark essence: gradient stroke plus a gradient-filled dot. */ + private static final String MINI_MARK_SVG = """ + + + + + + + + + + + """; + + @TempDir + Path tempDir; + + private Path render(String name, Consumer spec) throws Exception { + Path out = tempDir.resolve(name + ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(220, 160) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").addPath(spec).build(); + document.buildPdf(); + } + return out; + } + + private static List shadings(Path pdf) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + PDResources resources = doc.getPage(0).getResources(); + List result = new ArrayList<>(); + for (COSName name : resources.getShadingNames()) { + result.add(resources.getShading(name)); + } + return result; + } + } + + private static List patterns(Path pdf) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + PDResources resources = doc.getPage(0).getResources(); + List result = new ArrayList<>(); + for (COSName name : resources.getPatternNames()) { + result.add(resources.getPattern(name)); + } + return result; + } + } + + @Test + void gradientFillClipsToThePathAndEmitsAShading() throws Exception { + Path pdf = render("fill", p -> p.size(120, 60) + .moveTo(0, 0).lineTo(1, 0).lineTo(0.5, 1).closePath() + .fill(AXIS)); + + List shadings = shadings(pdf); + assertThat(shadings).hasSize(1); + assertThat(shadings.get(0).getShadingType()).isEqualTo(PDShading.SHADING_TYPE2); + assertThat(patterns(pdf)).isEmpty(); + } + + @Test + void gradientStrokeEmitsAShadingPatternColour() throws Exception { + Path pdf = render("stroke", p -> p.size(120, 60) + .moveTo(0, 0.5).curveTo(0.25, 1, 0.75, 0, 1, 0.5) + .stroke(DocumentStroke.of(VIOLET, 3)) + .strokePaint(AXIS)); + + List patterns = patterns(pdf); + assertThat(patterns).hasSize(1); + assertThat(patterns.get(0)).isInstanceOf(PDShadingPattern.class); + PDShading patternShading = ((PDShadingPattern) patterns.get(0)).getShading(); + assertThat(patternShading.getShadingType()).isEqualTo(PDShading.SHADING_TYPE2); + // The colour function must survive serialization too — a dangling + // /Function reference renders as no gradient at all. + assertThat(patternShading.getFunction()).isNotNull(); + // No fill gradient → no page-level shading resource. + assertThat(shadings(pdf)).isEmpty(); + } + + @Test + void solidPaintsNormaliseAwayAndEmitNoGradientResources() throws Exception { + Path pdf = render("solid", p -> p.size(120, 60) + .moveTo(0, 0).lineTo(1, 0).lineTo(0.5, 1).closePath() + .fill(DocumentPaint.solid(VIOLET)) + .stroke(DocumentStroke.of(DEEP, 2)) + .strokePaint(DocumentPaint.solid(DEEP))); + + assertThat(shadings(pdf)).isEmpty(); + assertThat(patterns(pdf)).isEmpty(); + } + + @Test + void svgIconWithGradientFillAndStrokeRendersEndToEnd() throws Exception { + SvgIcon icon = SvgIcon.parse(MINI_MARK_SVG); + Path out = tempDir.resolve("mark.pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(220, 160) + .margin(DocumentInsets.of(20)) + .create()) { + document.pageFlow().name("Flow").addSvgIcon(icon, 96).build(); + document.buildPdf(); + } + + // The stroked G-outline arrives as a pattern, the filled dot as a shading. + assertThat(patterns(out)).hasSize(1); + assertThat(shadings(out)).hasSize(1); + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java index 510240f31..7e765eab1 100644 --- a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java @@ -7,6 +7,7 @@ import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.svg.SvgPath; @@ -92,6 +93,40 @@ void nodeValidationFlowsThroughBuild() { .hasMessageContaining("must start with a MoveTo"); } + @Test + void gradientPaintsFlowThroughToTheNode() { + DocumentPaint axis = new DocumentPaint.LinearAxis(java.util.List.of( + new DocumentPaint.Stop(0.0, DocumentColor.rgb(167, 139, 250)), + new DocumentPaint.Stop(1.0, DocumentColor.rgb(97, 40, 217))), + 0.0, 0.0, 1.0, 1.0); + + PathNode node = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5) + .fill(axis) + .stroke(DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 2.0)) + .strokePaint(axis) + .build(); + + assertThat(node.fillPaint()).isSameAs(axis); + assertThat(node.strokePaint()).isSameAs(axis); + } + + @Test + void strokePaintWithoutAStrokeFailsAtBuild() { + PathBuilder builder = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5) + .strokePaint(DocumentPaint.linear( + DocumentColor.rgb(167, 139, 250), DocumentColor.rgb(97, 40, 217))); + + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("strokePaint needs a stroke"); + } + @Test void addPathPlacesTheNodeInTheFlow() throws Exception { try (DocumentSession document = GraphCompose.document() diff --git a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java index aab065874..75ed817fd 100644 --- a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -4,6 +4,7 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.layout.PlacedNode; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentPathSegment.CubicTo; import com.demcha.compose.document.style.DocumentPathSegment.LineTo; import org.junit.jupiter.api.Test; @@ -191,4 +192,193 @@ void dslBridgeStacksLayersIntoTheFlow() throws Exception { assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); } } + + // ------------------------------------------------------------------ + // Gradients + // ------------------------------------------------------------------ + + @Test + void userSpaceLinearGradientMapsToTheExactAxisInIconSpace() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + """); + + SvgIcon.Layer layer = icon.layers().get(0); + DocumentPaint.LinearAxis axis = (DocumentPaint.LinearAxis) layer.fillPaint(); + // SVG user (0,0) is the top-left → normalized (0,1); (10,10) → (1,0). + assertThat(axis.x0()).isCloseTo(0.0, within(1e-9)); + assertThat(axis.y0()).isCloseTo(1.0, within(1e-9)); + assertThat(axis.x1()).isCloseTo(1.0, within(1e-9)); + assertThat(axis.y1()).isCloseTo(0.0, within(1e-9)); + // The flat colour stays populated as the degradation target. + assertThat(layer.fill().color()).isEqualTo(new java.awt.Color(167, 139, 250)); + } + + @Test + void gradientStrokeKeepsWidthAndFirstStopFallback() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + """); + + SvgIcon.Layer layer = icon.layers().get(0); + assertThat(layer.fillPaint()).isNull(); + assertThat(layer.fill()).isNull(); + assertThat(layer.strokePaint()).isInstanceOf(DocumentPaint.LinearAxis.class); + assertThat(layer.stroke().width()).isEqualTo(7.0); + assertThat(layer.stroke().color().color()).isEqualTo(new java.awt.Color(167, 139, 250)); + } + + @Test + void boundingBoxGradientInterpolatesTheShapeBbox() { + // Default gradientUnits=objectBoundingBox, default axis 0%,0% → 100%,0%. + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + """); + + DocumentPaint.LinearAxis axis = + (DocumentPaint.LinearAxis) icon.layers().get(0).fillPaint(); + // Bbox: x ∈ [0.2, 0.8]; SVG bbox-top y=2 → normalized 0.8. + assertThat(axis.x0()).isCloseTo(0.2, within(1e-9)); + assertThat(axis.y0()).isCloseTo(0.8, within(1e-9)); + assertThat(axis.x1()).isCloseTo(0.8, within(1e-9)); + assertThat(axis.y1()).isCloseTo(0.8, within(1e-9)); + } + + @Test + void hrefSuppliesStopsAcrossOneHop() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + + + """); + + DocumentPaint.LinearAxis axis = + (DocumentPaint.LinearAxis) icon.layers().get(0).fillPaint(); + assertThat(axis.stops()).hasSize(3); + assertThat(axis.stops().get(1).offset()).isCloseTo(0.3, within(1e-9)); + } + + @Test + void gradientTransformShiftsTheAxis() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + """); + + DocumentPaint.LinearAxis axis = + (DocumentPaint.LinearAxis) icon.layers().get(0).fillPaint(); + assertThat(axis.x0()).isCloseTo(0.5, within(1e-9)); + assertThat(axis.x1()).isCloseTo(1.0, within(1e-9)); + } + + @Test + void radialGradientMapsCentreAndRadius() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + + + + """); + + DocumentPaint.RadialCircle circle = + (DocumentPaint.RadialCircle) icon.layers().get(0).fillPaint(); + assertThat(circle.cx()).isCloseTo(0.5, within(1e-9)); + assertThat(circle.cy()).isCloseTo(0.5, within(1e-9)); + assertThat(circle.r()).isCloseTo(0.5, within(1e-9)); + } + + @Test + void gradientCornersWithoutAPdfAnalogueFailLoudly() { + String defs = """ + + %s + + + """; + + assertThatThrownBy(() -> SvgIcon.parse(defs.formatted( + ""))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("references no"); + assertThatThrownBy(() -> SvgIcon.parse(defs.formatted(""" + + + """))) + .hasMessageContaining("spreadMethod"); + assertThatThrownBy(() -> SvgIcon.parse(defs.formatted(""" + + + + """))) + .hasMessageContaining("stop-opacity"); + assertThatThrownBy(() -> SvgIcon.parse(defs.formatted(""" + + + """))) + .hasMessageContaining("focal"); + } + + @Test + void nodeFormPackagesLayersAtTheRequestedWidth() { + SvgIcon icon = SvgIcon.parse(""" + + + + """); + + var stack = icon.node(96); + assertThat(stack.layers()).hasSize(1); + var path = (com.demcha.compose.document.node.PathNode) stack.layers().get(0).node(); + assertThat(path.width()).isEqualTo(96.0); + assertThat(path.height()).isCloseTo(48.0, within(1e-9)); + assertThatThrownBy(() -> icon.node(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("positive"); + } }