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.List heroPoints( + double[][] xy) { + java.util.List points = + new java.util.ArrayList<>(xy.length); + for (double[] p : xy) { + points.add(new com.demcha.compose.document.style.ShapePoint(p[0], p[1])); } - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImageIO.write(img, "png", out); - return out.toByteArray(); + return points; } // ─────────────────── Text styles ────────────────────────────── 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 new file mode 100644 index 00000000..f945cab9 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShadingSupport.java @@ -0,0 +1,175 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.style.DocumentPaint; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBoolean; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +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.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.graphics.shading.PDShading; +import org.apache.pdfbox.pdmodel.graphics.shading.PDShadingType2; +import org.apache.pdfbox.pdmodel.graphics.shading.PDShadingType3; + +import java.awt.Color; +import java.util.List; + +/** + * Builds PDF axial / radial shadings from the backend-neutral + * {@link DocumentPaint} gradient types. + * + *

The 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 stops) { + if (stops.size() == 2) { + return segment(stops.get(0).color().color(), stops.get(1).color().color()); + } + COSDictionary dict = new COSDictionary(); + dict.setInt(COSName.FUNCTION_TYPE, 3); + dict.setItem(COSName.DOMAIN, domain01()); + + COSArray functions = new COSArray(); + COSArray bounds = new COSArray(); + COSArray encode = new COSArray(); + for (int i = 0; i < stops.size() - 1; i++) { + functions.add(segment( + stops.get(i).color().color(), + stops.get(i + 1).color().color())); + if (i > 0) { + bounds.add(new COSFloat((float) stops.get(i).offset())); + } + encode.add(COSInteger.ZERO); + encode.add(COSInteger.ONE); + } + dict.setItem(COSName.FUNCTIONS, functions); + dict.setItem(COSName.BOUNDS, bounds); + dict.setItem(COSName.ENCODE, encode); + return new PDFunctionType3(dict); + } + + private static PDFunctionType2 segment(Color from, Color to) { + COSDictionary dict = new COSDictionary(); + dict.setInt(COSName.FUNCTION_TYPE, 2); + dict.setItem(COSName.DOMAIN, domain01()); + dict.setItem(COSName.C0, rgb(from)); + dict.setItem(COSName.C1, rgb(to)); + dict.setInt(COSName.N, 1); + return new PDFunctionType2(dict); + } + + private static COSArray domain01() { + COSArray domain = new COSArray(); + domain.add(COSInteger.ZERO); + domain.add(COSInteger.ONE); + return domain; + } + + private static COSArray rgb(Color color) { + COSArray array = new COSArray(); + array.add(new COSFloat(color.getRed() / 255f)); + array.add(new COSFloat(color.getGreen() / 255f)); + array.add(new COSFloat(color.getBlue() / 255f)); + return array; + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java index 2f1dd1a8..11215a0f 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeFragmentRenderHandler.java @@ -39,11 +39,12 @@ public void render(PlacedFragment fragment, } boolean hasFill = payload.fillColor() != null; + boolean hasGradient = payload.fillPaint() != null; boolean hasStroke = payload.stroke() != null && payload.stroke().strokeColor() != null && payload.stroke().width() > 0; boolean hasSideBorders = payload.sideBorders() != null && payload.sideBorders().hasAny(); - if (!hasFill && !hasStroke && !hasSideBorders) { + if (!hasFill && !hasGradient && !hasStroke && !hasSideBorders) { return; } @@ -65,7 +66,24 @@ public void render(PlacedFragment fragment, float bottomLeft = clampCornerRadius(radii.bottomLeft(), maxRadius); boolean anyRounded = topLeft > 0f || topRight > 0f || bottomRight > 0f || bottomLeft > 0f; - if (hasFill) { + if (hasGradient) { + // Clip to the shape's path inside a nested graphics state so the + // clip never leaks into the stroke pass, then paint the shading. + stream.saveGraphicsState(); + try { + if (anyRounded) { + drawRoundedRectangle(stream, x, y, width, height, + topLeft, topRight, bottomRight, bottomLeft); + } else { + stream.addRect(x, y, width, height); + } + stream.clip(); + stream.shadingFill(PdfShadingSupport.build( + payload.fillPaint(), x, y, width, height)); + } finally { + stream.restoreGraphicsState(); + } + } else if (hasFill) { PdfAlphaSupport.applyFillAlpha(stream, payload.fillColor()); stream.setNonStrokingColor(payload.fillColor()); if (anyRounded) { diff --git a/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java b/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java index a56ff905..228f2c6f 100644 --- a/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java +++ b/src/main/java/com/demcha/compose/document/chart/BarChartLayout.java @@ -6,6 +6,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.DocumentTextStyle; @@ -91,10 +92,11 @@ private static List resolveVertical(ChartSpec.Bar bar, ChartStyl if (segH < MIN_BAR_HEIGHT) { continue; } - DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); + DocumentPaint paint = style.paintForSeries(s, theme.palette()); out.add(new ChartPrimitive( - new ShapeNode("bar_c" + c + "_s" + s, groupW, segH, color, null, - barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero()), + new ShapeNode("bar_c" + c + "_s" + s, groupW, segH, null, null, + barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(), + null, paint), groupX, f.plotBottomY() + cum, groupW, segH)); cum += segH; } @@ -118,10 +120,11 @@ private static List resolveVertical(ChartSpec.Bar bar, ChartStyl continue; } double bx = groupX + s * barW + (barW - innerBarW) / 2.0; - DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); + DocumentPaint paint = style.paintForSeries(s, theme.palette()); out.add(new ChartPrimitive( - new ShapeNode("bar_c" + c + "_s" + s, innerBarW, h, color, null, - barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero()), + new ShapeNode("bar_c" + c + "_s" + s, innerBarW, h, null, null, + barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(), + null, paint), bx, f.plotBottomY(), innerBarW, h)); if (bar.valueLabels() == ValueLabelMode.OUTSIDE) { String text = bar.valueAxis().format().format(v); @@ -278,10 +281,11 @@ private static List resolveHorizontal(ChartSpec.Bar bar, ChartSt if (segW < MIN_BAR_HEIGHT) { continue; } - DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); + DocumentPaint paint = style.paintForSeries(s, theme.palette()); out.add(new ChartPrimitive( - new ShapeNode("bar_c" + c + "_s" + s, segW, groupH, color, null, - barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero()), + new ShapeNode("bar_c" + c + "_s" + s, segW, groupH, null, null, + barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(), + null, paint), plotLeftX + cum, groupTop - groupH, segW, groupH)); cum += segW; } @@ -303,10 +307,11 @@ private static List resolveHorizontal(ChartSpec.Bar bar, ChartSt continue; } double barTop = groupTop - s * barH - (barH - innerBarH) / 2.0; - DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); + DocumentPaint paint = style.paintForSeries(s, theme.palette()); out.add(new ChartPrimitive( - new ShapeNode("bar_c" + c + "_s" + s, w, innerBarH, color, null, - barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero()), + new ShapeNode("bar_c" + c + "_s" + s, w, innerBarH, null, null, + barRadius, null, null, DocumentInsets.zero(), DocumentInsets.zero(), + null, paint), plotLeftX, barTop - innerBarH, w, innerBarH)); if (bar.valueLabels() == ValueLabelMode.OUTSIDE) { emitEndLabel(out, "value_c" + c + "_s" + s, axis.format().format(v), diff --git a/src/main/java/com/demcha/compose/document/chart/ChartDefaults.java b/src/main/java/com/demcha/compose/document/chart/ChartDefaults.java index 1329b1d1..34cc1c2b 100644 --- a/src/main/java/com/demcha/compose/document/chart/ChartDefaults.java +++ b/src/main/java/com/demcha/compose/document/chart/ChartDefaults.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.chart; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.font.FontName; diff --git a/src/main/java/com/demcha/compose/document/chart/ChartStyle.java b/src/main/java/com/demcha/compose/document/chart/ChartStyle.java index 4e638e21..2c2b9153 100644 --- a/src/main/java/com/demcha/compose/document/chart/ChartStyle.java +++ b/src/main/java/com/demcha/compose/document/chart/ChartStyle.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.chart; import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; diff --git a/src/main/java/com/demcha/compose/document/chart/ChartTheme.java b/src/main/java/com/demcha/compose/document/chart/ChartTheme.java index c7075243..26ca338f 100644 --- a/src/main/java/com/demcha/compose/document/chart/ChartTheme.java +++ b/src/main/java/com/demcha/compose/document/chart/ChartTheme.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.chart; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextStyle; diff --git a/src/main/java/com/demcha/compose/document/chart/PointMarker.java b/src/main/java/com/demcha/compose/document/chart/PointMarker.java index a466508c..20584502 100644 --- a/src/main/java/com/demcha/compose/document/chart/PointMarker.java +++ b/src/main/java/com/demcha/compose/document/chart/PointMarker.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.chart; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentStroke; /** diff --git a/src/main/java/com/demcha/compose/document/dsl/ShapeBuilder.java b/src/main/java/com/demcha/compose/document/dsl/ShapeBuilder.java index 2e137a86..03a8ae2f 100644 --- a/src/main/java/com/demcha/compose/document/dsl/ShapeBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/ShapeBuilder.java @@ -52,6 +52,7 @@ public class ShapeBuilder implements Transformable { protected double width; protected double height; protected DocumentColor fillColor; + protected com.demcha.compose.document.style.DocumentPaint fillPaint; protected DocumentStroke stroke; protected DocumentCornerRadius cornerRadius = DocumentCornerRadius.ZERO; protected DocumentLinkOptions linkOptions; @@ -134,6 +135,21 @@ public ShapeBuilder fillColor(DocumentColor fillColor) { return this; } + /** + * Sets the shape fill with a {@link com.demcha.compose.document.style.DocumentPaint} + * — a solid colour or a gradient. When set, the paint wins over + * {@link #fillColor(DocumentColor)}; gradients render as native shadings in + * the PDF backend. + * + * @param paint fill paint, or {@code null} to clear + * @return this builder + * @since 1.8.0 + */ + public ShapeBuilder fill(com.demcha.compose.document.style.DocumentPaint paint) { + this.fillPaint = paint; + return this; + } + /** * Sets shape stroke with the public canonical stroke value. * @@ -234,6 +250,7 @@ public DocumentTransform currentTransform() { * @return shape node */ public ShapeNode build() { - return new ShapeNode(name, width, height, fillColor, stroke, cornerRadius, linkOptions, bookmarkOptions, padding, margin, transform); + return new ShapeNode(name, width, height, fillColor, stroke, cornerRadius, linkOptions, + bookmarkOptions, padding, margin, transform, fillPaint); } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeDefinition.java index e0871bb1..5b4c9149 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/ShapeDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/ShapeDefinition.java @@ -60,6 +60,20 @@ public List emitFragments(PreparedNode prepared, if (width <= EPS || height <= EPS) { return List.of(); } + // Solid paints normalise to a plain fill colour so the render path (and + // its byte output) is identical to a fillColor; only true gradients + // travel as fillPaint. + com.demcha.compose.document.style.DocumentPaint paint = node.fillPaint(); + java.awt.Color fill; + com.demcha.compose.document.style.DocumentPaint gradient = null; + if (paint instanceof com.demcha.compose.document.style.DocumentPaint.Solid solid) { + fill = solid.color().color(); + } else if (paint != null) { + gradient = paint; + fill = null; + } else { + fill = node.fillColor() == null ? null : node.fillColor().color(); + } LayoutFragment leaf = new LayoutFragment( placement.path(), 0, @@ -68,12 +82,13 @@ public List emitFragments(PreparedNode prepared, width, height, new ShapeFragmentPayload( - node.fillColor() == null ? null : node.fillColor().color(), + fill, toStroke(node.stroke()), node.cornerRadius(), node.linkOptions(), node.bookmarkOptions(), - null)); + null, + gradient)); return wrapAtomicWithTransform(leaf, placement, node.transform()); } } diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/ShapeFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/ShapeFragmentPayload.java index 11eed5ef..28fbfa6d 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/ShapeFragmentPayload.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/ShapeFragmentPayload.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.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.engine.components.content.shape.Stroke; import java.awt.Color; @@ -15,12 +16,19 @@ * leaving the left edge square. Single-radius callers continue to * work via the legacy double-precision constructor.

* + *

{@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 shadings(Path pdf) throws Exception { + try (PDDocument doc = Loader.loadPDF(pdf.toFile())) { + PDResources resources = doc.getPage(0).getResources(); + List result = new ArrayList<>(); + for (COSName name : resources.getShadingNames()) { + result.add(resources.getShading(name)); + } + return result; + } + } + + @Test + void linearPaintEmitsAnAxialShading() throws Exception { + List shadings = shadings(render("linear", DocumentPaint.linear(TEAL, GOLD))); + + assertThat(shadings).hasSize(1); + assertThat(shadings.get(0).getShadingType()).isEqualTo(PDShading.SHADING_TYPE2); + } + + @Test + void radialPaintEmitsARadialShading() throws Exception { + DocumentPaint radial = new DocumentPaint.Radial(List.of( + new DocumentPaint.Stop(0.0, GOLD), + new DocumentPaint.Stop(1.0, TEAL)), 0.5, 0.5); + + List shadings = shadings(render("radial", radial)); + + assertThat(shadings).hasSize(1); + assertThat(shadings.get(0).getShadingType()).isEqualTo(PDShading.SHADING_TYPE3); + } + + @Test + void multiStopLinearUsesAStitchingFunctionAndRenders() throws Exception { + DocumentPaint threeStops = new DocumentPaint.Linear(List.of( + new DocumentPaint.Stop(0.0, TEAL), + new DocumentPaint.Stop(0.3, GOLD), + new DocumentPaint.Stop(1.0, DocumentColor.WHITE)), 90.0); + + assertThat(shadings(render("multistop", threeStops))).hasSize(1); + } + + @Test + void solidPaintEmitsNoShadingResources() throws Exception { + assertThat(shadings(render("solid", DocumentPaint.solid(TEAL)))).isEmpty(); + } +} diff --git a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java index d5831c2e..ad093706 100644 --- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java +++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java @@ -3,6 +3,7 @@ import com.demcha.compose.document.node.LineNode; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.ShapeNode; +import com.demcha.compose.document.style.DocumentPaint; import com.demcha.compose.document.style.DocumentTextStyle; import org.junit.jupiter.api.Test;