From 4ee7312e592630cacd708b2c6e522422eec2218a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 25 Jul 2026 10:14:22 +0100 Subject: [PATCH 1/2] fix(pptx): clamp the clip raster scale and surface rasterized regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clip raster fallback aims for a 2048-pixel long edge, but that ratio was only capped from above. A clip region wider than 2048 pt therefore drove the scale below 1.0 and rasterized *below* native size, while the picture stayed anchored at the full clip size — an A0-scale composite landed near 44 DPI and read as visibly blurry. The inner Math.max guarded the divisor, not the result. Clamp the scale between native size and 4x, behind named constants that say what each bound is for. Decks whose clip regions fit a normal page are unaffected: their scale already saturated at the upper cap, which is also why no existing test covered this branch — every other clip test uses a page a few hundred points wide. Also make the fallback's cost visible. Each rasterized region is a full sub-render through the PDF backend and loses text editability inside its bounds, yet nothing reported how often it happened. The render environment now counts rasterized regions and their pixel volume, and emits one DEBUG line per pass on com.demcha.compose.engine.render — silent when nothing was rasterized, so an all-vector deck adds no noise. It is wired into the two paths that can reach the clip fallback; the raster-slide path turns the whole page into one picture and never gets there. Tests: a clip box on a 3400x2400 pt page, asserting the picture is published at no less than native resolution. The fixture asserts its own premise — that the clip really exceeds 2048 pt — so it cannot silently stop exercising the branch. Removing only the floor turns it red. Verified: full reactor clean verify green (1519 tests, 0 failures). Capability matrix updated in the same change, per the repository rule. --- CHANGELOG.md | 13 ++++++ .../architecture/backend-capability-matrix.md | 2 +- .../backend/fixed/pptx/PptxDeckAssembly.java | 1 + .../fixed/pptx/PptxFixedLayoutBackend.java | 25 ++++++++++- .../fixed/pptx/PptxRenderEnvironment.java | 30 +++++++++++++ .../pptx/PptxClipRasterFallbackTest.java | 45 +++++++++++++++++++ 6 files changed, 113 insertions(+), 3 deletions(-) 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..61210be2 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; 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); + } + } + } } From f49e99cb890c9e6e93639a4840e6e2d2d81f37b7 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 25 Jul 2026 13:11:09 +0100 Subject: [PATCH 2/2] docs(pptx): record the clip raster's memory ceiling alongside the clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clamp removes the implicit ~17MB cap a downscaled raster used to impose, so a very large clip region now costs transient memory proportional to its size. That is the intended trade — blur is worse than a transient allocation, and the floor only engages above 2048pt — but the capability matrix described the resolution change without its resource consequence. --- docs/architecture/backend-capability-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 61210be2..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; 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; 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) | ❌ |