From 061ab4352136b84125cdcb71745c01428b386211 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 18 Jul 2026 10:24:31 +0100
Subject: [PATCH 1/3] feat(render-pptx): keep provably no-op clips editable,
align gradient stops and handler contracts
---
CHANGELOG.md | 16 +
.../compose/document/style/DocumentColor.java | 5 +-
.../architecture/backend-capability-matrix.md | 5 +-
.../backend/fixed/pptx/PptxClipSafety.java | 308 ++++++++++++++++++
.../fixed/pptx/PptxFixedLayoutBackend.java | 42 ++-
.../fixed/pptx/handlers/PptxGradientFill.java | 13 +-
.../PptxLineFragmentRenderHandler.java | 3 +-
.../fixed/pptx/handlers/PptxShapeStyle.java | 2 +
.../backend/fixed/pptx/PptxAlphaTest.java | 82 +++++
.../fixed/pptx/PptxBackendContractTest.java | 12 +
.../pptx/PptxClipRasterFallbackTest.java | 32 ++
.../fixed/pptx/PptxClipSafetyTest.java | 252 ++++++++++++++
.../fixed/pptx/PptxVectorFragmentsTest.java | 36 ++
13 files changed, 796 insertions(+), 12 deletions(-)
create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxAlphaTest.java
create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a69833b40..f9f2e683f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -104,6 +104,22 @@ follow semantic versioning; release dates are ISO 8601.
byte-identical decks across runs by pinning the OPC created/modified
properties and normalizing every zip entry timestamp (zone-independent).
+- Provably no-op clips stay editable in the fixed PPTX backend: when every
+ child of a clip region sits safely inside the clip outline (the common
+ defensive-clip case — a rounded card whose padded content never reaches the
+ corners), the raster fallback is skipped and the region renders as native,
+ editable shapes. The proof is conservative — unprovable outline kinds
+ (polygons, free paths), transforms inside the region, stroked polygons and
+ paths (mitred joins), path segments outside the unit box, table rows, and
+ ink whose stroke or side-border bleed nears the boundary all keep the
+ pixel-exact raster path.
+- PPTX gradients pin their first and last stop offsets to the domain ends,
+ matching what the PDF shading functions actually paint — a partial-offset
+ ramp now shades identically in both formats.
+- `PptxFixedLayoutBackend.Builder.addHandler` rejects a duplicate custom
+ handler for the same payload type, matching the PDF builder's contract, and
+ documents that custom handlers do not apply inside rasterized clip
+ composites.
- `DocumentSession.toPptxBytes()` / `writePptx(OutputStream)` /
`buildPptx()` / `buildPptx(Path)` — the PPTX counterparts of the PDF
convenience trio. The backend resolves through the format-keyed provider
diff --git a/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java b/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java
index 7b1aab610..ea336cc26 100644
--- a/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java
+++ b/core/src/main/java/com/demcha/compose/document/style/DocumentColor.java
@@ -77,7 +77,10 @@ public static DocumentColor rgb(int red, int green, int blue) {
* rectangles/panels/bars, chart value-label halos, ellipses (chart point
* markers), polygons, and inline shapes — via a graphics-state alpha
* constant. Text, lines, and the DOCX backend currently render the colour
- * fully opaque.
+ * fully opaque. The PPTX backend carries the alpha natively on every
+ * surface — fills, strokes, text runs, and table paint — so a translucent
+ * colour on text or table cells shows through in the deck where the PDF
+ * still paints it opaque.
*
* @param red red channel from 0 to 255
* @param green green channel from 0 to 255
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index ca9365b8f..d9a7c86e2 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -56,11 +56,12 @@ Payload records live in `core` under
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ semantic images (`DocxSemanticBackend`) |
| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ |
| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ✅ semantic tables (`DocxSemanticBackend`) |
-| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ raster fallback in `PptxFixedLayoutBackend` — the clip region renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning |
+| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning |
| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning |
| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ |
| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ |
-| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState`) | 🚧 (native fill alpha) | ❌ |
+| Alpha / opacity | ⚠️ `PdfAlphaSupport` (`PDExtendedGraphicsState` on shape fills and strokes; text, lines, and table paint render opaque) | ✅ native `` via POI on every surface — fills, strokes, text runs, table paint | ❌ |
+| Text decorations — underline / strikethrough (`DocumentTextDecoration`) | ❌ (the decoration flags resolve to font faces, but no decoration lines are drawn) | ✅ `PptxTextFrames.applyStyle` | ❌ |
## Navigation and interactivity
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
new file mode 100644
index 000000000..2bc7fbe2a
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
@@ -0,0 +1,308 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload;
+import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload;
+import com.demcha.compose.document.layout.payloads.BarcodeFragmentPayload;
+import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ImageFragmentPayload;
+import com.demcha.compose.document.layout.payloads.LineFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
+import com.demcha.compose.document.layout.payloads.ParagraphSpan;
+import com.demcha.compose.document.layout.payloads.ParagraphTextSpan;
+import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload;
+import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload;
+import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload;
+import com.demcha.compose.document.layout.payloads.SideBorders;
+import com.demcha.compose.document.layout.payloads.TransformBeginPayload;
+import com.demcha.compose.document.layout.payloads.TransformEndPayload;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentLineCap;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+
+import java.util.List;
+
+/**
+ * Proves that a clip region cannot remove any ink — every child fragment sits
+ * safely inside the clip outline — so the raster fallback can be skipped and
+ * the region's children stay native, editable PowerPoint shapes. Most real
+ * documents clip defensively (a rounded card whose padded content never
+ * reaches the corners); rasterizing those regions costs editability for
+ * nothing.
+ *
+ * Conservative by construction: any doubt keeps the pixel-exact raster
+ * path. Provable ink is modelled exactly — smooth strokes bleed half their
+ * width past the fragment box, side borders likewise, unstroked polygon
+ * fills and in-unit-box path fills stay inside the box, rasters draw inside
+ * their box, and text gets a font-size-aware guard for glyph overhang.
+ * Everything else is unprovable and rasters: polygon/path outlines as clip
+ * shapes, stroked polygons and free paths (miter joins can spike far beyond
+ * half the stroke width), paths whose segments leave the unit box (the
+ * segment contract does not clamp them), table rows (border bleed is not
+ * modelled), transforms inside the region, and unknown payload types.
+ */
+final class PptxClipSafety {
+
+ /** Base inset for text payloads, whose ink can exceed the measured boxes. */
+ private static final double TEXT_GUARD = 2.0;
+
+ /** Glyph ink overhang (italics, swashes) as a fraction of the font size. */
+ private static final double GLYPH_OVERHANG_FACTOR = 0.15;
+
+ /** Ink extent that cannot be modelled — always keeps the raster path. */
+ private static final double UNPROVABLE = Double.POSITIVE_INFINITY;
+
+ /**
+ * Float-placement tolerance: ink exactly on the boundary is not cut, so an
+ * exact-fit child (an SVG path spanning its clip box) still proves safe.
+ */
+ private static final double PLACEMENT_TOLERANCE = 0.01;
+
+ private PptxClipSafety() {
+ }
+
+ /**
+ * Returns {@code true} when every renderable fragment between the clip
+ * markers provably stays inside the clip outline, so clipping is a visual
+ * no-op.
+ *
+ * @param clip the clip-begin payload (outline + policy)
+ * @param clipFragment the placed clip fragment (absolute outline box)
+ * @param fragments the page's fragment list
+ * @param beginIndex index of the clip-begin marker
+ * @param endIndex index of the matching clip-end marker
+ */
+ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip,
+ PlacedFragment clipFragment,
+ List fragments,
+ int beginIndex,
+ int endIndex) {
+ for (int index = beginIndex + 1; index < endIndex; index++) {
+ PlacedFragment child = fragments.get(index);
+ Object payload = child.payload();
+ if (payload instanceof TransformBeginPayload) {
+ // A rotated or scaled composite can carry ink far outside its
+ // children's untransformed boxes — never provable.
+ return false;
+ }
+ if (payload instanceof TransformEndPayload
+ || payload instanceof ShapeClipBeginPayload
+ || payload instanceof ShapeClipEndPayload
+ || payload instanceof AnchorMarkerPayload
+ || payload instanceof BookmarkMarkerPayload) {
+ // Markers draw nothing themselves; a nested clip's children
+ // are checked individually right here, and if the verdict is
+ // "no-op" the main loop re-dispatches the nested region on
+ // its own merits.
+ continue;
+ }
+ if (!safelyInside(clip, clipFragment, child)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean safelyInside(ShapeClipBeginPayload clip,
+ PlacedFragment clipFragment,
+ PlacedFragment child) {
+ double guard = requiredInset(child.payload()) - PLACEMENT_TOLERANCE;
+ if (Double.isNaN(guard)) {
+ // A NaN stroke width would make every comparison below false-pass.
+ return false;
+ }
+ double left = child.x() - clipFragment.x();
+ double bottom = child.y() - clipFragment.y();
+ double right = left + child.width();
+ double top = bottom + child.height();
+ double width = clipFragment.width();
+ double height = clipFragment.height();
+ if (left < guard || bottom < guard || right > width - guard || top > height - guard) {
+ return false;
+ }
+ if (clip.policy() != ClipPolicy.CLIP_PATH) {
+ // Bounds clipping is the plain rectangle the guard just proved.
+ return true;
+ }
+ ShapeOutline outline = clip.outline();
+ if (outline instanceof ShapeOutline.Rectangle) {
+ return true;
+ }
+ if (outline instanceof ShapeOutline.RoundedRectangle rounded) {
+ double radius = rounded.cornerRadius();
+ return avoidsCornerSquares(left, bottom, right, top, width, height,
+ radius, radius, radius, radius, guard);
+ }
+ if (outline instanceof ShapeOutline.RoundedRectanglePerCorner rounded) {
+ DocumentCornerRadius corners = rounded.corners();
+ return avoidsCornerSquares(left, bottom, right, top, width, height,
+ corners.bottomLeft(), corners.bottomRight(),
+ corners.topLeft(), corners.topRight(), guard);
+ }
+ if (outline instanceof ShapeOutline.Ellipse) {
+ // Minkowski: growing the ink box by the guard and testing against
+ // the true ellipse is exact; shrinking the ellipse's axes is not.
+ return insideEllipse(left - guard, bottom - guard, right + guard, top + guard,
+ width, height);
+ }
+ // Polygon and free-path outlines are not cheaply provable.
+ return false;
+ }
+
+ /**
+ * A rounded rectangle only removes ink inside the four corner squares
+ * (side = that corner's radius); a child avoiding all of them — with the
+ * guard — cannot be cut, even when it spans the full middle band.
+ */
+ private static boolean avoidsCornerSquares(double left, double bottom, double right, double top,
+ double width, double height,
+ double bottomLeft, double bottomRight,
+ double topLeft, double topRight,
+ double guard) {
+ return !intersects(left, bottom, right, top,
+ 0, 0, bottomLeft + guard, bottomLeft + guard)
+ && !intersects(left, bottom, right, top,
+ width - bottomRight - guard, 0, width, bottomRight + guard)
+ && !intersects(left, bottom, right, top,
+ 0, height - topLeft - guard, topLeft + guard, height)
+ && !intersects(left, bottom, right, top,
+ width - topRight - guard, height - topRight - guard, width, height);
+ }
+
+ /** Convexity: a box is inside the ellipse iff all four corners are. */
+ private static boolean insideEllipse(double left, double bottom, double right, double top,
+ double width, double height) {
+ double a = width / 2.0;
+ double b = height / 2.0;
+ if (a <= 0 || b <= 0 || left > right || bottom > top) {
+ return false;
+ }
+ return pointInsideEllipse(left, bottom, a, b, a, b)
+ && pointInsideEllipse(right, bottom, a, b, a, b)
+ && pointInsideEllipse(left, top, a, b, a, b)
+ && pointInsideEllipse(right, top, a, b, a, b);
+ }
+
+ private static boolean pointInsideEllipse(double x, double y,
+ double centerX, double centerY,
+ double a, double b) {
+ double dx = (x - centerX) / a;
+ double dy = (y - centerY) / b;
+ return dx * dx + dy * dy <= 1.0;
+ }
+
+ private static boolean intersects(double left, double bottom, double right, double top,
+ double squareLeft, double squareBottom,
+ double squareRight, double squareTop) {
+ return left < squareRight && right > squareLeft
+ && bottom < squareTop && top > squareBottom;
+ }
+
+ /**
+ * The distance the child's ink may extend beyond its fragment box, or
+ * {@link #UNPROVABLE} when it cannot be modelled. Smooth strokes and side
+ * borders are centred on their geometry and bleed half their width;
+ * mitred joins on stroked polygons and free paths can spike far beyond
+ * that, so only their unstroked fills are provable — and a path fill only
+ * when its segments (a conservative Bézier control-point hull) stay in
+ * the unit box, since the segment contract does not clamp them. Rasters
+ * draw inside their box; text gets a font-size-aware overhang guard.
+ */
+ private static double requiredInset(Object payload) {
+ if (payload instanceof ShapeFragmentPayload shape) {
+ double inset = halfWidth(shape.stroke());
+ SideBorders borders = shape.sideBorders();
+ if (borders != null) {
+ inset = Math.max(inset, halfWidth(borders.top()));
+ inset = Math.max(inset, halfWidth(borders.right()));
+ inset = Math.max(inset, halfWidth(borders.bottom()));
+ inset = Math.max(inset, halfWidth(borders.left()));
+ }
+ return inset;
+ }
+ if (payload instanceof EllipseFragmentPayload ellipse) {
+ return halfWidth(ellipse.stroke());
+ }
+ if (payload instanceof PolygonFragmentPayload polygon) {
+ return polygon.stroke() == null ? 0 : UNPROVABLE;
+ }
+ if (payload instanceof PathFragmentPayload path) {
+ return path.stroke() == null && segmentsInsideUnitBox(path.segments())
+ ? 0
+ : UNPROVABLE;
+ }
+ if (payload instanceof LineFragmentPayload line) {
+ // A projecting (square) cap on a diagonal line extends its corner
+ // up to halfWidth·√2 per axis beyond the geometry; butt and round
+ // caps stay within halfWidth.
+ double factor = line.lineCap() == DocumentLineCap.SQUARE ? Math.sqrt(2.0) : 1.0;
+ return halfWidth(line.stroke()) * factor;
+ }
+ if (payload instanceof ImageFragmentPayload || payload instanceof BarcodeFragmentPayload) {
+ return 0;
+ }
+ if (payload instanceof ParagraphFragmentPayload paragraph) {
+ return textGuard(paragraph);
+ }
+ // Table rows (border bleed unmodelled) and unknown payload types.
+ return UNPROVABLE;
+ }
+
+ /**
+ * Glyph ink can exceed the measured boxes — italic overhang and swashes
+ * scale with the font size — so the guard grows with the largest run.
+ */
+ private static double textGuard(ParagraphFragmentPayload paragraph) {
+ double maxSize = 0;
+ for (ParagraphLine line : paragraph.lines()) {
+ for (ParagraphSpan span : line.spans()) {
+ if (span instanceof ParagraphTextSpan text) {
+ maxSize = Math.max(maxSize, text.textStyle().size());
+ }
+ }
+ }
+ return Math.max(TEXT_GUARD, GLYPH_OVERHANG_FACTOR * maxSize);
+ }
+
+ /**
+ * Conservative unit-box containment: every explicit coordinate, including
+ * Bézier control points (which bound the curve's hull), must stay inside
+ * the normalized box. Any unknown future segment kind fails the proof.
+ */
+ private static boolean segmentsInsideUnitBox(List segments) {
+ for (DocumentPathSegment segment : segments) {
+ if (segment instanceof DocumentPathSegment.MoveTo move) {
+ if (!insideUnitBox(move.x(), move.y())) {
+ return false;
+ }
+ } else if (segment instanceof DocumentPathSegment.LineTo lineTo) {
+ if (!insideUnitBox(lineTo.x(), lineTo.y())) {
+ return false;
+ }
+ } else if (segment instanceof DocumentPathSegment.CubicTo cubic) {
+ if (!insideUnitBox(cubic.control1X(), cubic.control1Y())
+ || !insideUnitBox(cubic.control2X(), cubic.control2Y())
+ || !insideUnitBox(cubic.x(), cubic.y())) {
+ return false;
+ }
+ } else if (!(segment instanceof DocumentPathSegment.Close)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static boolean insideUnitBox(double x, double y) {
+ return x >= 0 && x <= 1 && y >= 0 && y <= 1;
+ }
+
+ private static double halfWidth(Stroke stroke) {
+ return stroke == null ? 0 : stroke.width() / 2.0;
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java
index 940cd26dd..e83c5e179 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java
@@ -468,8 +468,14 @@ void renderGraph(LayoutGraph graph,
* placed at the clip bounds, so the clip is pixel-exact even though
* DrawingML cannot express it. Fragment-level links, bookmarks, and
* anchors inside the region are still recorded; run-level link hotspots
- * inside a rasterized composite are not emitted. Returns the last
- * consumed index.
+ * inside a rasterized composite are not emitted, and custom fragment
+ * handlers do not apply inside it (the region renders through the PDF
+ * backend's defaults). Returns the last consumed index.
+ *
+ * When {@link PptxClipSafety} proves the clip cannot remove any ink —
+ * the common defensive-clip case, e.g. a rounded card whose padded
+ * content never reaches the corners — the raster fallback is skipped
+ * entirely and the children render as native, editable shapes.
*/
private int renderClippedComposite(LayoutGraph graph,
List fragments,
@@ -477,7 +483,8 @@ private int renderClippedComposite(LayoutGraph graph,
PptxRenderEnvironment environment,
FixedLayoutRenderContext context) throws Exception {
PlacedFragment beginFragment = fragments.get(beginIndex);
- String ownerPath = ((ShapeClipBeginPayload) beginFragment.payload()).ownerPath();
+ ShapeClipBeginPayload clipPayload = (ShapeClipBeginPayload) beginFragment.payload();
+ String ownerPath = clipPayload.ownerPath();
int endIndex = beginIndex + 1;
while (endIndex < fragments.size()
&& !(fragments.get(endIndex).payload() instanceof ShapeClipEndPayload end
@@ -493,6 +500,14 @@ private int renderClippedComposite(LayoutGraph graph,
recordRegionNavigation(fragments, beginIndex, endIndex, environment);
return endIndex;
}
+ if (PptxClipSafety.clipCannotRemoveInk(
+ clipPayload, beginFragment, fragments, beginIndex, endIndex)) {
+ // The clip provably cuts nothing: consume only the begin marker
+ // and let the main loop render the children as native, editable
+ // shapes (nested clips and table groups re-dispatch normally; the
+ // end marker is a silent no-op).
+ return beginIndex;
+ }
// Translate the region into a clip-sized canvas and rasterize only the
// clip box — the PDF backend applies the real clip, and the raster
@@ -776,13 +791,30 @@ public Builder rasterSlides(int dpi) {
/**
* Registers a custom fragment handler. A handler reporting the same
- * payload type as a built-in default replaces that default.
+ * payload type as a built-in default replaces that default; a second
+ * custom handler for the same payload type is rejected, matching the
+ * PDF builder's contract.
+ *
+ * Custom handlers do not apply inside rasterized clip composites —
+ * a clip region that cannot be proven a visual no-op renders through
+ * the PDF backend's default handlers into one picture.
*
* @param handler handler to register
* @return this builder
+ * @throws IllegalArgumentException if a custom handler for the same
+ * payload type is already registered
*/
public Builder addHandler(PptxFragmentRenderHandler> handler) {
- customHandlers.add(Objects.requireNonNull(handler, "handler"));
+ Objects.requireNonNull(handler, "handler");
+ for (PptxFragmentRenderHandler> existing : customHandlers) {
+ if (existing.payloadType().equals(handler.payloadType())) {
+ throw new IllegalArgumentException(
+ "Duplicate custom PPTX handler for payload type "
+ + handler.payloadType().getName()
+ + "; remove the previous addHandler() call before registering another");
+ }
+ }
+ customHandlers.add(handler);
return this;
}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java
index dbf9a3b0d..addf61c10 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java
@@ -119,9 +119,18 @@ private static void radialPath(CTGradientFillProperties fill, double cx, double
private static void stops(CTGradientFillProperties fill, List stops) {
var stopList = fill.isSetGsLst() ? fill.getGsLst() : fill.addNewGsLst();
- for (DocumentPaint.Stop stop : stops) {
+ for (int index = 0; index < stops.size(); index++) {
+ DocumentPaint.Stop stop = stops.get(index);
CTGradientStop gradientStop = stopList.addNewGs();
- gradientStop.setPos((int) Math.round(stop.offset() * 100000.0));
+ // The PDF backend's shading functions span the full domain — the
+ // first and last stop offsets are effectively 0 and 1, only the
+ // interior offsets survive as bounds. Pin the boundary stops the
+ // same way so a partial-offset ramp shades identically in both
+ // formats.
+ double offset = index == 0 ? 0.0
+ : index == stops.size() - 1 ? 1.0
+ : stop.offset();
+ gradientStop.setPos((int) Math.round(offset * 100000.0));
// Stop colors are opaque by contract (DocumentPaint.Stop rejects
// alpha), so only the RGB channels travel.
Color color = stop.color().color();
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
index d9d902b0c..b6ee6f213 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
@@ -61,8 +61,7 @@ public void render(PlacedFragment fragment,
// The preset always draws top-left → bottom-right inside the anchor;
// flip vertically when the segment ascends left-to-right.
line.setFlipVertical((x2 - x1) * (y2 - y1) < 0);
- line.setLineColor(stroke.strokeColor().color());
- line.setLineWidth(stroke.width());
+ PptxShapeStyle.applyStroke(line, stroke);
PptxShapeStyle.applyLineCap(line, payload.lineCap());
PptxShapeStyle.applyDashPattern(line, payload.dashPattern());
}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
index 8019b4a6c..10c30c134 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
@@ -41,6 +41,8 @@ static void applyStroke(XSLFSimpleShape shape, Stroke stroke) {
shape.setLineColor(null);
return;
}
+ // POI's setLineColor writes the color's alpha as an child
+ // in the XML; only its getLineColor getter hides it on read-back.
shape.setLineColor(stroke.strokeColor().color());
shape.setLineWidth(stroke.width());
}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxAlphaTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxAlphaTest.java
new file mode 100644
index 000000000..923a847ea
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxAlphaTest.java
@@ -0,0 +1,82 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFAutoShape;
+import org.apache.poi.xslf.usermodel.XSLFTextBox;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTRegularTextRun;
+
+import java.io.ByteArrayInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Translucent colors travel natively into DrawingML: POI writes an
+ * {@code } child for any color whose alpha is below 255, so the
+ * capability matrix's "native alpha on every surface" claim is pinned here
+ * for shape fills and text runs rather than trusted implicitly.
+ */
+class PptxAlphaTest {
+
+ @Test
+ void translucentFillAndTextCarryNativeAlpha() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ // Table cell fills travel through the identical XSLFAutoShape
+ // setFillColor path this shape pins, so the matrix's "table
+ // paint" claim rides on the same mechanism.
+ session.add(session.dsl().shape().name("Veil").size(120, 40)
+ .fillColor(DocumentColor.rgba(20, 80, 95, 128))
+ .stroke(com.demcha.compose.document.style.DocumentStroke.of(
+ DocumentColor.rgba(10, 10, 10, 64), 2))
+ .build());
+ session.add(new ParagraphBuilder().name("Ghost")
+ .text("watermark-ish")
+ .textStyle(DocumentTextStyle.builder()
+ .color(DocumentColor.rgba(0, 0, 0, 128))
+ .build())
+ .build());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ XSLFAutoShape veil = show.getSlides().get(0).getShapes().stream()
+ .filter(XSLFAutoShape.class::isInstance)
+ .map(XSLFAutoShape.class::cast)
+ .findFirst().orElseThrow();
+ assertThat(veil.getFillColor().getAlpha())
+ .as("shape fill alpha survives the POI round-trip")
+ .isEqualTo(128);
+ // POI's getLineColor ignores the alpha child, so the stroke
+ // claim is pinned at the XML level PowerPoint actually reads.
+ var strokeColor = ((org.openxmlformats.schemas.presentationml.x2006.main.CTShape)
+ veil.getXmlObject()).getSpPr().getLn().getSolidFill().getSrgbClr();
+ assertThat(strokeColor.sizeOfAlphaArray())
+ .as("stroke color must carry an explicit alpha child")
+ .isEqualTo(1);
+ assertThat(((Number) strokeColor.getAlphaArray(0).getVal()).intValue())
+ .isBetween(24_000, 26_000);
+
+ XSLFTextBox ghost = show.getSlides().get(0).getShapes().stream()
+ .filter(XSLFTextBox.class::isInstance)
+ .map(XSLFTextBox.class::cast)
+ .findFirst().orElseThrow();
+ CTRegularTextRun run = (CTRegularTextRun) ghost.getTextParagraphs().get(0)
+ .getTextRuns().get(0).getXmlObject();
+ var color = run.getRPr().getSolidFill().getSrgbClr();
+ assertThat(color.sizeOfAlphaArray())
+ .as("text run color must carry an explicit alpha child")
+ .isEqualTo(1);
+ // 128/255 in DrawingML thousandths-of-a-percent, rounding-tolerant.
+ assertThat(((Number) color.getAlphaArray(0).getVal()).intValue())
+ .isBetween(49_000, 51_000);
+ }
+ }
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java
index 200ca602c..d534f37a7 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java
@@ -36,6 +36,18 @@ void builderRejectsNullChromeEntriesAndTimestamp() {
.hasMessageContaining("timestamp");
}
+ @Test
+ void aDuplicateCustomHandlerIsRejected() {
+ PptxFixedLayoutBackend.Builder builder = PptxFixedLayoutBackend.builder()
+ .addHandler(new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxEllipseFragmentRenderHandler());
+ assertThatThrownBy(() -> builder.addHandler(
+ new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxEllipseFragmentRenderHandler()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Duplicate custom PPTX handler");
+ }
+
@Test
void renderToImagesPointsAtThePdfBackend() throws Exception {
try (DocumentSession session = composeOnePage()) {
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java
index 64c69c150..c9d1d377e 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java
@@ -163,6 +163,38 @@ void nestedClipOfADifferentOwnerIsConsumedIntoOneComposite() throws Exception {
}
}
+ @Test
+ void aProvablyNoOpClipStaysNativeAndEditable() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 240)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ // A defensive clip: the centered circle never reaches the card's
+ // corners, so clipping removes no ink and the raster fallback is
+ // skipped — the card and its content stay editable vector shapes.
+ session.add(new ShapeContainerBuilder()
+ .outline(new com.demcha.compose.document.style.ShapeOutline
+ .RoundedRectangle(80, 44, 8))
+ .clipPolicy(com.demcha.compose.document.style.ClipPolicy.CLIP_PATH)
+ .fillColor(DocumentColor.LIGHT_GRAY)
+ .center(new EllipseBuilder().circle(16)
+ .fillColor(DocumentColor.ROYAL_BLUE).build())
+ .build());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ List shapes = show.getSlides().get(0).getShapes();
+ assertThat(shapes)
+ .noneMatch(shape -> "GraphCompose Clipped Composite"
+ .equals(shape.getShapeName()));
+ assertThat(shapes.stream()
+ .filter(org.apache.poi.xslf.usermodel.XSLFAutoShape.class::isInstance)
+ .count())
+ .as("card fill and its layer circle render as native shapes")
+ .isEqualTo(2);
+ }
+ }
+ }
+
@Test
void disablingTheFallbackRendersUnclippedVectors() throws Exception {
try (DocumentSession session = composeClippedBadge()) {
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
new file mode 100644
index 000000000..b0ee8219b
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
@@ -0,0 +1,252 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.EllipseFragmentPayload;
+import com.demcha.compose.document.layout.payloads.LineFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload;
+import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload;
+import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload;
+import com.demcha.compose.document.layout.payloads.SideBorders;
+import com.demcha.compose.document.layout.payloads.TransformBeginPayload;
+import com.demcha.compose.document.layout.payloads.TransformEndPayload;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentCornerRadius;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.DocumentTransform;
+import com.demcha.compose.document.style.ShapePoint;
+import com.demcha.compose.document.style.ShapeOutline;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+import org.junit.jupiter.api.Test;
+
+import java.awt.Color;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The no-op-clip proof: children safely inside the outline keep the clip out
+ * of the raster fallback, and every unprovable situation — outline kinds
+ * without a cheap containment test, transforms, edge-hugging ink, wide
+ * strokes near the boundary — conservatively keeps it.
+ */
+class PptxClipSafetyTest {
+
+ private static final double CLIP_X = 100;
+ private static final double CLIP_Y = 50;
+ private static final double CLIP_W = 80;
+ private static final double CLIP_H = 60;
+
+ private static PlacedFragment clipFragment(ShapeOutline outline, ClipPolicy policy) {
+ return new PlacedFragment("root/card", 0, 0, CLIP_X, CLIP_Y, CLIP_W, CLIP_H,
+ null, null, new ShapeClipBeginPayload(outline, policy, "root/card"));
+ }
+
+ /** Child box in clip-local coordinates, with an arbitrary payload. */
+ private static PlacedFragment child(double x, double y, double width, double height, Object payload) {
+ return new PlacedFragment("root/card/child", 0, 0,
+ CLIP_X + x, CLIP_Y + y, width, height, null, null, payload);
+ }
+
+ private static boolean verdict(PlacedFragment clip, PlacedFragment... children) {
+ List fragments = new ArrayList<>();
+ fragments.add(clip);
+ fragments.addAll(List.of(children));
+ fragments.add(new PlacedFragment("root/card", 1, 0, CLIP_X, CLIP_Y, CLIP_W, CLIP_H,
+ null, null, new ShapeClipEndPayload("root/card")));
+ return PptxClipSafety.clipCannotRemoveInk(
+ (ShapeClipBeginPayload) clip.payload(), clip,
+ fragments, 0, fragments.size() - 1);
+ }
+
+ private static Object ellipsePayload(double strokeWidth) {
+ return new EllipseFragmentPayload(Color.BLUE,
+ strokeWidth > 0 ? new Stroke(Color.BLACK, strokeWidth) : null, null, null);
+ }
+
+ @Test
+ void paddedChildrenInsideARectangleAreProvablySafe() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ assertThat(verdict(clip, child(10, 10, 40, 30, ellipsePayload(0)))).isTrue();
+ }
+
+ @Test
+ void exactFitVectorInkIsSafeButOverflowAndStrokeBleedAreNot() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // Unstroked vector ink stays inside its box by construction, so even
+ // an exact-fit child (an SVG path spanning its clip box) is provably
+ // safe; overflowing ink and boundary-crossing stroke bleed are not.
+ assertThat(verdict(clip, child(0, 0, CLIP_W, CLIP_H, ellipsePayload(0)))).isTrue();
+ assertThat(verdict(clip, child(-1, 10, 40, 30, ellipsePayload(0)))).isFalse();
+ assertThat(verdict(clip, child(50, 10, 40, 30, ellipsePayload(0)))).isFalse();
+ assertThat(verdict(clip, child(0, 10, 40, 30, ellipsePayload(2)))).isFalse();
+ }
+
+ @Test
+ void unknownPayloadInkIsUnprovableAnywhere() {
+ record MysteryPayload() {
+ }
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // Unknown ink extents cannot be modelled — even a deeply padded
+ // placement keeps the raster path.
+ assertThat(verdict(clip, child(20, 15, 40, 30, new MysteryPayload()))).isFalse();
+ }
+
+ @Test
+ void mitreProneAndUnclampedInkIsUnprovable() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // Stroked polygons and paths can spike past half the stroke width at
+ // mitred joins; path segments are not clamped to the unit box, so an
+ // out-of-box coordinate draws outside the fragment bounds.
+ PolygonFragmentPayload strokedDiamond = new PolygonFragmentPayload(
+ List.of(new ShapePoint(0.5, 0), new ShapePoint(1, 0.5),
+ new ShapePoint(0.5, 1), new ShapePoint(0, 0.5)),
+ Color.ORANGE, new Stroke(Color.BLACK, 1), null, null);
+ assertThat(verdict(clip, child(20, 15, 30, 20, strokedDiamond))).isFalse();
+
+ PolygonFragmentPayload filledDiamond = new PolygonFragmentPayload(
+ List.of(new ShapePoint(0.5, 0), new ShapePoint(1, 0.5),
+ new ShapePoint(0.5, 1), new ShapePoint(0, 0.5)),
+ Color.ORANGE, null, null, null);
+ assertThat(verdict(clip, child(20, 15, 30, 20, filledDiamond))).isTrue();
+
+ PathFragmentPayload inBoxFill = new PathFragmentPayload(
+ List.of(new DocumentPathSegment.MoveTo(0, 0),
+ new DocumentPathSegment.LineTo(1, 0),
+ new DocumentPathSegment.LineTo(1, 1),
+ new DocumentPathSegment.Close()),
+ Color.ORANGE, null, null, null, null, null, null, null, null);
+ assertThat(verdict(clip, child(20, 15, 30, 20, inBoxFill))).isTrue();
+
+ PathFragmentPayload overflowingFill = new PathFragmentPayload(
+ List.of(new DocumentPathSegment.MoveTo(0, 0),
+ new DocumentPathSegment.LineTo(1.5, 0),
+ new DocumentPathSegment.LineTo(1, 1),
+ new DocumentPathSegment.Close()),
+ Color.ORANGE, null, null, null, null, null, null, null, null);
+ assertThat(verdict(clip, child(20, 15, 30, 20, overflowingFill))).isFalse();
+
+ PathFragmentPayload strokedFill = new PathFragmentPayload(
+ List.of(new DocumentPathSegment.MoveTo(0, 0),
+ new DocumentPathSegment.LineTo(1, 0),
+ new DocumentPathSegment.LineTo(1, 1),
+ new DocumentPathSegment.Close()),
+ Color.ORANGE, null, new Stroke(Color.BLACK, 1), null,
+ null, null, null, null, null);
+ assertThat(verdict(clip, child(20, 15, 30, 20, strokedFill))).isFalse();
+ }
+
+ @Test
+ void sideBordersWidenTheShapeInset() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ ShapeFragmentPayload accented = new ShapeFragmentPayload(
+ Color.LIGHT_GRAY, null, null, null, null,
+ new SideBorders(null, null, null, new Stroke(Color.BLACK, 6)), null);
+ // 1pt from the edge: the 6pt left accent border bleeds 3pt out.
+ assertThat(verdict(clip, child(1, 10, 40, 30, accented))).isFalse();
+ assertThat(verdict(clip, child(4, 10, 40, 30, accented))).isTrue();
+ }
+
+ @Test
+ void aWideStrokeNearTheBoundaryWidensTheInset() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // 3pt from the edge: a 6pt stroke reaches exactly the boundary (still
+ // uncut), an 8pt stroke crosses it.
+ assertThat(verdict(clip, child(3, 10, 40, 30, ellipsePayload(0)))).isTrue();
+ assertThat(verdict(clip, child(3, 10, 40, 30, ellipsePayload(6)))).isTrue();
+ assertThat(verdict(clip, child(3, 10, 40, 30, ellipsePayload(8)))).isFalse();
+ }
+
+ @Test
+ void roundedCornersOnlyForbidTheCornerSquares() {
+ PlacedFragment clip = clipFragment(
+ new ShapeOutline.RoundedRectangle(CLIP_W, CLIP_H, 8), ClipPolicy.CLIP_PATH);
+ // Full-width middle band clears every corner square.
+ assertThat(verdict(clip, child(3, 20, CLIP_W - 6, 20, ellipsePayload(0)))).isTrue();
+ // Bottom-left corner square is cut territory.
+ assertThat(verdict(clip, child(3, 3, 10, 10, ellipsePayload(0)))).isFalse();
+ }
+
+ @Test
+ void perCornerRadiiForbidOnlyTheRoundedCorners() {
+ PlacedFragment clip = clipFragment(
+ new ShapeOutline.RoundedRectanglePerCorner(CLIP_W, CLIP_H,
+ DocumentCornerRadius.right(12)), ClipPolicy.CLIP_PATH);
+ // Square left corners admit corner-adjacent ink; rounded right ones don't.
+ assertThat(verdict(clip, child(3, 3, 10, 10, ellipsePayload(0)))).isTrue();
+ assertThat(verdict(clip, child(CLIP_W - 13, 3, 10, 10, ellipsePayload(0)))).isFalse();
+ }
+
+ @Test
+ void perCornerRadiiMapTopAndBottomToTheYUpConvention() {
+ // Only the visual TOP-left corner is rounded: in the engine's y-up
+ // space that is the high-y corner. A top/bottom swap in the corner
+ // mapping would flip both verdicts.
+ PlacedFragment clip = clipFragment(
+ new ShapeOutline.RoundedRectanglePerCorner(CLIP_W, CLIP_H,
+ DocumentCornerRadius.of(12, 0, 0, 0)), ClipPolicy.CLIP_PATH);
+ assertThat(verdict(clip, child(3, CLIP_H - 13, 10, 10, ellipsePayload(0))))
+ .as("ink in the rounded visual top-left corner").isFalse();
+ assertThat(verdict(clip, child(3, 3, 10, 10, ellipsePayload(0))))
+ .as("ink in the square visual bottom-left corner").isTrue();
+ }
+
+ @Test
+ void ellipseContainmentChecksTheCorners() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Ellipse(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // Small centered box: corners inside the ellipse.
+ assertThat(verdict(clip, child(30, 22, 20, 16, ellipsePayload(0)))).isTrue();
+ // Corner-region box: inside the bounding box, outside the ellipse.
+ assertThat(verdict(clip, child(4, 4, 12, 10, ellipsePayload(0)))).isFalse();
+ }
+
+ @Test
+ void boundsPolicyIgnoresTheOutlineShape() {
+ // CLIP_BOUNDS clips to the rectangle even for an ellipse outline, so
+ // the corner-region box that fails CLIP_PATH is safe here.
+ PlacedFragment clip = clipFragment(new ShapeOutline.Ellipse(CLIP_W, CLIP_H), ClipPolicy.CLIP_BOUNDS);
+ assertThat(verdict(clip, child(4, 4, 12, 10, ellipsePayload(0)))).isTrue();
+ }
+
+ @Test
+ void unprovableOutlinesAndTransformsStayOnTheRasterPath() {
+ PlacedFragment polygonClip = clipFragment(
+ ShapeOutline.diamond(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ assertThat(verdict(polygonClip, child(30, 25, 10, 8, ellipsePayload(0)))).isFalse();
+
+ PlacedFragment rectClip = clipFragment(
+ new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ assertThat(verdict(rectClip,
+ child(20, 20, 10, 8, new TransformBeginPayload(
+ DocumentTransform.rotate(30), "root/card/spin")),
+ child(22, 22, 6, 4, ellipsePayload(0)),
+ child(20, 20, 10, 8, new TransformEndPayload("root/card/spin"))))
+ .isFalse();
+ }
+
+ @Test
+ void markersThemselvesDoNotBlockTheProof() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // A nested clip whose markers sit at the region edge draws nothing
+ // itself; only its renderable children are judged.
+ assertThat(verdict(clip,
+ child(0, 0, CLIP_W, CLIP_H, new ShapeClipBeginPayload(
+ new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH, "root/card/inner")),
+ child(10, 10, 20, 15, ellipsePayload(0)),
+ child(0, 0, CLIP_W, CLIP_H, new ShapeClipEndPayload("root/card/inner"))))
+ .isTrue();
+ }
+
+ @Test
+ void lineThicknessCountsAsStrokeBleed() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ LineFragmentPayload thin = new LineFragmentPayload(
+ new Stroke(Color.BLACK, 1), 0, 0, CLIP_W - 8, 0, null, null);
+ LineFragmentPayload fat = new LineFragmentPayload(
+ new Stroke(Color.BLACK, 10), 0, 0, CLIP_W - 8, 0, null, null);
+ assertThat(verdict(clip, child(4, 30, CLIP_W - 8, 0, thin))).isTrue();
+ assertThat(verdict(clip, child(4, 30, CLIP_W - 8, 0, fat))).isFalse();
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java
index 25a6949d5..eda7b75d5 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java
@@ -193,6 +193,42 @@ void polygonAndGradientPathRenderAsFreeforms() throws Exception {
}
}
+ @Test
+ void gradientBoundaryStopsArePinnedToTheDomainLikeThePdfShading() throws Exception {
+ try (XMLSlideShow show = new XMLSlideShow();
+ PdfMeasurementResources measurement = PdfMeasurementResources.open(List.of())) {
+ PptxRenderSession session = new PptxRenderSession(show, 300, 200, 1);
+ PptxRenderEnvironment environment = new PptxRenderEnvironment(
+ show, session, 0, 200, measurement.fontLibrary(), List.of());
+
+ // The PDF shading functions ignore the first/last stop offsets
+ // (they span the full domain); the deck must shade identically,
+ // so 0.3 / 0.5 / 0.9 lands as 0 / 0.5 / 1.
+ PathFragmentPayload ramp = new PathFragmentPayload(
+ List.of(new DocumentPathSegment.MoveTo(0, 0),
+ new DocumentPathSegment.LineTo(1, 0),
+ new DocumentPathSegment.LineTo(1, 1),
+ new DocumentPathSegment.Close()),
+ null,
+ new DocumentPaint.Linear(List.of(
+ new DocumentPaint.Stop(0.3, DocumentColor.ROYAL_BLUE),
+ new DocumentPaint.Stop(0.5, DocumentColor.ORANGE),
+ new DocumentPaint.Stop(0.9, DocumentColor.DARK_GRAY)), 0.0),
+ null, null, null, null, null, null, null);
+ new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxPathFragmentRenderHandler()
+ .render(new PlacedFragment("root/ramp", 0, 0, 40, 30, 120, 40, null, null,
+ ramp), ramp, environment);
+
+ var properties = ((CTShape) show.getSlides().get(0).getShapes().get(0).getXmlObject()).getSpPr();
+ var stopArray = properties.getGradFill().getGsLst().getGsArray();
+ assertThat(stopArray.length).isEqualTo(3);
+ assertThat(stopArray[0].getPos()).isEqualTo(0);
+ assertThat(stopArray[1].getPos()).isEqualTo(50000);
+ assertThat(stopArray[2].getPos()).isEqualTo(100000);
+ }
+ }
+
@Test
void transformMarkersBecomeRotatedScaledGroups() throws Exception {
try (DocumentSession session = GraphCompose.document()
From 559faeefc0d95d80d2dd659d34e4e87381d9b4e0 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 19 Jul 2026 01:41:41 +0100
Subject: [PATCH 2/3] fix(render-pptx): judge nested clip regions by their
confining box in the no-op proof
---
.../backend/fixed/pptx/PptxClipSafety.java | 46 ++++++++++++++++---
.../fixed/pptx/PptxClipSafetyTest.java | 22 +++++++++
2 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
index 2bc7fbe2a..98890cb53 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
@@ -91,15 +91,24 @@ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip,
// children's untransformed boxes — never provable.
return false;
}
+ if (payload instanceof ShapeClipBeginPayload nested) {
+ // A nested clip region confines its ink to its own clip box —
+ // either as a raster composite anchored exactly on those
+ // bounds, or as children its own proof kept inside — so the
+ // outer verdict judges just that box and skips the subregion.
+ // The main loop re-dispatches the nested clip on its own
+ // merits afterwards, at its own (finer) granularity.
+ if (!insideOutline(clip, clipFragment, child, -PLACEMENT_TOLERANCE)) {
+ return false;
+ }
+ index = matchingEnd(fragments, index, nested.ownerPath(), endIndex);
+ continue;
+ }
if (payload instanceof TransformEndPayload
- || payload instanceof ShapeClipBeginPayload
|| payload instanceof ShapeClipEndPayload
|| payload instanceof AnchorMarkerPayload
|| payload instanceof BookmarkMarkerPayload) {
- // Markers draw nothing themselves; a nested clip's children
- // are checked individually right here, and if the verdict is
- // "no-op" the main loop re-dispatches the nested region on
- // its own merits.
+ // Markers draw nothing themselves.
continue;
}
if (!safelyInside(clip, clipFragment, child)) {
@@ -109,10 +118,35 @@ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip,
return true;
}
+ /**
+ * Returns the index of the nested region's matching end marker (found by
+ * owner path), or {@code stopIndex} when the pairing is broken — the scan
+ * then simply runs out, which errs on checking too much, never too little.
+ */
+ private static int matchingEnd(List fragments,
+ int nestedBeginIndex,
+ String ownerPath,
+ int stopIndex) {
+ for (int index = nestedBeginIndex + 1; index < stopIndex; index++) {
+ if (fragments.get(index).payload() instanceof ShapeClipEndPayload end
+ && ownerPath.equals(end.ownerPath())) {
+ return index;
+ }
+ }
+ return stopIndex;
+ }
+
private static boolean safelyInside(ShapeClipBeginPayload clip,
PlacedFragment clipFragment,
PlacedFragment child) {
- double guard = requiredInset(child.payload()) - PLACEMENT_TOLERANCE;
+ return insideOutline(clip, clipFragment, child,
+ requiredInset(child.payload()) - PLACEMENT_TOLERANCE);
+ }
+
+ private static boolean insideOutline(ShapeClipBeginPayload clip,
+ PlacedFragment clipFragment,
+ PlacedFragment child,
+ double guard) {
if (Double.isNaN(guard)) {
// A NaN stroke width would make every comparison below false-pass.
return false;
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
index b0ee8219b..d31e8692b 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
@@ -226,6 +226,28 @@ void unprovableOutlinesAndTransformsStayOnTheRasterPath() {
.isFalse();
}
+ @Test
+ void aNestedClipConfinesItsUnprovableChildrenToItsOwnBox() {
+ PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
+ // The nested icon clip contains an unprovable child (a stroked
+ // diamond), but the nested region confines its ink to its own box —
+ // the outer verdict judges just that box, so the outer region stays
+ // native and only the icon rasters at its own 16x16 granularity.
+ PlacedFragment nestedBegin = child(20, 20, 16, 16, new ShapeClipBeginPayload(
+ new ShapeOutline.Rectangle(16, 16), ClipPolicy.CLIP_PATH, "root/card/icon"));
+ PlacedFragment unprovableIcon = child(20, 20, 16, 16, new PolygonFragmentPayload(
+ List.of(new ShapePoint(0.5, 0), new ShapePoint(1, 0.5),
+ new ShapePoint(0.5, 1), new ShapePoint(0, 0.5)),
+ Color.ORANGE, new Stroke(Color.BLACK, 1), null, null));
+ PlacedFragment nestedEnd = child(20, 20, 0, 0, new ShapeClipEndPayload("root/card/icon"));
+ assertThat(verdict(clip, nestedBegin, unprovableIcon, nestedEnd)).isTrue();
+
+ // A nested clip box that itself leaves the outer outline still fails.
+ PlacedFragment overflowingBegin = child(CLIP_W - 8, 20, 16, 16, new ShapeClipBeginPayload(
+ new ShapeOutline.Rectangle(16, 16), ClipPolicy.CLIP_PATH, "root/card/icon"));
+ assertThat(verdict(clip, overflowingBegin, unprovableIcon, nestedEnd)).isFalse();
+ }
+
@Test
void markersThemselvesDoNotBlockTheProof() {
PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
From abd15efd0ebba9ea92b64ac8eca78157958f3cbb Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sun, 19 Jul 2026 02:02:23 +0100
Subject: [PATCH 3/3] fix(render-pptx): fail the no-op clip proof on a broken
nested pairing
---
CHANGELOG.md | 13 ++++++-----
.../backend/fixed/pptx/PptxClipSafety.java | 21 +++++++++++++-----
.../fixed/pptx/PptxClipSafetyTest.java | 22 +++++++++++++++++--
3 files changed, 44 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9f2e683f..c3dfe963d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -108,11 +108,14 @@ follow semantic versioning; release dates are ISO 8601.
child of a clip region sits safely inside the clip outline (the common
defensive-clip case — a rounded card whose padded content never reaches the
corners), the raster fallback is skipped and the region renders as native,
- editable shapes. The proof is conservative — unprovable outline kinds
- (polygons, free paths), transforms inside the region, stroked polygons and
- paths (mitred joins), path segments outside the unit box, table rows, and
- ink whose stroke or side-border bleed nears the boundary all keep the
- pixel-exact raster path.
+ editable shapes. Nested clip regions are judged by their confining clip box
+ and re-dispatch at their own granularity, so an unprovable icon inside a
+ card no longer rasterizes the whole card — only the icon. The proof is
+ conservative — unprovable outline kinds (polygons, free paths), transforms
+ inside the region, stroked polygons and paths (mitred joins), path segments
+ outside the unit box, table rows, broken nested pairings, and ink whose
+ stroke or side-border bleed nears the boundary all keep the pixel-exact
+ raster path.
- PPTX gradients pin their first and last stop offsets to the domain ends,
matching what the PDF shading functions actually paint — a partial-offset
ramp now shades identically in both formats.
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
index 98890cb53..cc4255b6f 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafety.java
@@ -61,6 +61,8 @@ final class PptxClipSafety {
/**
* Float-placement tolerance: ink exactly on the boundary is not cut, so an
* exact-fit child (an SVG path spanning its clip box) still proves safe.
+ * Nested clip regions apply it once per nesting level, so the worst-case
+ * acceptance is 0.01pt × depth — far below a pixel at any raster scale.
*/
private static final double PLACEMENT_TOLERANCE = 0.01;
@@ -70,7 +72,9 @@ private PptxClipSafety() {
/**
* Returns {@code true} when every renderable fragment between the clip
* markers provably stays inside the clip outline, so clipping is a visual
- * no-op.
+ * no-op. Nested clip regions are judged by their confining clip box alone
+ * (their ink never leaves it — see the in-loop note) and re-dispatch at
+ * their own granularity; a broken nested pairing fails the proof.
*
* @param clip the clip-begin payload (outline + policy)
* @param clipFragment the placed clip fragment (absolute outline box)
@@ -101,7 +105,14 @@ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip,
if (!insideOutline(clip, clipFragment, child, -PLACEMENT_TOLERANCE)) {
return false;
}
- index = matchingEnd(fragments, index, nested.ownerPath(), endIndex);
+ int nestedEnd = matchingEnd(fragments, index, nested.ownerPath(), endIndex);
+ if (nestedEnd < 0) {
+ // Broken pairing: the backend's defensive branch renders
+ // an unmatched nested region's children unclipped, so
+ // nothing confines their ink — unprovable.
+ return false;
+ }
+ index = nestedEnd;
continue;
}
if (payload instanceof TransformEndPayload
@@ -120,8 +131,8 @@ static boolean clipCannotRemoveInk(ShapeClipBeginPayload clip,
/**
* Returns the index of the nested region's matching end marker (found by
- * owner path), or {@code stopIndex} when the pairing is broken — the scan
- * then simply runs out, which errs on checking too much, never too little.
+ * owner path), or {@code -1} when the pairing is broken before
+ * {@code stopIndex} — the caller then fails the proof.
*/
private static int matchingEnd(List fragments,
int nestedBeginIndex,
@@ -133,7 +144,7 @@ private static int matchingEnd(List fragments,
return index;
}
}
- return stopIndex;
+ return -1;
}
private static boolean safelyInside(ShapeClipBeginPayload clip,
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
index d31e8692b..a133809e6 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipSafetyTest.java
@@ -246,13 +246,31 @@ void aNestedClipConfinesItsUnprovableChildrenToItsOwnBox() {
PlacedFragment overflowingBegin = child(CLIP_W - 8, 20, 16, 16, new ShapeClipBeginPayload(
new ShapeOutline.Rectangle(16, 16), ClipPolicy.CLIP_PATH, "root/card/icon"));
assertThat(verdict(clip, overflowingBegin, unprovableIcon, nestedEnd)).isFalse();
+
+ // A nested begin without its matching end fails the proof: the
+ // backend's defensive branch would render that subregion unclipped,
+ // so nothing confines its ink.
+ assertThat(verdict(clip, nestedBegin, unprovableIcon)).isFalse();
+
+ // A degenerate nested box (the zero-area clip removes all its ink)
+ // passes trivially, whatever it contains.
+ PlacedFragment degenerateBegin = child(20, 20, 0, 0, new ShapeClipBeginPayload(
+ new ShapeOutline.Rectangle(16, 16), ClipPolicy.CLIP_PATH, "root/card/icon"));
+ assertThat(verdict(clip, degenerateBegin, unprovableIcon, nestedEnd)).isTrue();
+
+ // Nested-inside-nested: the outer verdict still judges only the
+ // outermost nested box; the deeper pairing resolves by owner path.
+ PlacedFragment innerBegin = child(22, 22, 8, 8, new ShapeClipBeginPayload(
+ new ShapeOutline.Rectangle(8, 8), ClipPolicy.CLIP_PATH, "root/card/icon/glyph"));
+ PlacedFragment innerEnd = child(22, 22, 0, 0, new ShapeClipEndPayload("root/card/icon/glyph"));
+ assertThat(verdict(clip, nestedBegin, innerBegin, unprovableIcon, innerEnd, nestedEnd)).isTrue();
}
@Test
void markersThemselvesDoNotBlockTheProof() {
PlacedFragment clip = clipFragment(new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH);
- // A nested clip whose markers sit at the region edge draws nothing
- // itself; only its renderable children are judged.
+ // An exact-fit nested clip region is judged by its box (which fills
+ // the outer outline exactly — safe) and its subregion is skipped.
assertThat(verdict(clip,
child(0, 0, CLIP_W, CLIP_H, new ShapeClipBeginPayload(
new ShapeOutline.Rectangle(CLIP_W, CLIP_H), ClipPolicy.CLIP_PATH, "root/card/inner")),