Skip to content

Commit 9fbf76d

Browse files
authored
fix(pptx): clamp the clip raster scale and surface rasterized regions (#442)
* fix(pptx): clamp the clip raster scale and surface rasterized regions 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. * docs(pptx): record the clip raster's memory ceiling alongside the clamp 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.
1 parent d383513 commit 9fbf76d

6 files changed

Lines changed: 113 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,19 @@ follow semantic versioning; release dates are ISO 8601.
215215

216216
### Fixed
217217

218+
- **A large clipped region in a PPTX deck is no longer downscaled.** The raster
219+
fallback aims for a 2048-pixel long edge, and that ratio was only capped from
220+
above, so a clip box wider than 2048 pt rasterized *below* native size — an
221+
A0-scale composite landed near 44 DPI and read as visibly blurry, since the
222+
picture is anchored at the full clip size regardless. The scale is now clamped
223+
between native size and 4×. Decks whose clip regions fit a normal page are
224+
unaffected: their scale already saturated at the upper cap.
225+
- The PPTX backend now logs, once per render at `DEBUG` on
226+
`com.demcha.compose.engine.render`, how many clip regions were rasterized and
227+
their total megapixels. Each rasterized region costs a full sub-render through
228+
the PDF backend and loses text editability inside its bounds, so a clip-heavy
229+
deck's cost is visible rather than inferred from render time. Silent when
230+
nothing was rasterized.
218231
- The PDF backend now draws `DocumentTextDecoration.UNDERLINE` and
219232
`STRIKETHROUGH` marks — previously the decoration flags resolved only to
220233
font faces (which alias to the regular program), so decorated text rendered

docs/architecture/backend-capability-matrix.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Payload records live in `core` under
5959
| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) |`PdfImageFragmentRenderHandler` |`PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ semantic images (`DocxSemanticBackend`) |
6060
| Barcode / QR (`BarcodeFragmentPayload`) |`PdfBarcodeFragmentRenderHandler` (ZXing raster) |`PptxBarcodeFragmentRenderHandler` (identical ZXing raster) ||
6161
| 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`) |
62-
| 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 |
62+
| 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 |
6363
| 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 |
6464
| Anchor markers (`AnchorMarkerPayload`) |`PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` |`PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) ||
6565
| 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) ||

render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ static void renderSections(List<SectionUnit> sections,
9999
}
100100
environment.beginSection(0);
101101
PptxNavigationWriter.apply(environment);
102+
environment.logRasterizedClipSummary();
102103
// Metadata is deck-global: the first section that declares it wins,
103104
// matching the PDF backend's combined-document rule.
104105
sections.stream()

render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackend.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer {
101101

102102
private static final Instant DEFAULT_DETERMINISTIC_INSTANT = Instant.parse("2000-01-01T00:00:00Z");
103103

104+
/**
105+
* Long edge, in pixels, a rasterized clip region aims for. Small regions are
106+
* upscaled towards it so the picture stays crisp on a projector.
107+
*/
108+
private static final double CLIP_RASTER_TARGET_PIXELS = 2048.0;
109+
110+
/** Upscale ceiling, so a tiny badge does not mint a needlessly heavy picture. */
111+
private static final double CLIP_RASTER_MAX_SCALE = 4.0;
112+
113+
/**
114+
* Downscale floor. Without it, a clip region wider than
115+
* {@link #CLIP_RASTER_TARGET_PIXELS} points rasterizes <em>below</em> native
116+
* size — an A0-scale composite would land at ~44 DPI and read as visibly
117+
* blurry, since the picture is anchored at the full clip size regardless.
118+
*/
119+
private static final double CLIP_RASTER_MIN_SCALE = 1.0;
120+
104121
private final Map<Class<?>, PptxFragmentRenderHandler<?>> handlers;
105122

106123
// Package-private: the deck assembly reads each section's chrome directly.
@@ -377,6 +394,7 @@ private void renderVectorSlides(LayoutGraph graph,
377394
PptxChromeRenderer.applyHeadersAndFooters(
378395
environment, headerFooterOptions, graph.canvas(), pageCount);
379396
PptxNavigationWriter.apply(environment);
397+
environment.logRasterizedClipSummary();
380398
if (metadataOptions != null) {
381399
PptxDeckAssembly.applyMetadata(show, metadataOptions);
382400
}
@@ -530,11 +548,14 @@ private int renderClippedComposite(LayoutGraph graph,
530548
beginFragment.width(), beginFragment.height(),
531549
com.demcha.compose.engine.components.style.Margin.of(0));
532550
LayoutGraph regionGraph = new LayoutGraph(clipCanvas, 1, List.of(), region);
533-
double scale = Math.min(4.0, 2048.0
534-
/ Math.max(1.0, Math.max(beginFragment.width(), beginFragment.height())));
551+
double longestEdge = Math.max(1.0,
552+
Math.max(beginFragment.width(), beginFragment.height()));
553+
double scale = Math.max(CLIP_RASTER_MIN_SCALE,
554+
Math.min(CLIP_RASTER_MAX_SCALE, CLIP_RASTER_TARGET_PIXELS / longestEdge));
535555
BufferedImage raster = new PdfFixedLayoutBackend()
536556
.renderToImages(regionGraph, context, (int) Math.round(72.0 * scale), true, 0)
537557
.get(0);
558+
environment.recordRasterizedClip(raster.getWidth(), raster.getHeight());
538559
byte[] png;
539560
try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
540561
ImageIO.write(raster, "png", buffer);

render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxRenderEnvironment.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ public final class PptxRenderEnvironment {
7777
private final List<FragmentLink> fragmentLinks = new ArrayList<>();
7878
private final Set<String> substitutionWarned = new LinkedHashSet<>();
7979
private final java.util.Deque<XSLFGroupShape> groupStack = new java.util.ArrayDeque<>();
80+
private int rasterizedClipCount;
81+
private long rasterizedClipPixels;
8082

8183
PptxRenderEnvironment(XMLSlideShow show,
8284
PptxRenderSession session,
@@ -164,6 +166,34 @@ XSLFSlide globalSlide(int globalPageIndex) {
164166
return session.slide(globalPageIndex);
165167
}
166168

169+
/**
170+
* Records that one clip region was rasterized rather than rendered as
171+
* native shapes. Each such region costs a full sub-render through the PDF
172+
* backend and loses text editability inside its bounds, so the cost of a
173+
* clip-heavy deck should be visible rather than inferred from render time.
174+
*
175+
* @param pixelWidth rasterized picture width in pixels
176+
* @param pixelHeight rasterized picture height in pixels
177+
*/
178+
void recordRasterizedClip(int pixelWidth, int pixelHeight) {
179+
rasterizedClipCount++;
180+
rasterizedClipPixels += (long) pixelWidth * pixelHeight;
181+
}
182+
183+
/**
184+
* Emits the one-line clip-rasterization summary for the finished render
185+
* pass. Silent when nothing was rasterized, so an all-vector deck adds no
186+
* noise to the log.
187+
*/
188+
void logRasterizedClipSummary() {
189+
if (rasterizedClipCount == 0) {
190+
return;
191+
}
192+
LOG.debug("render.pptx.fixed.clip-raster regions={} megapixels={}",
193+
rasterizedClipCount,
194+
String.format(java.util.Locale.ROOT, "%.2f", rasterizedClipPixels / 1_000_000.0));
195+
}
196+
167197
/**
168198
* Returns the container new shapes must be created on: the innermost open
169199
* transform group when one is active, otherwise the page's slide. The

render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxClipRasterFallbackTest.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,49 @@ void disablingTheFallbackRendersUnclippedVectors() throws Exception {
213213
}
214214
}
215215
}
216+
217+
@Test
218+
void aClipRegionLargerThanTheRasterTargetStillRendersAtLeastAtNativeResolution() throws Exception {
219+
// The raster scale aims for a 2048px long edge. A clip box WIDER than
220+
// 2048pt drives that ratio below 1.0, and without a floor the picture is
221+
// downscaled while still being anchored at the full clip size — an
222+
// A0-scale composite landing at ~44 DPI, visibly blurry. Every other clip
223+
// test uses a page a few hundred points wide, where the ratio saturates at
224+
// the upscale cap, so this branch was never exercised.
225+
double pageWidth = 3400;
226+
double pageHeight = 2400;
227+
try (DocumentSession session = GraphCompose.document()
228+
.pageSize(pageWidth, pageHeight)
229+
.margin(DocumentInsets.of(20))
230+
.create()) {
231+
session.add(new ShapeContainerBuilder()
232+
.ellipse(3000, 2000)
233+
.layer(new EllipseBuilder().circle(2600)
234+
.fillColor(DocumentColor.ROYAL_BLUE).build())
235+
.build());
236+
237+
LayoutGraph graph = session.render(new GraphCapturingBackend());
238+
byte[] pptx = session.render(new PptxFixedLayoutBackend());
239+
240+
PlacedFragment clipFragment = graph.fragments().stream()
241+
.filter(fragment -> fragment.payload() instanceof ShapeClipBeginPayload)
242+
.findFirst().orElseThrow();
243+
assertThat(clipFragment.width())
244+
.as("the fixture must actually exceed the raster target, or it proves nothing")
245+
.isGreaterThan(2048.0);
246+
247+
try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
248+
XSLFPictureShape composite = show.getSlides().get(0).getShapes().stream()
249+
.filter(shape -> "GraphCompose Clipped Composite".equals(shape.getShapeName()))
250+
.map(XSLFPictureShape.class::cast)
251+
.findFirst().orElseThrow();
252+
BufferedImage bitmap = ImageIO.read(
253+
new ByteArrayInputStream(composite.getPictureData().getData()));
254+
255+
assertThat(bitmap.getWidth() / clipFragment.width())
256+
.as("a rasterized clip must never be published below native 72 DPI")
257+
.isGreaterThanOrEqualTo(1.0);
258+
}
259+
}
260+
}
216261
}

0 commit comments

Comments
 (0)