diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a76c794..4db02e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,19 @@ follow semantic versioning; release dates are ISO 8601. ### Fixed +- **A large clipped region in a PPTX deck is no longer downscaled.** The raster + fallback aims for a 2048-pixel long edge, and that ratio was only capped from + above, so a clip box wider than 2048 pt rasterized *below* native size — an + A0-scale composite landed near 44 DPI and read as visibly blurry, since the + picture is anchored at the full clip size regardless. The scale is now clamped + between native size and 4×. Decks whose clip regions fit a normal page are + unaffected: their scale already saturated at the upper cap. +- The PPTX backend now logs, once per render at `DEBUG` on + `com.demcha.compose.engine.render`, how many clip regions were rasterized and + their total megapixels. Each rasterized region costs a full sub-render through + the PDF backend and loses text editability inside its bounds, so a clip-heavy + deck's cost is visible rather than inferred from render time. Silent when + nothing was rasterized. - The PDF backend now draws `DocumentTextDecoration.UNDERLINE` and `STRIKETHROUGH` marks — previously the decoration flags resolved only to font faces (which alias to the regular program), so decorated text rendered diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index d4818f9d..dd5e48ce 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -59,7 +59,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) | ✅ `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 | +| 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; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); 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) | ❌ | diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java index 5bcf0ed5..85c2b231 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java @@ -99,6 +99,7 @@ static void renderSections(List sections, } environment.beginSection(0); PptxNavigationWriter.apply(environment); + environment.logRasterizedClipSummary(); // Metadata is deck-global: the first section that declares it wins, // matching the PDF backend's combined-document rule. sections.stream() 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 e64f16bc..3aae5f41 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 @@ -101,6 +101,23 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer { private static final Instant DEFAULT_DETERMINISTIC_INSTANT = Instant.parse("2000-01-01T00:00:00Z"); + /** + * Long edge, in pixels, a rasterized clip region aims for. Small regions are + * upscaled towards it so the picture stays crisp on a projector. + */ + private static final double CLIP_RASTER_TARGET_PIXELS = 2048.0; + + /** Upscale ceiling, so a tiny badge does not mint a needlessly heavy picture. */ + private static final double CLIP_RASTER_MAX_SCALE = 4.0; + + /** + * Downscale floor. Without it, a clip region wider than + * {@link #CLIP_RASTER_TARGET_PIXELS} points rasterizes below native + * size — an A0-scale composite would land at ~44 DPI and read as visibly + * blurry, since the picture is anchored at the full clip size regardless. + */ + private static final double CLIP_RASTER_MIN_SCALE = 1.0; + private final Map, PptxFragmentRenderHandler> handlers; // Package-private: the deck assembly reads each section's chrome directly. @@ -377,6 +394,7 @@ private void renderVectorSlides(LayoutGraph graph, PptxChromeRenderer.applyHeadersAndFooters( environment, headerFooterOptions, graph.canvas(), pageCount); PptxNavigationWriter.apply(environment); + environment.logRasterizedClipSummary(); if (metadataOptions != null) { PptxDeckAssembly.applyMetadata(show, metadataOptions); } @@ -530,11 +548,14 @@ private int renderClippedComposite(LayoutGraph graph, 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()))); + double longestEdge = Math.max(1.0, + Math.max(beginFragment.width(), beginFragment.height())); + double scale = Math.max(CLIP_RASTER_MIN_SCALE, + Math.min(CLIP_RASTER_MAX_SCALE, CLIP_RASTER_TARGET_PIXELS / longestEdge)); BufferedImage raster = new PdfFixedLayoutBackend() .renderToImages(regionGraph, context, (int) Math.round(72.0 * scale), true, 0) .get(0); + environment.recordRasterizedClip(raster.getWidth(), raster.getHeight()); byte[] png; try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { ImageIO.write(raster, "png", buffer); diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java index f7f52679..3e79cbeb 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java @@ -77,6 +77,8 @@ public final class PptxRenderEnvironment { private final List fragmentLinks = new ArrayList<>(); private final Set substitutionWarned = new LinkedHashSet<>(); private final java.util.Deque groupStack = new java.util.ArrayDeque<>(); + private int rasterizedClipCount; + private long rasterizedClipPixels; PptxRenderEnvironment(XMLSlideShow show, PptxRenderSession session, @@ -164,6 +166,34 @@ XSLFSlide globalSlide(int globalPageIndex) { return session.slide(globalPageIndex); } + /** + * Records that one clip region was rasterized rather than rendered as + * native shapes. Each such region costs a full sub-render through the PDF + * backend and loses text editability inside its bounds, so the cost of a + * clip-heavy deck should be visible rather than inferred from render time. + * + * @param pixelWidth rasterized picture width in pixels + * @param pixelHeight rasterized picture height in pixels + */ + void recordRasterizedClip(int pixelWidth, int pixelHeight) { + rasterizedClipCount++; + rasterizedClipPixels += (long) pixelWidth * pixelHeight; + } + + /** + * Emits the one-line clip-rasterization summary for the finished render + * pass. Silent when nothing was rasterized, so an all-vector deck adds no + * noise to the log. + */ + void logRasterizedClipSummary() { + if (rasterizedClipCount == 0) { + return; + } + LOG.debug("render.pptx.fixed.clip-raster regions={} megapixels={}", + rasterizedClipCount, + String.format(java.util.Locale.ROOT, "%.2f", rasterizedClipPixels / 1_000_000.0)); + } + /** * Returns the container new shapes must be created on: the innermost open * transform group when one is active, otherwise the page's slide. The 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 c9d1d377..23271143 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 @@ -213,4 +213,49 @@ void disablingTheFallbackRendersUnclippedVectors() throws Exception { } } } + + @Test + void aClipRegionLargerThanTheRasterTargetStillRendersAtLeastAtNativeResolution() throws Exception { + // The raster scale aims for a 2048px long edge. A clip box WIDER than + // 2048pt drives that ratio below 1.0, and without a floor the picture is + // downscaled while still being anchored at the full clip size — an + // A0-scale composite landing at ~44 DPI, visibly blurry. Every other clip + // test uses a page a few hundred points wide, where the ratio saturates at + // the upscale cap, so this branch was never exercised. + double pageWidth = 3400; + double pageHeight = 2400; + try (DocumentSession session = GraphCompose.document() + .pageSize(pageWidth, pageHeight) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(new ShapeContainerBuilder() + .ellipse(3000, 2000) + .layer(new EllipseBuilder().circle(2600) + .fillColor(DocumentColor.ROYAL_BLUE).build()) + .build()); + + 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(); + assertThat(clipFragment.width()) + .as("the fixture must actually exceed the raster target, or it proves nothing") + .isGreaterThan(2048.0); + + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + XSLFPictureShape composite = show.getSlides().get(0).getShapes().stream() + .filter(shape -> "GraphCompose Clipped Composite".equals(shape.getShapeName())) + .map(XSLFPictureShape.class::cast) + .findFirst().orElseThrow(); + BufferedImage bitmap = ImageIO.read( + new ByteArrayInputStream(composite.getPictureData().getData())); + + assertThat(bitmap.getWidth() / clipFragment.width()) + .as("a rasterized clip must never be published below native 72 DPI") + .isGreaterThanOrEqualTo(1.0); + } + } + } }