Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ static void renderSections(List<SectionUnit> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>below</em> 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<Class<?>, PptxFragmentRenderHandler<?>> handlers;

// Package-private: the deck assembly reads each section's chrome directly.
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ public final class PptxRenderEnvironment {
private final List<FragmentLink> fragmentLinks = new ArrayList<>();
private final Set<String> substitutionWarned = new LinkedHashSet<>();
private final java.util.Deque<XSLFGroupShape> groupStack = new java.util.ArrayDeque<>();
private int rasterizedClipCount;
private long rasterizedClipPixels;

PptxRenderEnvironment(XMLSlideShow show,
PptxRenderSession session,
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Loading