diff --git a/CHANGELOG.md b/CHANGELOG.md index eec50539..3bbc05fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,8 @@ Entries land here as they merge. series, type/colour-agnostic), sealed `ChartSpec` (`bar()` / `line()` with axis, legend, value-label, and sizing knobs), `ChartStyle` (nullable-field cascade merged over `ChartTheme` tokens, per-series paint overrides), and - `DocumentPaint` (solid today; linear/radial gradient stops reserved for the - gradient work). Charts compile at layout time into existing primitives + `DocumentPaint` (solid, linear, and radial — see the gradient entry below). + Charts compile at layout time into existing primitives (shapes, lines, paragraphs) via `ChartDefinition` — no new render handlers, deterministic geometry, covered by the standard snapshot machinery; any fixed-layout backend renders charts with no chart-specific code, while the @@ -68,6 +68,20 @@ Entries land here as they merge. configurable halo chip (`ChartStyle.valueLabelHalo(...)`, themed white) so digits stay legible where lines cross them, and deterministically flip below their point when two series' labels would collide at the same category. +- **Gradient fills** (`@since 1.8.0`). `DocumentPaint` graduates to + `com.demcha.compose.document.style` as the shared paint vocabulary, and + gradients now actually render: `ShapeNode` gains an optional `fillPaint` + (`ShapeBuilder.fill(paint)`) that wins over `fillColor`. The PDF backend + paints `DocumentPaint.linear` as a native axial shading (0° = left→right, + 90° = bottom→top; two stops exponential, more stops stitched) and + `DocumentPaint.radial` as a radial shading reaching the farthest corner, + clipped to the shape path — rounded corners included. Chart bars now carry + their full series paint, so a gradient palette renders as gradients instead + of degrading to the first stop. Solid paints normalise to the plain + fill-colour path, keeping existing documents byte-identical; backends + without shading support fall back to `primaryColor()` by contract. The + flagship `BusinessReportExample` hero is now fully vector — gradient-sky + shape plus polygon mountain ranges replace the last Graphics2D raster. - **Translucent shape colours** (`@since 1.8.0`). `DocumentColor.rgba(r, g, b, a)` and `withOpacity(0..1)`: the PDF backend honours the alpha channel on shape fills and strokes (rectangles/panels/bars, chart value-label halos, ellipse diff --git a/assets/readme/examples/business-report.pdf b/assets/readme/examples/business-report.pdf index da9ed9a6..b9e5682d 100644 Binary files a/assets/readme/examples/business-report.pdf and b/assets/readme/examples/business-report.pdf differ diff --git a/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java b/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java index 61c82b7b..028dfde3 100644 --- a/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java @@ -8,7 +8,7 @@ import com.demcha.compose.document.chart.ChartSize; import com.demcha.compose.document.chart.ChartSpec; import com.demcha.compose.document.chart.ChartStyle; -import com.demcha.compose.document.chart.DocumentPaint; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.chart.LegendPosition; import com.demcha.compose.document.chart.NumberFormatSpec; import com.demcha.compose.document.chart.PointMarker; diff --git a/examples/src/main/java/com/demcha/examples/flagships/BusinessReportExample.java b/examples/src/main/java/com/demcha/examples/flagships/BusinessReportExample.java index 0fa0bc22..ba7cc1d8 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/BusinessReportExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/BusinessReportExample.java @@ -5,8 +5,6 @@ import com.demcha.compose.document.api.DocumentSession; import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.SectionBuilder; -import com.demcha.compose.document.image.DocumentImageData; -import com.demcha.compose.document.image.DocumentImageFitMode; import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerAlign; import com.demcha.compose.document.node.TextAlign; @@ -21,13 +19,6 @@ import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; -import javax.imageio.ImageIO; -import java.awt.GradientPaint; -import java.awt.Graphics2D; -import java.awt.Polygon; -import java.awt.RenderingHints; -import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; import java.nio.file.Path; /** @@ -86,8 +77,6 @@ private BusinessReportExample() { public static Path generate() throws Exception { Path outputFile = ExampleOutputPaths.prepare("flagships", "business-report.pdf"); - DocumentImageData heroImage = DocumentImageData.fromBytes(renderHeroImage(380, 200)); - try (DocumentSession document = GraphCompose.document(outputFile) .pageSize(DocumentPageSize.A4) .pageBackground(PAPER) @@ -155,22 +144,17 @@ public static Path generate() throws Exception { .margin(new DocumentInsets(4, 0, 0, 0)))) .addSection("HeroImage", section -> section .padding(DocumentInsets.zero()) - // Hero image lives inside a rounded - // shape container so the navy edges - // soften into a frame instead of - // bleeding straight to the page edge. + // The hero scene is fully vector now: a + // gradient-sky shape with two polygon + // mountain ranges, clipped to the rounded + // frame. No raster, no AWT. .addContainer(frame -> frame .name("HeroFrame") .roundedRect(210, 110, 12) .fillColor(NAVY_DARK) .stroke(DocumentStroke.of(GOLD, 0.6)) .clipPolicy(ClipPolicy.CLIP_PATH) - .center(new com.demcha.compose.document.dsl.ImageBuilder() - .name("HeroImage") - .source(heroImage) - .size(204, 104) - .fitMode(DocumentImageFitMode.COVER) - .build())))) + .center(buildHeroScene(204, 104))))) // Three KPI cards .addRow("KpiRow", row -> row @@ -444,8 +428,8 @@ private static DocumentNode buildChart() { .build(); com.demcha.compose.document.chart.ChartStyle style = com.demcha.compose.document.chart.ChartStyle.builder() - .seriesPaint(0, com.demcha.compose.document.chart.DocumentPaint.solid(NAVY)) - .seriesPaint(1, com.demcha.compose.document.chart.DocumentPaint.solid(GOLD)) + .seriesPaint(0, com.demcha.compose.document.style.DocumentPaint.solid(NAVY)) + .seriesPaint(1, com.demcha.compose.document.style.DocumentPaint.solid(GOLD)) .build(); return new com.demcha.compose.document.node.ChartNode( "PerformanceChart", spec, style, null, null); @@ -454,53 +438,60 @@ private static DocumentNode buildChart() { // ─────────────────── Hero image generator ───────────────────── /** - * Renders a simple gradient hero image (sunset sky + mountain - * silhouette) so the example does not depend on any external image - * asset. The result is encoded as PNG bytes and embedded directly. - * - *
This is the one remaining raster block in the example, and it is - * deliberate: the sky requires a smooth linear gradient, which the engine - * does not paint natively yet ({@code DocumentPaint.linear} is reserved - * for the gradient work). Once gradients land, the mountains become - * {@code PolygonNode}s, the glow a translucent fill, and this method goes - * away like the old chart raster did.
+ * Builds the hero scene fully from vector primitives: a gradient-sky + * shape (warm cream at the horizon rising into slate) with two polygon + * mountain ranges — the distant one translucent, the foreground one + * solid. The same sunset the old Graphics2D raster painted, now native: + * deterministic, crisp at any zoom, and free of the AWT dependency. */ - private static byte[] renderHeroImage(int width, int height) throws Exception { - BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); - Graphics2D g = img.createGraphics(); - try { - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - // Sky gradient: slate at the top, warm cream at the horizon. - g.setPaint(new GradientPaint( - 0, 0, new java.awt.Color(58, 70, 100), - 0, height * 0.7f, new java.awt.Color(218, 196, 162))); - g.fillRect(0, 0, width, height); - // Distant mountain silhouette. - g.setColor(new java.awt.Color(50, 62, 88, 230)); - Polygon farRange = new Polygon( - new int[]{0, (int) (width * 0.18), (int) (width * 0.36), (int) (width * 0.54), - (int) (width * 0.72), (int) (width * 0.88), width, width, 0}, - new int[]{(int) (height * 0.55), (int) (height * 0.40), (int) (height * 0.50), - (int) (height * 0.34), (int) (height * 0.46), (int) (height * 0.36), - (int) (height * 0.50), height, height}, - 9); - g.fill(farRange); - // Foreground mountain wedge. - g.setColor(new java.awt.Color(28, 36, 60)); - Polygon foreRange = new Polygon( - new int[]{0, (int) (width * 0.22), (int) (width * 0.40), (int) (width * 0.60), - (int) (width * 0.82), width, width, 0}, - new int[]{(int) (height * 0.78), (int) (height * 0.55), (int) (height * 0.68), - (int) (height * 0.50), (int) (height * 0.62), (int) (height * 0.74), - height, height}, - 8); - g.fill(foreRange); - } finally { - g.dispose(); + private static DocumentNode buildHeroScene(double width, double height) { + com.demcha.compose.document.node.ShapeNode sky = + new com.demcha.compose.document.dsl.ShapeBuilder() + .name("HeroSky") + .size(width, height) + .fill(new com.demcha.compose.document.style.DocumentPaint.Linear( + java.util.List.of( + new com.demcha.compose.document.style.DocumentPaint.Stop( + 0.0, DocumentColor.rgb(218, 196, 162)), + new com.demcha.compose.document.style.DocumentPaint.Stop( + 0.30, DocumentColor.rgb(218, 196, 162)), + new com.demcha.compose.document.style.DocumentPaint.Stop( + 1.0, DocumentColor.rgb(58, 70, 100))), + 90.0)) + .build(); + com.demcha.compose.document.node.PolygonNode farRange = + new com.demcha.compose.document.node.PolygonNode( + "HeroFarRange", width, height, + heroPoints(new double[][] { + {0, .45}, {.18, .60}, {.36, .50}, {.54, .66}, + {.72, .54}, {.88, .64}, {1, .50}, {1, 0}, {0, 0}}), + DocumentColor.rgba(50, 62, 88, 230), null, + DocumentInsets.zero(), DocumentInsets.zero()); + com.demcha.compose.document.node.PolygonNode foreRange = + new com.demcha.compose.document.node.PolygonNode( + "HeroForeRange", width, height, + heroPoints(new double[][] { + {0, .22}, {.22, .45}, {.40, .32}, {.60, .50}, + {.82, .38}, {1, .26}, {1, 0}, {0, 0}}), + DocumentColor.rgb(28, 36, 60), null, + DocumentInsets.zero(), DocumentInsets.zero()); + return new com.demcha.compose.document.node.CanvasLayerNode( + "HeroScene", width, height, + java.util.List.of( + new com.demcha.compose.document.node.CanvasChild(sky, 0, 0), + new com.demcha.compose.document.node.CanvasChild(farRange, 0, 0), + new com.demcha.compose.document.node.CanvasChild(foreRange, 0, 0)), + ClipPolicy.OVERFLOW_VISIBLE, DocumentInsets.zero(), DocumentInsets.zero()); + } + + private static java.util.ListThe translation is deterministic: a {@link DocumentPaint.Linear} maps to + * a {@code /ShadingType 2} whose axis crosses the target box's centre along + * 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.
+ * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +final class PdfShadingSupport { + + private PdfShadingSupport() { + } + + /** + * Builds the shading for a gradient paint over the given box. + * + * @param paint gradient paint ({@link DocumentPaint.Linear} or {@link DocumentPaint.Radial}) + * @param x box left, page coordinates + * @param y box bottom, page coordinates + * @param width box width + * @param height box height + * @return configured shading + * @throws IllegalArgumentException for a {@link DocumentPaint.Solid} (solid + * fills never reach the shading path) + */ + static PDShading build(DocumentPaint paint, float x, float y, float width, float height) { + if (paint instanceof DocumentPaint.Linear linear) { + return axial(linear, x, y, width, height); + } + if (paint instanceof DocumentPaint.Radial radial) { + return radial(radial, x, y, width, height); + } + throw new IllegalArgumentException( + "solid paints are normalised before emission and never reach the shading path"); + } + + private static PDShading axial(DocumentPaint.Linear linear, + float x, float y, float width, float height) { + double radians = Math.toRadians(linear.angleDegrees()); + double dx = Math.cos(radians); + double dy = Math.sin(radians); + double cx = x + width / 2.0; + double cy = y + height / 2.0; + // Half the box's extent projected onto the gradient axis, so the axis + // always spans the box regardless of angle. + double halfLen = (Math.abs(width * dx) + Math.abs(height * dy)) / 2.0; + + PDShadingType2 shading = new PDShadingType2(new COSDictionary()); + shading.setShadingType(PDShading.SHADING_TYPE2); + shading.setColorSpace(PDDeviceRGB.INSTANCE); + COSArray coords = new COSArray(); + coords.add(new COSFloat((float) (cx - dx * halfLen))); + coords.add(new COSFloat((float) (cy - dy * halfLen))); + coords.add(new COSFloat((float) (cx + dx * halfLen))); + coords.add(new COSFloat((float) (cy + dy * halfLen))); + shading.setCoords(coords); + shading.setFunction(stopsFunction(linear.stops())); + shading.setExtend(bothExtend()); + return shading; + } + + private static PDShading radial(DocumentPaint.Radial radial, + float x, float y, float width, float height) { + double cx = x + radial.cx() * width; + double cy = y + radial.cy() * height; + // Radius to the farthest corner so the last stop always reaches it. + double r = 0.0; + for (double[] corner : new double[][] {{x, y}, {x + width, y}, {x, y + height}, + {x + width, y + height}}) { + r = Math.max(r, Math.hypot(corner[0] - cx, corner[1] - cy)); + } + + PDShadingType3 shading = new PDShadingType3(new COSDictionary()); + shading.setShadingType(PDShading.SHADING_TYPE3); + shading.setColorSpace(PDDeviceRGB.INSTANCE); + COSArray coords = new COSArray(); + coords.add(new COSFloat((float) cx)); + coords.add(new COSFloat((float) cy)); + coords.add(new COSFloat(0f)); + coords.add(new COSFloat((float) cx)); + coords.add(new COSFloat((float) cy)); + coords.add(new COSFloat((float) r)); + shading.setCoords(coords); + shading.setFunction(stopsFunction(radial.stops())); + shading.setExtend(bothExtend()); + return shading; + } + + private static COSArray bothExtend() { + COSArray extend = new COSArray(); + extend.add(COSBoolean.TRUE); + extend.add(COSBoolean.TRUE); + return extend; + } + + /** Two stops → one exponential function; more → a stitching function. */ + private static PDFunction stopsFunction(List{@code fillPaint} carries a gradient fill when one is requested; + * solid paints are normalised to {@code fillColor} before emission, so a + * non-null {@code fillPaint} is always {@link DocumentPaint.Linear} or + * {@link DocumentPaint.Radial} and backends without shading support fall + * back to its {@link DocumentPaint#primaryColor()}.
+ * * @param fillColor optional shape fill color * @param stroke optional shape stroke * @param cornerRadius per-corner radii in points * @param linkOptions optional fragment-level link metadata * @param bookmarkOptions optional fragment-level bookmark metadata * @param sideBorders optional per-side border strokes + * @param fillPaint optional gradient fill; {@code null} for solid fills */ public record ShapeFragmentPayload( Color fillColor, @@ -28,7 +36,8 @@ public record ShapeFragmentPayload( DocumentCornerRadius cornerRadius, DocumentLinkOptions linkOptions, DocumentBookmarkOptions bookmarkOptions, - SideBorders sideBorders + SideBorders sideBorders, + DocumentPaint fillPaint ) implements PdfSemanticFragmentPayload { /** * Normalizes the render-only corner radius. @@ -39,6 +48,25 @@ public record ShapeFragmentPayload( } } + /** + * Backwards-compatible constructor without a gradient fill. + * + * @param fillColor optional shape fill color + * @param stroke optional shape stroke + * @param cornerRadius per-corner radii in points + * @param linkOptions optional fragment-level link metadata + * @param bookmarkOptions optional fragment-level bookmark metadata + * @param sideBorders optional per-side border strokes + */ + public ShapeFragmentPayload(Color fillColor, + Stroke stroke, + DocumentCornerRadius cornerRadius, + DocumentLinkOptions linkOptions, + DocumentBookmarkOptions bookmarkOptions, + SideBorders sideBorders) { + this(fillColor, stroke, cornerRadius, linkOptions, bookmarkOptions, sideBorders, null); + } + /** * Backwards-compatible constructor that accepts a single uniform * radius (pre-Phase E.1.1 wiring) and applies it to every corner. diff --git a/src/main/java/com/demcha/compose/document/node/ShapeNode.java b/src/main/java/com/demcha/compose/document/node/ShapeNode.java index eb9fbe2f..ae4e0cdc 100644 --- a/src/main/java/com/demcha/compose/document/node/ShapeNode.java +++ b/src/main/java/com/demcha/compose/document/node/ShapeNode.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.style.DocumentColor; import com.demcha.compose.document.style.DocumentCornerRadius; import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTransform; @@ -26,6 +27,10 @@ * {@link DocumentTransform#NONE}. Layout snapshots stay * deterministic regardless of rotation/scale — backends * apply the transform during render only. + * @param fillPaint optional paint fill; when set it wins over + * {@code fillColor}. Gradients render as native shadings in + * the PDF backend; backends without shading support fall + * back to {@link DocumentPaint#primaryColor()}. * * @author Artem Demchyshyn */ @@ -40,8 +45,39 @@ public record ShapeNode( DocumentBookmarkOptions bookmarkOptions, DocumentInsets padding, DocumentInsets margin, - DocumentTransform transform + DocumentTransform transform, + DocumentPaint fillPaint ) implements DocumentNode { + /** + * Backwards-compatible canonical constructor without a paint fill. + * + * @param name node name used in snapshots and layout graph paths + * @param width resolved shape width + * @param height resolved shape height + * @param fillColor optional fill color + * @param stroke optional stroke descriptor + * @param cornerRadius optional render-only corner radius + * @param linkOptions optional node-level link metadata + * @param bookmarkOptions optional node-level bookmark metadata + * @param padding inner padding + * @param margin outer margin + * @param transform render-time affine transform + */ + public ShapeNode(String name, + double width, + double height, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentLinkOptions linkOptions, + DocumentBookmarkOptions bookmarkOptions, + DocumentInsets padding, + DocumentInsets margin, + DocumentTransform transform) { + this(name, width, height, fillColor, stroke, cornerRadius, linkOptions, bookmarkOptions, + padding, margin, transform, null); + } + /** * Normalizes spacing defaults and validates explicit shape dimensions. */ diff --git a/src/main/java/com/demcha/compose/document/chart/DocumentPaint.java b/src/main/java/com/demcha/compose/document/style/DocumentPaint.java similarity index 84% rename from src/main/java/com/demcha/compose/document/chart/DocumentPaint.java rename to src/main/java/com/demcha/compose/document/style/DocumentPaint.java index ed635f7b..20976154 100644 --- a/src/main/java/com/demcha/compose/document/chart/DocumentPaint.java +++ b/src/main/java/com/demcha/compose/document/style/DocumentPaint.java @@ -1,18 +1,18 @@ -package com.demcha.compose.document.chart; +package com.demcha.compose.document.style; -import com.demcha.compose.document.style.DocumentColor; import java.util.List; import java.util.Objects; /** - * A fill specification: a flat colour or a gradient. Lives in the chart package - * for now, but is intended to graduate into - * {@code com.demcha.compose.document.style} and replace bare {@link DocumentColor} - * in every fillable surface (shape containers, panel backgrounds, page - * backgrounds) once gradients land engine-wide. PDFBox renders gradients via - * axial / radial shadings (PDShadingType2/3); until that work lands every - * backend (and the v1 chart resolver) renders {@link #primaryColor()}. + * A fill specification: a flat colour or a multi-stop gradient. This is the + * single paint vocabulary every fillable surface shares — chart palettes + * today, shape and panel fills as they adopt the {@code fillPaint} component. + * + *Backend contract: the PDF backend renders {@link Linear} and + * {@link Radial} as native axial / radial shadings; a backend (or surface) + * that cannot paint a gradient degrades to {@link #primaryColor()} — the + * first stop — so authoring code never branches per backend.
* * @author Artem Demchyshyn * @since 1.8.0 diff --git a/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapeGradientTest.java b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapeGradientTest.java new file mode 100644 index 00000000..1c4c6bd1 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapeGradientTest.java @@ -0,0 +1,94 @@ +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.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; +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.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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link DocumentPaint} gradients reach the PDF as native + * shadings — and that solid fills (colour or {@code DocumentPaint.solid}) + * emit no shading resources at all, keeping existing output byte-identical. + */ +class PdfShapeGradientTest { + + private static final DocumentColor TEAL = DocumentColor.rgb(20, 80, 95); + private static final DocumentColor GOLD = DocumentColor.rgb(196, 153, 76); + + @TempDir + Path tempDir; + + private Path render(String name, DocumentPaint paint) 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") + .addShape(s -> s.size(120, 60).cornerRadius(6).fill(paint)) + .build(); + document.buildPdf(); + } + return out; + } + + private static List