diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffbdbb00..adca5d7ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -256,6 +256,15 @@ Entries land here as they merge. - **`ChartStyle.paintForSeries` rejects a negative series index** with a value-naming `IllegalArgumentException` instead of leaking a bare `IndexOutOfBoundsException` from the palette modulo. +- **A translucent gradient stop is rejected instead of silently rendering + opaque.** Gradients render through PDF axial / radial shadings, which carry + no alpha channel, so `PdfShadingSupport` dropped a stop colour's alpha and a + translucent stop rendered fully opaque with no diagnostic. `DocumentPaint.Stop` + now rejects a colour with alpha below 255 at construction, naming the offending + alpha — flatten the transparency into the stop colour, or apply opacity to the + whole shape. This matches the SVG reader, which already refuses `stop-opacity`, + and reaches the `DocumentPaint.linear(from, to)` sugar too. Opaque gradients are + unaffected. - **SVG path reader no longer hangs on malformed `d` data.** A `Z`/`z` close command (which consumes no operands) followed by a stray non-command token — e.g. `"M0 0 Z5"` — made the scanner loop forever, 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 57ccb2778..39ab08c3f 100644 --- a/src/main/java/com/demcha/compose/document/style/DocumentPaint.java +++ b/src/main/java/com/demcha/compose/document/style/DocumentPaint.java @@ -223,18 +223,35 @@ private static void requireFinite(double value, String what) { /** * One gradient colour stop. * + *
The colour must be fully opaque. Gradients render through PDF axial / + * radial shadings, which carry no alpha channel, so a translucent stop + * would silently render opaque. Rather than render wrong, a stop with + * alpha below 255 is rejected at construction — flatten transparency into + * the stop colour, or apply opacity to the whole shape instead. This + * mirrors the SVG reader, which already refuses {@code stop-opacity}. + * * @param offset position along the gradient axis in [0,1] - * @param color colour at this offset + * @param color fully-opaque colour at this offset */ record Stop(double offset, DocumentColor color) { /** - * Validates the offset and colour. + * Validates the offset and the opaque-colour requirement. + * + * @throws IllegalArgumentException if {@code offset} is outside [0,1] + * or {@code color} is translucent */ public Stop { if (offset < 0 || offset > 1 || Double.isNaN(offset)) { throw new IllegalArgumentException("stop offset must be in [0,1]: " + offset); } Objects.requireNonNull(color, "color"); + int alpha = color.color().getAlpha(); + if (alpha != 255) { + throw new IllegalArgumentException( + "gradient stop colour must be opaque (alpha=" + alpha + "); shadings " + + "carry no alpha, so a translucent stop would render opaque — " + + "flatten transparency into the colour or apply opacity to the shape"); + } } } } diff --git a/src/test/java/com/demcha/compose/document/style/DocumentPaintTest.java b/src/test/java/com/demcha/compose/document/style/DocumentPaintTest.java new file mode 100644 index 000000000..7542a29db --- /dev/null +++ b/src/test/java/com/demcha/compose/document/style/DocumentPaintTest.java @@ -0,0 +1,61 @@ +package com.demcha.compose.document.style; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Value-level contract of {@link DocumentPaint.Stop}: the offset bounds and the + * opaque-colour requirement. Gradients render through PDF axial / radial + * shadings, which carry no alpha channel, so a translucent stop must fail at + * construction instead of silently rendering opaque — mirroring the SVG + * reader, which already refuses {@code stop-opacity}. + */ +class DocumentPaintTest { + + @Test + void opaqueStopIsAccepted() { + assertThatCode(() -> new DocumentPaint.Stop(0.5, DocumentColor.rgb(20, 80, 95))) + .doesNotThrowAnyException(); + // rgba with full alpha is still opaque. + assertThatCode(() -> new DocumentPaint.Stop(0.0, DocumentColor.rgba(10, 20, 30, 255))) + .doesNotThrowAnyException(); + } + + @Test + void translucentStopIsRejected() { + assertThatThrownBy(() -> new DocumentPaint.Stop(0.0, DocumentColor.rgb(20, 80, 95).withOpacity(0.5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opaque"); + assertThatThrownBy(() -> new DocumentPaint.Stop(1.0, DocumentColor.rgba(10, 20, 30, 0))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opaque"); + } + + @Test + void offsetOutsideUnitRangeIsRejected() { + assertThatThrownBy(() -> new DocumentPaint.Stop(-0.1, DocumentColor.BLACK)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("offset"); + assertThatThrownBy(() -> new DocumentPaint.Stop(1.5, DocumentColor.BLACK)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("offset"); + } + + @Test + void linearFactoryRejectsTranslucentEndpoints() { + // The two-colour sugar builds stops, so the opaque guard reaches it too. + assertThatThrownBy(() -> DocumentPaint.linear( + DocumentColor.rgb(20, 80, 95).withOpacity(0.4), DocumentColor.WHITE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("opaque"); + } + + @Test + void solidPaintExposesItsColor() { + DocumentColor teal = DocumentColor.rgb(20, 80, 95); + assertThat(DocumentPaint.solid(teal).primaryColor()).isEqualTo(teal); + } +}