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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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) | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand All @@ -102,6 +112,7 @@ private PptxFixedLayoutBackend(Builder builder) {
}
this.handlers = Map.copyOf(merged);
this.rasterSlidesDpi = builder.rasterSlidesDpi;
this.clipRasterFallback = builder.clipRasterFallback;
}

/**
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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<PlacedFragment> fragments = graph.fragments();
Expand All @@ -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<PlacedFragment> 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<PlacedFragment> 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<PlacedFragment> 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.
Expand Down Expand Up @@ -379,6 +485,7 @@ public static final class Builder {

private final List<PptxFragmentRenderHandler<?>> customHandlers = new ArrayList<>();
private int rasterSlidesDpi;
private boolean clipRasterFallback = true;

private Builder() {
}
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading
Loading