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")),