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 5ae174426..0ab247f33 100644 Binary files a/assets/readme/examples/vector-path.pdf and b/assets/readme/examples/vector-path.pdf differ diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java index c570beb1d..7c51ea734 100644 --- a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java +++ b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java @@ -4,12 +4,14 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.svg.SvgIcon; import com.demcha.compose.document.svg.SvgPath; import com.demcha.examples.support.ExampleOutputPaths; import java.nio.file.Path; +import java.util.List; /** * Runnable showcase for the v1.8 vector-path primitive: free-form design @@ -39,6 +41,13 @@ public final class VectorPathExample { private static final DocumentColor SAND_EDGE = DocumentColor.rgb(140, 90, 30); private static final DocumentColor MOSS = DocumentColor.rgb(208, 226, 213); private static final DocumentColor MOSS_EDGE = DocumentColor.rgb(60, 110, 80); + private static final DocumentColor VIOLET = DocumentColor.rgb(167, 139, 250); + private static final DocumentColor DEEP_VIOLET = DocumentColor.rgb(97, 40, 217); + + /** Brand gradient along the top-left → bottom-right diagonal. */ + private static final DocumentPaint BRAND_AXIS = new DocumentPaint.LinearAxis(List.of( + new DocumentPaint.Stop(0.0, VIOLET), + new DocumentPaint.Stop(1.0, DEEP_VIOLET)), 0.0, 1.0, 1.0, 0.0); /** Material Icons "favorite" path data (Apache 2.0), viewBox 0 0 24 24. */ private static final String MATERIAL_HEART_D = @@ -68,7 +77,7 @@ public static Path generate() throws Exception { Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf"); try (DocumentSession document = GraphCompose.document(pdfFile) - .pageSize(420, 780) + .pageSize(420, 920) .margin(DocumentInsets.of(28)) .create()) { document.pageFlow(page -> 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(ConsumerSolid 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( ListFills 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, ListCoordinate 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 {@codeEach 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
+ * CSS stylesheets and classes, text, masks, clip paths, filters, + * {@code