From becce6e92ebb4c8df8e7a6a45df860285f5a126c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 14:33:39 +0100 Subject: [PATCH 1/2] feat(pptx): pixel-exact clipping via a rasterized composite fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawingML has no graphics-state clipping, so clipped composites rendered unclipped — visibly different from the PDF (the parity demo's badge showed a whole circle where the PDF clips it to the container outline). The backend now consumes the whole clip region (the fragments between a clip-begin marker and its matching end, found by owner path), renders it through the PDF backend at up to 4 px/pt with a transparent background, crops the raster to the clip bounds, and places it as one picture — the clip is applied by the PDF engine itself, so the result is pixel-exact, and because the picture is created on the active drawing surface an enclosing transform group still rotates and scales it. The picture anchors on the crop's pixel bounds converted back to points, so the floor/ceil snap never shifts content. Fragment-level links, bookmarks, and anchors inside the region are still recorded; the raster is capped at 2048 px on the longest side. The fallback is on by default; Builder.clipRasterFallback(false) restores the previous unclipped vector rendering with its one-time warning, and the clip-begin handler now documents that it only dispatches on that path. PptxClipRasterFallbackTest pins both modes: one transparent composite picture on the clip bounds with an opaque centre and a fully transparent corner outside the outline (the clip really cut the layer), no vector leak of the clipped content, and the disabled path rendering the unclipped vector again. Full 10-module gate green; the vector parity demo's badge now matches the PDF. --- CHANGELOG.md | 7 + .../architecture/backend-capability-matrix.md | 2 +- .../fixed/pptx/PptxFixedLayoutBackend.java | 125 +++++++++++++++++- .../PptxShapeClipBeginRenderHandler.java | 13 +- .../pptx/PptxClipRasterFallbackTest.java | 116 ++++++++++++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ead5c20..cb75ca93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,13 @@ follow semantic versioning; release dates are ISO 8601. clipping); anchor and bookmark markers record their destinations for the navigation pass. +- Clipped composites in the fixed PPTX backend are now pixel-exact: the clip + region renders through the PDF backend into one transparent picture placed + on the clip bounds (DrawingML has no graphics-state clipping), including any + enclosing rotation. The picture is not editable as shapes; + `PptxFixedLayoutBackend.Builder.clipRasterFallback(false)` restores the + previous unclipped vector rendering with its one-time warning. + ### Fixed - `DocumentSession.toImages` / `toImage` rasterized documents that use binary diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a9c20d51..bcccc377 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -56,7 +56,7 @@ 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) | ⚠️ `PptxShapeClipBegin/EndRenderHandler` (children render unclipped with a one-time warning — DrawingML has no graphics-state clipping; use the PDF backend or raster-slide mode for exact clips) | ⚠️ inline fallback + one-time capability warning | +| 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) | ⚠️ 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` (destinations recorded; slide-jump emission pending) | ❌ | | Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` (records collected; slide-name mapping pending — PPTX has no document outline) | ❌ | 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 e0b7b5de..97df3cf1 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 @@ -85,6 +85,12 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer { */ private final int rasterSlidesDpi; + /** + * When {@code true} (the default), a clipped composite renders through the + * PDF backend into a transparent picture, giving pixel-exact clipping. + */ + private final boolean clipRasterFallback; + /** * Creates a backend with the default handler set. */ @@ -102,6 +108,7 @@ private PptxFixedLayoutBackend(Builder builder) { } this.handlers = Map.copyOf(merged); this.rasterSlidesDpi = builder.rasterSlidesDpi; + this.clipRasterFallback = builder.clipRasterFallback; } /** @@ -233,7 +240,7 @@ private void renderToOutput(LayoutGraph graph, PptxRenderEnvironment environment = new PptxRenderEnvironment(show, session, 0, graph.canvas().height(), measurement.fontLibrary(), context.customFontFamilies()); - renderGraph(graph, environment); + renderGraph(graph, environment, context); show.write(output); } } @@ -274,7 +281,9 @@ private static byte[] encodePng(BufferedImage image) throws IOException { * fragments so every cell fill of a table lands beneath its borders and * text — the PDF backend's two-pass discipline. */ - private void renderGraph(LayoutGraph graph, PptxRenderEnvironment environment) throws Exception { + private void renderGraph(LayoutGraph graph, + PptxRenderEnvironment environment, + FixedLayoutRenderContext context) throws Exception { PptxFragmentRenderHandler tableRowHandler = handlers.get(TableRowFragmentPayload.class); List fragments = graph.fragments(); @@ -285,10 +294,105 @@ private void renderGraph(LayoutGraph graph, PptxRenderEnvironment environment) t index = renderTableRowGroup(fragments, index, tableHandler, environment); continue; } + if (clipRasterFallback + && fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) { + index = renderClippedComposite(graph, fragments, index, environment, context); + continue; + } renderFragment(fragment, environment); } } + /** + * Renders one clip region — the fragments between a clip-begin marker and + * its matching end — through the PDF backend into a transparent picture + * 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. + */ + private int renderClippedComposite(LayoutGraph graph, + List fragments, + int beginIndex, + PptxRenderEnvironment environment, + FixedLayoutRenderContext context) throws Exception { + PlacedFragment beginFragment = fragments.get(beginIndex); + String ownerPath = ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) + beginFragment.payload()).ownerPath(); + int endIndex = beginIndex + 1; + while (endIndex < fragments.size() + && !(fragments.get(endIndex).payload() + instanceof com.demcha.compose.document.layout.payloads.ShapeClipEndPayload end + && Objects.equals(end.ownerPath(), ownerPath))) { + endIndex++; + } + if (endIndex >= fragments.size()) { + // Defensive: an unmatched begin falls back to the unclipped path. + renderFragment(beginFragment, environment); + return beginIndex; + } + + // Re-page the region onto page 0 and let the PDF backend apply the + // real clip; the raster is cropped to the clip bounds afterwards. + List region = new ArrayList<>(endIndex - beginIndex + 1); + for (int index = beginIndex; index <= endIndex; index++) { + PlacedFragment fragment = fragments.get(index); + region.add(new PlacedFragment(fragment.path(), fragment.fragmentIndex(), 0, + fragment.x(), fragment.y(), fragment.width(), fragment.height(), + fragment.margin(), fragment.padding(), fragment.payload())); + } + LayoutGraph regionGraph = new LayoutGraph(graph.canvas(), 1, List.of(), region); + double canvasHeight = graph.canvas().height(); + double scale = Math.min(4.0, 2048.0 + / Math.max(1.0, Math.max(beginFragment.width(), beginFragment.height()))); + BufferedImage page = new PdfFixedLayoutBackend() + .renderToImages(regionGraph, context, (int) Math.round(72.0 * scale), true, 0) + .get(0); + double pixelsPerPoint = page.getWidth() / graph.canvas().width(); + int left = clamp((int) Math.floor(beginFragment.x() * pixelsPerPoint), 0, page.getWidth() - 1); + int top = clamp((int) Math.floor( + (canvasHeight - beginFragment.y() - beginFragment.height()) * pixelsPerPoint), + 0, page.getHeight() - 1); + int right = clamp((int) Math.ceil( + (beginFragment.x() + beginFragment.width()) * pixelsPerPoint), left + 1, page.getWidth()); + int bottom = clamp((int) Math.ceil( + (canvasHeight - beginFragment.y()) * pixelsPerPoint), top + 1, page.getHeight()); + BufferedImage cropped = page.getSubimage(left, top, right - left, bottom - top); + byte[] png; + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + ImageIO.write(cropped, "png", buffer); + png = buffer.toByteArray(); + } + XSLFPictureShape picture = + environment.surface(beginFragment.pageIndex()).createPicture( + environment.slideShow().addPicture(png, PictureData.PictureType.PNG)); + // Anchor on the crop pixel bounds converted back to points, so the + // floor/ceil snap never shifts the content visually. + picture.setAnchor(new Rectangle2D.Double( + left / pixelsPerPoint, + top / pixelsPerPoint, + (right - left) / pixelsPerPoint, + (bottom - top) / pixelsPerPoint)); + ((org.openxmlformats.schemas.presentationml.x2006.main.CTPicture) picture.getXmlObject()) + .getNvPicPr().getCNvPr().setName("GraphCompose Clipped Composite"); + + for (int index = beginIndex; index <= endIndex; index++) { + PlacedFragment fragment = fragments.get(index); + if (fragment.payload() + instanceof com.demcha.compose.document.layout.payloads.AnchorMarkerPayload anchor) { + environment.registerAnchor(fragment, anchor.anchor()); + } else { + finishRenderedFragment(fragment, fragment.payload(), environment); + } + } + return endIndex; + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + /** * Renders one contiguous run of same-table row fragments: all fills * first, then all borders and text. Returns the last consumed index. @@ -379,6 +483,7 @@ public static final class Builder { private final List> customHandlers = new ArrayList<>(); private int rasterSlidesDpi; + private boolean clipRasterFallback = true; private Builder() { } @@ -422,6 +527,22 @@ public Builder addHandler(PptxFragmentRenderHandler handler) { return this; } + /** + * Controls the clip fallback. DrawingML has no graphics-state + * clipping, so by default a clipped composite renders through the PDF + * backend into one transparent picture placed at the clip bounds: + * pixel-exact clipping (including any enclosing rotation), at the + * price of that fragment not being editable as shapes. Disabling the + * fallback renders the children unclipped with a one-time warning. + * + * @param enabled {@code true} keeps the raster fallback + * @return this builder + */ + public Builder clipRasterFallback(boolean enabled) { + this.clipRasterFallback = enabled; + return this; + } + /** * Builds the configured backend. * diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java index 52fda59b..3b1e0b68 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java @@ -6,12 +6,13 @@ import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; /** - * Clip regions cannot be expressed in PPTX — DrawingML has no graphics-state - * clipping, only per-picture source crops — so the marker degrades to a - * one-time capability warning and the children render unclipped, the same - * sanctioned fallback the payload documents and the DOCX backend uses. - * Content that must be clipped exactly renders through the PDF backend or - * the raster-slide mode. + * Runs only when the backend's clip raster fallback is disabled: clip regions + * cannot be expressed in PPTX — DrawingML has no graphics-state clipping, only + * per-picture source crops — so the marker degrades to a one-time capability + * warning and the children render unclipped, the sanctioned fallback the + * payload documents. By default the backend never dispatches here; it + * rasterizes the whole clip region through the PDF backend instead + * (pixel-exact clipping as one transparent picture). * * @since 2.1.0 */ 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 new file mode 100644 index 00000000..9331c90d --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java @@ -0,0 +1,116 @@ +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.EllipseBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFPictureShape; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The clip raster fallback: a clipped composite renders through the PDF + * backend into one transparent picture anchored on the clip bounds — + * pixel-exact clipping in a format that cannot express it — while disabling + * the fallback restores the unclipped vector rendering. + */ +class PptxClipRasterFallbackTest { + + private static DocumentSession composeClippedBadge() { + DocumentSession session = GraphCompose.document() + .pageSize(300, 240) + .margin(DocumentInsets.of(20)) + .create(); + session.add(new ShapeContainerBuilder() + .ellipse(70, 70) + .layer(new EllipseBuilder().circle(50) + .fillColor(DocumentColor.ROYAL_BLUE).build()) + .build()); + return session; + } + + @Test + void clippedCompositeRasterizesToOneTransparentPictureOnTheClipBounds() throws Exception { + try (DocumentSession session = composeClippedBadge()) { + LayoutGraph graph = session.render(new GraphCapturingBackend()); + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + + PlacedFragment clipFragment = graph.fragments().stream() + .filter(fragment -> fragment.payload() instanceof ShapeClipBeginPayload) + .findFirst().orElseThrow(); + double canvasHeight = graph.canvas().height(); + + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + List shapes = show.getSlides().get(0).getShapes(); + List composites = shapes.stream() + .filter(shape -> "GraphCompose Clipped Composite".equals(shape.getShapeName())) + .map(XSLFPictureShape.class::cast) + .toList(); + assertThat(composites).hasSize(1); + XSLFPictureShape composite = composites.get(0); + + Rectangle2D anchor = composite.getAnchor(); + assertThat(anchor.getX()).isCloseTo(clipFragment.x(), + org.assertj.core.data.Offset.offset(0.5)); + assertThat(anchor.getY()).isCloseTo( + canvasHeight - clipFragment.y() - clipFragment.height(), + org.assertj.core.data.Offset.offset(0.5)); + assertThat(anchor.getWidth()).isCloseTo(clipFragment.width(), + org.assertj.core.data.Offset.offset(0.5)); + + BufferedImage bitmap = ImageIO.read( + new ByteArrayInputStream(composite.getPictureData().getData())); + assertThat(bitmap.getColorModel().hasAlpha()) + .as("the composite must keep its transparent background").isTrue(); + // Centre pixel carries the layer fill; the top-left corner lies + // outside the ellipse outline and must stay fully transparent — + // the clip actually cut the layer. + int centre = bitmap.getRGB(bitmap.getWidth() / 2, bitmap.getHeight() / 2); + int corner = bitmap.getRGB(1, 1); + assertThat((centre >>> 24)).as("centre opaque").isGreaterThan(200); + assertThat((corner >>> 24)).as("corner transparent").isLessThan(30); + + // No vector ellipse for the clipped layer leaks next to the + // picture (the unfilled, unstroked outline draws nothing). + assertThat(shapes.stream() + .filter(shape -> shape instanceof org.apache.poi.xslf.usermodel.XSLFAutoShape) + .count()) + .as("the clipped layer must not leak as a vector shape") + .isZero(); + } + } + } + + @Test + void disablingTheFallbackRendersUnclippedVectors() throws Exception { + try (DocumentSession session = composeClippedBadge()) { + byte[] pptx = session.render( + PptxFixedLayoutBackend.builder().clipRasterFallback(false).build()); + 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("the unclipped layer stays a vector shape") + .isEqualTo(1); + } + } + } +} From 64dc3c23285d63811d56c50099f916f30a43fc05 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 14:53:17 +0100 Subject: [PATCH 2/2] refactor(pptx): rasterize only the clip box, prove the cut, honor custom handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clip fallback rasterized the whole page at a scale keyed to the clip box, so a small badge on a large canvas allocated a full-page raster per region (hundreds of megabytes on poster-sized canvases). The region's fragments now translate into a clip-sized canvas and only the clip box renders — the raster is bounded by the capped clip size regardless of the page, and the crop step disappears; the picture anchors directly on the clip fragment box. A zero-size clip box skips drawing but still records the region's navigation, and the interception respects Builder.addHandler: a custom clip-begin handler keeps normal dispatch. Tests sharpen to actually prove the cut: the sampled pixel lies inside the layer circle but outside the container outline, so it is opaque in an unclipped render and transparent only when the clip really cut the layer (the old corner sample was transparent either way). New tests pin the two headline behaviours: a clip inside a rotated container renders its composite inside the rotated group, and a nested clip of a different owner is consumed into one composite with both clips applied by the PDF sub-render. --- .../fixed/pptx/PptxFixedLayoutBackend.java | 74 +++++++++-------- .../PptxShapeClipBeginRenderHandler.java | 7 +- .../pptx/PptxClipRasterFallbackTest.java | 82 +++++++++++++++++-- 3 files changed, 117 insertions(+), 46 deletions(-) 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 97df3cf1..471d7a55 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 @@ -21,9 +21,13 @@ import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; import com.demcha.compose.document.exceptions.UnsupportedNodeCapabilityException; +import com.demcha.compose.document.layout.LayoutCanvas; import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; import com.demcha.compose.document.layout.payloads.PdfSemanticFragmentPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; +import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload; import org.apache.poi.sl.usermodel.PictureData; import org.apache.poi.xslf.usermodel.XMLSlideShow; @@ -295,7 +299,11 @@ private void renderGraph(LayoutGraph graph, continue; } if (clipRasterFallback - && fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) { + && fragment.payload() instanceof ShapeClipBeginPayload + && handlers.get(ShapeClipBeginPayload.class) + instanceof PptxShapeClipBeginRenderHandler) { + // A custom clip handler registered via Builder.addHandler keeps + // normal dispatch; only the built-in degrade path is replaced. index = renderClippedComposite(graph, fragments, index, environment, context); continue; } @@ -318,12 +326,10 @@ private int renderClippedComposite(LayoutGraph graph, PptxRenderEnvironment environment, FixedLayoutRenderContext context) throws Exception { PlacedFragment beginFragment = fragments.get(beginIndex); - String ownerPath = ((com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload) - beginFragment.payload()).ownerPath(); + String ownerPath = ((ShapeClipBeginPayload) beginFragment.payload()).ownerPath(); int endIndex = beginIndex + 1; while (endIndex < fragments.size() - && !(fragments.get(endIndex).payload() - instanceof com.demcha.compose.document.layout.payloads.ShapeClipEndPayload end + && !(fragments.get(endIndex).payload() instanceof ShapeClipEndPayload end && Objects.equals(end.ownerPath(), ownerPath))) { endIndex++; } @@ -332,65 +338,61 @@ private int renderClippedComposite(LayoutGraph graph, renderFragment(beginFragment, environment); return beginIndex; } + if (beginFragment.width() <= 0 || beginFragment.height() <= 0) { + recordRegionNavigation(fragments, beginIndex, endIndex, environment); + return endIndex; + } - // Re-page the region onto page 0 and let the PDF backend apply the - // real clip; the raster is cropped to the clip bounds afterwards. + // Translate the region into a clip-sized canvas and rasterize only the + // clip box — the PDF backend applies the real clip, and the raster + // never exceeds the capped clip size no matter how large the page is. List region = new ArrayList<>(endIndex - beginIndex + 1); for (int index = beginIndex; index <= endIndex; index++) { PlacedFragment fragment = fragments.get(index); region.add(new PlacedFragment(fragment.path(), fragment.fragmentIndex(), 0, - fragment.x(), fragment.y(), fragment.width(), fragment.height(), + fragment.x() - beginFragment.x(), fragment.y() - beginFragment.y(), + fragment.width(), fragment.height(), fragment.margin(), fragment.padding(), fragment.payload())); } - LayoutGraph regionGraph = new LayoutGraph(graph.canvas(), 1, List.of(), region); - double canvasHeight = graph.canvas().height(); + LayoutCanvas clipCanvas = new LayoutCanvas( + beginFragment.width(), beginFragment.height(), + beginFragment.width(), beginFragment.height(), + com.demcha.compose.engine.components.style.Margin.of(0)); + LayoutGraph regionGraph = new LayoutGraph(clipCanvas, 1, List.of(), region); double scale = Math.min(4.0, 2048.0 / Math.max(1.0, Math.max(beginFragment.width(), beginFragment.height()))); - BufferedImage page = new PdfFixedLayoutBackend() + BufferedImage raster = new PdfFixedLayoutBackend() .renderToImages(regionGraph, context, (int) Math.round(72.0 * scale), true, 0) .get(0); - double pixelsPerPoint = page.getWidth() / graph.canvas().width(); - int left = clamp((int) Math.floor(beginFragment.x() * pixelsPerPoint), 0, page.getWidth() - 1); - int top = clamp((int) Math.floor( - (canvasHeight - beginFragment.y() - beginFragment.height()) * pixelsPerPoint), - 0, page.getHeight() - 1); - int right = clamp((int) Math.ceil( - (beginFragment.x() + beginFragment.width()) * pixelsPerPoint), left + 1, page.getWidth()); - int bottom = clamp((int) Math.ceil( - (canvasHeight - beginFragment.y()) * pixelsPerPoint), top + 1, page.getHeight()); - BufferedImage cropped = page.getSubimage(left, top, right - left, bottom - top); byte[] png; try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - ImageIO.write(cropped, "png", buffer); + ImageIO.write(raster, "png", buffer); png = buffer.toByteArray(); } XSLFPictureShape picture = environment.surface(beginFragment.pageIndex()).createPicture( environment.slideShow().addPicture(png, PictureData.PictureType.PNG)); - // Anchor on the crop pixel bounds converted back to points, so the - // floor/ceil snap never shifts the content visually. - picture.setAnchor(new Rectangle2D.Double( - left / pixelsPerPoint, - top / pixelsPerPoint, - (right - left) / pixelsPerPoint, - (bottom - top) / pixelsPerPoint)); + picture.setAnchor(PptxCoordinates.anchorOf(environment.canvasHeight(), beginFragment)); ((org.openxmlformats.schemas.presentationml.x2006.main.CTPicture) picture.getXmlObject()) .getNvPicPr().getCNvPr().setName("GraphCompose Clipped Composite"); + recordRegionNavigation(fragments, beginIndex, endIndex, environment); + return endIndex; + } + + /** Records links, bookmarks, and anchors of a consumed clip region. */ + private void recordRegionNavigation(List fragments, + int beginIndex, + int endIndex, + PptxRenderEnvironment environment) { for (int index = beginIndex; index <= endIndex; index++) { PlacedFragment fragment = fragments.get(index); - if (fragment.payload() - instanceof com.demcha.compose.document.layout.payloads.AnchorMarkerPayload anchor) { + if (fragment.payload() instanceof AnchorMarkerPayload anchor) { environment.registerAnchor(fragment, anchor.anchor()); } else { finishRenderedFragment(fragment, fragment.payload(), environment); } } - return endIndex; - } - - private static int clamp(int value, int min, int max) { - return Math.max(min, Math.min(max, value)); } /** diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java index 3b1e0b68..97b26269 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java @@ -10,9 +10,10 @@ * cannot be expressed in PPTX — DrawingML has no graphics-state clipping, only * per-picture source crops — so the marker degrades to a one-time capability * warning and the children render unclipped, the sanctioned fallback the - * payload documents. By default the backend never dispatches here; it - * rasterizes the whole clip region through the PDF backend instead - * (pixel-exact clipping as one transparent picture). + * payload documents. With the fallback enabled (the default) the backend + * dispatches here only for an unmatched begin marker; whole regions + * rasterize through the PDF backend instead (pixel-exact clipping as one + * transparent picture). * * @since 2.1.0 */ 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 9331c90d..64c69c15 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 @@ -71,18 +71,29 @@ void clippedCompositeRasterizesToOneTransparentPictureOnTheClipBounds() throws E org.assertj.core.data.Offset.offset(0.5)); assertThat(anchor.getWidth()).isCloseTo(clipFragment.width(), org.assertj.core.data.Offset.offset(0.5)); + assertThat(anchor.getHeight()).isCloseTo(clipFragment.height(), + org.assertj.core.data.Offset.offset(0.5)); BufferedImage bitmap = ImageIO.read( new ByteArrayInputStream(composite.getPictureData().getData())); assertThat(bitmap.getColorModel().hasAlpha()) .as("the composite must keep its transparent background").isTrue(); - // Centre pixel carries the layer fill; the top-left corner lies - // outside the ellipse outline and must stay fully transparent — - // the clip actually cut the layer. - int centre = bitmap.getRGB(bitmap.getWidth() / 2, bitmap.getHeight() / 2); - int corner = bitmap.getRGB(1, 1); - assertThat((centre >>> 24)).as("centre opaque").isGreaterThan(200); - assertThat((corner >>> 24)).as("corner transparent").isLessThan(30); + // The layer circle (top-left aligned, r=25pt at centre 25,25) + // covers point (10,10)pt, but that point lies OUTSIDE the + // container ellipse (centre 35,35, r=35) — so it is opaque in + // an unclipped render and transparent only when the clip + // actually cut the layer. The circle centre stays opaque. + double pixelsPerPoint = bitmap.getWidth() / clipFragment.width(); + int cut = bitmap.getRGB( + (int) Math.round(10 * pixelsPerPoint), + (int) Math.round(10 * pixelsPerPoint)); + int inside = bitmap.getRGB( + (int) Math.round(25 * pixelsPerPoint), + (int) Math.round(25 * pixelsPerPoint)); + assertThat((inside >>> 24)).as("circle centre opaque").isGreaterThan(200); + assertThat((cut >>> 24)) + .as("point inside the layer but outside the outline must be clipped away") + .isLessThan(30); // No vector ellipse for the clipped layer leaks next to the // picture (the unfilled, unstroked outline draws nothing). @@ -95,6 +106,63 @@ void clippedCompositeRasterizesToOneTransparentPictureOnTheClipBounds() throws E } } + @Test + void clipInsideATransformStaysInsideTheRotatedGroup() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 240) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(new ShapeContainerBuilder() + .ellipse(70, 70) + .layer(new EllipseBuilder().circle(50) + .fillColor(DocumentColor.ROYAL_BLUE).build()) + .transform(new com.demcha.compose.document.style.DocumentTransform(30, 1, 1)) + .build()); + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + org.apache.poi.xslf.usermodel.XSLFGroupShape group = + show.getSlides().get(0).getShapes().stream() + .filter(org.apache.poi.xslf.usermodel.XSLFGroupShape.class::isInstance) + .map(org.apache.poi.xslf.usermodel.XSLFGroupShape.class::cast) + .findFirst().orElseThrow(); + assertThat(group.getRotation()) + .as("the enclosing rotation applies to the rasterized clip") + .isEqualTo(30.0); + assertThat(group.getShapes()) + .anyMatch(shape -> "GraphCompose Clipped Composite" + .equals(shape.getShapeName())); + } + } + } + + @Test + void nestedClipOfADifferentOwnerIsConsumedIntoOneComposite() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 260) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(new ShapeContainerBuilder() + .ellipse(90, 90) + .layer(new ShapeContainerBuilder() + .ellipse(50, 50) + .layer(new EllipseBuilder().circle(40) + .fillColor(DocumentColor.ORANGE).build()) + .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.stream() + .filter(shape -> "GraphCompose Clipped Composite" + .equals(shape.getShapeName())) + .count()) + .as("the outer region swallows the inner clip; the PDF " + + "sub-render applies both clips inside one picture") + .isEqualTo(1); + } + } + } + @Test void disablingTheFallbackRendersUnclippedVectors() throws Exception { try (DocumentSession session = composeClippedBadge()) {