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;d*f`~bL~`5xIoIrQ2tcs%
zBuYDkk>Df^&!+@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