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..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; @@ -85,6 +89,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 +112,7 @@ private PptxFixedLayoutBackend(Builder builder) { } this.handlers = Map.copyOf(merged); this.rasterSlidesDpi = builder.rasterSlidesDpi; + this.clipRasterFallback = builder.clipRasterFallback; } /** @@ -233,7 +244,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 +285,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 +298,103 @@ private void renderGraph(LayoutGraph graph, PptxRenderEnvironment environment) t index = renderTableRowGroup(fragments, index, tableHandler, environment); continue; } + if (clipRasterFallback + && 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; + } 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 = ((ShapeClipBeginPayload) beginFragment.payload()).ownerPath(); + int endIndex = beginIndex + 1; + while (endIndex < fragments.size() + && !(fragments.get(endIndex).payload() instanceof 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; + } + if (beginFragment.width() <= 0 || beginFragment.height() <= 0) { + recordRegionNavigation(fragments, beginIndex, endIndex, environment); + return endIndex; + } + + // 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() - beginFragment.x(), fragment.y() - beginFragment.y(), + fragment.width(), fragment.height(), + fragment.margin(), fragment.padding(), fragment.payload())); + } + 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 raster = new PdfFixedLayoutBackend() + .renderToImages(regionGraph, context, (int) Math.round(72.0 * scale), true, 0) + .get(0); + byte[] png; + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + ImageIO.write(raster, "png", buffer); + png = buffer.toByteArray(); + } + XSLFPictureShape picture = + environment.surface(beginFragment.pageIndex()).createPicture( + environment.slideShow().addPicture(png, PictureData.PictureType.PNG)); + 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 AnchorMarkerPayload anchor) { + environment.registerAnchor(fragment, anchor.anchor()); + } else { + finishRenderedFragment(fragment, fragment.payload(), environment); + } + } + } + /** * Renders one contiguous run of same-table row fragments: all fills * first, then all borders and text. Returns the last consumed index. @@ -379,6 +485,7 @@ public static final class Builder { private final List> customHandlers = new ArrayList<>(); private int rasterSlidesDpi; + private boolean clipRasterFallback = true; private Builder() { } @@ -422,6 +529,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..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 @@ -6,12 +6,14 @@ 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. 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 new file mode 100644 index 00000000..64c69c15 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java @@ -0,0 +1,184 @@ +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)); + 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(); + // 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). + 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 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()) { + 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); + } + } + } +}