diff --git a/CHANGELOG.md b/CHANGELOG.md
index cee5bdae..8ead5c20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -60,6 +60,18 @@ follow semantic versioning; release dates are ISO 8601.
identical everywhere. Slides are not editable as text; the default stays
the editable vector mode.
+- The fixed PPTX backend completes the fragment matrix: images (STRETCH /
+ CONTAIN / COVER via the picture source crop, dimensions decoded from the
+ real bitmap), barcodes (the PDF handler's identical ZXing raster), polygons
+ and free paths as freeform geometry, native DrawingML gradient fills and
+ gradient strokes (radial centres and explicit linear axes approximate, with
+ a one-time note), and transform markers as rotated, centre-pivot-scaled
+ group shapes — every fragment between the markers is created inside the
+ group, PPTX's replacement for PDF's graphics-state matrix. Clip regions
+ render unclipped with a one-time warning (DrawingML has no graphics-state
+ clipping); anchor and bookmark markers record their destinations for the
+ navigation pass.
+
### Fixed
- `DocumentSession.toImages` / `toImage` rasterized documents that use binary
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index d10c4e3a..a9c20d51 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -48,18 +48,18 @@ Payload records live in `core` under
| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ❌ |
| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ❌ |
| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ❌ |
-| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | 🚧 | ❌ |
-| Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | 🚧 | ❌ |
-| Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | 🚧 | ❌ |
-| Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | 🚧 (approximation expected — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ |
-| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | 🚧 | ❌ |
-| Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | 🚧 | ✅ semantic images (`DocxSemanticBackend`) |
-| Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | 🚧 | ❌ |
+| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ❌ |
+| Free path — segments, dash, cap, join (`PathFragmentPayload`) | ✅ `PdfPathFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxPathFragmentRenderHandler` + `PptxInlineGeometry` (numeric dash arrays map to the dashed preset) | ❌ |
+| Linear gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ✅ `PptxGradientFill` (native `gradFill`; explicit-axis endpoints approximate to the angle) | ❌ |
+| Radial gradient fill (`DocumentPaint`) | ✅ `PdfShadingSupport` | ⚠️ `PptxGradientFill` (`circle` path shade — DrawingML cannot express radius-to-farthest-corner exactly) | ❌ |
+| Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ |
+| 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) | 🚧 (rect crops for pictures; other content degrades with a one-time warning — PPTX has no graphics-state clipping) | ⚠️ inline fallback + one-time capability warning |
-| Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | 🚧 (group shape with `xfrm`) | ⚠️ inline fallback + one-time capability warning |
-| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | 🚧 | ❌ |
-| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | 🚧 (PPTX has no document outline; mapped to slide names where 1:1) | ❌ |
+| 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 |
+| 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) | ❌ |
| Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState`) | 🚧 (native fill alpha) | ❌ |
## Navigation and interactivity
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 a9ffc882..e0b7b5de 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
@@ -3,7 +3,17 @@
import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext;
import com.demcha.compose.document.backend.fixed.FixedLayoutRenderer;
import com.demcha.compose.document.backend.fixed.SectionUnit;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxAnchorMarkerRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxBarcodeFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxBookmarkMarkerRenderHandler;
import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxEllipseFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxImageFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxPathFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxPolygonFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxShapeClipBeginRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxShapeClipEndRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxTransformBeginRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxTransformEndRenderHandler;
import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxLineFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxParagraphFragmentRenderHandler;
import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxShapeFragmentRenderHandler;
@@ -49,11 +59,13 @@
* throws {@link UnsupportedNodeCapabilityException}). Custom handlers replace
* built-in defaults per payload type via {@link Builder#addHandler}.
*
- * The backend currently renders paragraphs (including rich runs, chips and
- * inline graphics), table rows, and vector shape, line, and ellipse fragments;
- * the remaining payload types arrive incrementally — see
+ *
The backend currently renders every fragment payload the layout compiler
+ * emits — paragraphs (rich runs, chips, inline graphics), table rows, vector
+ * shapes, lines, ellipses, polygons, free paths (with gradient fills and
+ * strokes), images, barcodes, transform groups, and navigation markers; clip
+ * regions degrade with a one-time warning — see
* {@code docs/architecture/backend-capability-matrix.md} for the live
- * per-capability status. Multi-section rendering and render-to-images are not
+ * per-capability status and documented deviations. Multi-section rendering and render-to-images are not
* implemented yet and throw {@link UnsupportedOperationException}.
*
* Thread-safety: immutable and reusable across renders; each render
@@ -107,7 +119,17 @@ private static List> defaultHandlers() {
new PptxLineFragmentRenderHandler(),
new PptxEllipseFragmentRenderHandler(),
new PptxParagraphFragmentRenderHandler(),
- new PptxTableRowFragmentRenderHandler());
+ new PptxTableRowFragmentRenderHandler(),
+ new PptxImageFragmentRenderHandler(),
+ new PptxBarcodeFragmentRenderHandler(),
+ new PptxPolygonFragmentRenderHandler(),
+ new PptxPathFragmentRenderHandler(),
+ new PptxTransformBeginRenderHandler(),
+ new PptxTransformEndRenderHandler(),
+ new PptxShapeClipBeginRenderHandler(),
+ new PptxShapeClipEndRenderHandler(),
+ new PptxAnchorMarkerRenderHandler(),
+ new PptxBookmarkMarkerRenderHandler());
}
@Override
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 7aa46b9e..945b26f8 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
@@ -15,6 +15,8 @@
import org.apache.poi.xslf.usermodel.XMLSlideShow;
import org.apache.poi.xslf.usermodel.XSLFFontInfo;
import org.apache.poi.xslf.usermodel.XSLFPictureData;
+import org.apache.poi.xslf.usermodel.XSLFGroupShape;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import org.apache.poi.xslf.usermodel.XSLFSlide;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,8 +39,8 @@
* The environment owns the slide surfaces for one document render pass plus
* the navigation bookkeeping that resolves after all fragments have been
* painted: bookmark records, anchor destinations, and fragment-level link
- * rectangles. Handlers draw through {@link #slide(int)} and never create
- * slides themselves.
+ * rectangles. Handlers draw through {@link #surface(int)} - the innermost open
+ * transform group or the page's slide - and never create slides themselves.
*
* Navigation records are collected in slide top-down space (rectangles are
* top-left anchored, in points). Their emission — slide-jump hyperlinks and
@@ -62,10 +64,12 @@ public final class PptxRenderEnvironment {
private final Map customFontFamilies;
private final Map viewerFontMetrics;
private final Map pictureDataCache = new LinkedHashMap<>();
+ private final Map pictureSizeCache = new LinkedHashMap<>();
private final List bookmarkRecords = new ArrayList<>();
private final Map anchorDestinations = new LinkedHashMap<>();
private final List fragmentLinks = new ArrayList<>();
private final Set substitutionWarned = new LinkedHashSet<>();
+ private final java.util.Deque groupStack = new java.util.ArrayDeque<>();
PptxRenderEnvironment(XMLSlideShow show,
PptxRenderSession session,
@@ -134,6 +138,45 @@ public XSLFSlide slide(int pageIndex) {
return session.slide(pageIndex);
}
+ /**
+ * Returns the container new shapes must be created on: the innermost open
+ * transform group when one is active, otherwise the page's slide. The
+ * layout compiler guarantees begin/end markers stay balanced on one page,
+ * so the stack never leaks across slides.
+ *
+ * @param pageIndex zero-based page index
+ * @return active drawing container for that page
+ */
+ public XSLFShapeContainer surface(int pageIndex) {
+ XSLFGroupShape group = groupStack.peek();
+ return group != null ? group : session.slide(pageIndex);
+ }
+
+ /**
+ * Opens a transform group as the active drawing container. Called by the
+ * transform-begin handler; every push is popped by the matching end
+ * marker.
+ *
+ * @param group group shape covering the transformed composite
+ */
+ public void pushGroup(XSLFGroupShape group) {
+ groupStack.push(group);
+ }
+
+ /**
+ * Closes the innermost transform group.
+ *
+ * @return the group that was active
+ */
+ public XSLFGroupShape popGroup() {
+ XSLFGroupShape group = groupStack.poll();
+ if (group == null) {
+ throw new IllegalStateException(
+ "Transform end marker without a matching begin: the group stack is empty.");
+ }
+ return group;
+ }
+
/**
* Returns the page height in points — the constant every vertical
* coordinate flips against when moving from the engine's bottom-left page
@@ -227,6 +270,28 @@ public double viewerAscent(TextStyle style, double fallbackAscent) {
style, font, viewerFontMetrics.get(style.fontName()));
}
+ /**
+ * Returns the image's intrinsic pixel size, decoded once per fingerprint.
+ * POI's header sniffing mis-reports some images (notably tiny PNGs), which
+ * would break the CONTAIN/COVER aspect math the PDF backend computes from
+ * the real bitmap — so both backends read the same decoded dimensions.
+ *
+ * @param imageData engine image payload
+ * @return decoded pixel dimensions, or {@code null} when undecodable
+ */
+ public java.awt.Dimension pictureSize(ImageData imageData) {
+ return pictureSizeCache.computeIfAbsent(imageData.getFingerprint(), ignored -> {
+ try {
+ java.awt.image.BufferedImage decoded = javax.imageio.ImageIO.read(
+ new ByteArrayInputStream(imageData.getBytes()));
+ return decoded == null ? null
+ : new java.awt.Dimension(decoded.getWidth(), decoded.getHeight());
+ } catch (IOException exception) {
+ return null;
+ }
+ });
+ }
+
/**
* Resolves picture data once per distinct image fingerprint.
*
@@ -279,7 +344,14 @@ private static boolean embedFontFamily(XMLSlideShow show,
return true;
}
- void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkOptions) {
+ /**
+ * Records a bookmark for the navigation pass.
+ *
+ * @param fragment placed fragment carrying the bookmark
+ * @param bookmarkOptions resolved bookmark metadata
+ */
+ @Internal
+ public void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkOptions) {
bookmarkRecords.add(new BookmarkRecord(
bookmarkOptions.title(),
bookmarkOptions.level(),
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxAnchorMarkerRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxAnchorMarkerRenderHandler.java
new file mode 100644
index 00000000..ac5efeba
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxAnchorMarkerRenderHandler.java
@@ -0,0 +1,34 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload;
+
+/**
+ * Registers a named anchor destination; nothing is drawn. Slide-jump
+ * hyperlinks resolve against these records when navigation emission lands.
+ *
+ * @since 2.1.0
+ */
+public final class PptxAnchorMarkerRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the anchor-marker handler.
+ */
+ public PptxAnchorMarkerRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return AnchorMarkerPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ AnchorMarkerPayload payload,
+ PptxRenderEnvironment environment) {
+ environment.registerAnchor(fragment, payload.anchor());
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBarcodeFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBarcodeFragmentRenderHandler.java
new file mode 100644
index 00000000..3afcd3f2
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBarcodeFragmentRenderHandler.java
@@ -0,0 +1,135 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.BarcodeFragmentPayload;
+import com.demcha.compose.engine.components.content.barcode.BarcodeData;
+import com.demcha.compose.engine.components.content.barcode.BarcodeType;
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.WriterException;
+import com.google.zxing.common.BitMatrix;
+import com.google.zxing.datamatrix.DataMatrixWriter;
+import com.google.zxing.oned.Code128Writer;
+import com.google.zxing.oned.Code39Writer;
+import com.google.zxing.oned.EAN13Writer;
+import com.google.zxing.oned.EAN8Writer;
+import com.google.zxing.oned.UPCAWriter;
+import com.google.zxing.pdf417.PDF417Writer;
+import com.google.zxing.qrcode.QRCodeWriter;
+import org.apache.poi.sl.usermodel.PictureData;
+import org.apache.poi.xslf.usermodel.XSLFPictureShape;
+
+import javax.imageio.ImageIO;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.EnumMap;
+import java.util.Map;
+
+/**
+ * Renders semantic barcode fragments with the same ZXing raster the PDF
+ * handler produces — identical writer, hints, oversampled render size, and
+ * pixel colors — placed as a picture on the fragment box.
+ *
+ * @since 2.1.0
+ */
+public final class PptxBarcodeFragmentRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the barcode fragment renderer.
+ */
+ public PptxBarcodeFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return BarcodeFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ BarcodeFragmentPayload payload,
+ PptxRenderEnvironment environment) throws IOException {
+ if (fragment.width() <= 0 || fragment.height() <= 0) {
+ return;
+ }
+ BufferedImage barcode = generateBarcodeImage(
+ payload.barcodeData(), (int) fragment.width(), (int) fragment.height());
+ byte[] png;
+ try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ ImageIO.write(barcode, "PNG", output);
+ png = output.toByteArray();
+ }
+ XSLFPictureShape picture = environment.surface(fragment.pageIndex())
+ .createPicture(environment.slideShow().addPicture(png, PictureData.PictureType.PNG));
+ picture.setAnchor(new Rectangle2D.Double(
+ fragment.x(),
+ PptxCoordinates.topY(environment.canvasHeight(), fragment.y(), fragment.height()),
+ fragment.width(),
+ fragment.height()));
+ }
+
+ /**
+ * Mirrors the PDF handler's raster generation exactly: same oversampling,
+ * hints, and per-pixel foreground/background mapping, so both formats
+ * carry the same barcode bitmap.
+ */
+ private static BufferedImage generateBarcodeImage(BarcodeData data,
+ int width,
+ int height) throws IOException {
+ try {
+ Map hints = new EnumMap<>(EncodeHintType.class);
+ hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
+ if (data.getMargin() >= 0) {
+ hints.put(EncodeHintType.MARGIN, data.getMargin());
+ }
+ int renderWidth = Math.max(width * 2, 200);
+ int renderHeight = Math.max(height * 2, 200);
+ BitMatrix matrix = createWriter(data.getType()).encode(
+ data.getContent(), mapFormat(data.getType()), renderWidth, renderHeight, hints);
+ BufferedImage image = new BufferedImage(
+ matrix.getWidth(), matrix.getHeight(), BufferedImage.TYPE_INT_ARGB);
+ int foreground = data.getForeground().getRGB();
+ int background = data.getBackground().getRGB();
+ for (int py = 0; py < matrix.getHeight(); py++) {
+ for (int px = 0; px < matrix.getWidth(); px++) {
+ image.setRGB(px, py, matrix.get(px, py) ? foreground : background);
+ }
+ }
+ return image;
+ } catch (WriterException ex) {
+ throw new IOException("Failed to generate barcode for type " + data.getType(), ex);
+ }
+ }
+
+ private static com.google.zxing.Writer createWriter(BarcodeType type) {
+ return switch (type) {
+ case QR_CODE -> new QRCodeWriter();
+ case CODE_128 -> new Code128Writer();
+ case CODE_39 -> new Code39Writer();
+ case EAN_13 -> new EAN13Writer();
+ case EAN_8 -> new EAN8Writer();
+ case UPC_A -> new UPCAWriter();
+ case PDF_417 -> new PDF417Writer();
+ case DATA_MATRIX -> new DataMatrixWriter();
+ };
+ }
+
+ private static BarcodeFormat mapFormat(BarcodeType type) {
+ return switch (type) {
+ case QR_CODE -> BarcodeFormat.QR_CODE;
+ case CODE_128 -> BarcodeFormat.CODE_128;
+ case CODE_39 -> BarcodeFormat.CODE_39;
+ case EAN_13 -> BarcodeFormat.EAN_13;
+ case EAN_8 -> BarcodeFormat.EAN_8;
+ case UPC_A -> BarcodeFormat.UPC_A;
+ case PDF_417 -> BarcodeFormat.PDF_417;
+ case DATA_MATRIX -> BarcodeFormat.DATA_MATRIX;
+ };
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBookmarkMarkerRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBookmarkMarkerRenderHandler.java
new file mode 100644
index 00000000..0f10ef04
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBookmarkMarkerRenderHandler.java
@@ -0,0 +1,35 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.BookmarkMarkerPayload;
+
+/**
+ * Dispatch target for bookmark markers; nothing is drawn here — the backend's
+ * generic post-render bookkeeping records the bookmark, mirroring the PDF
+ * backend's no-op marker handler.
+ *
+ * @since 2.1.0
+ */
+public final class PptxBookmarkMarkerRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the bookmark-marker handler.
+ */
+ public PptxBookmarkMarkerRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return BookmarkMarkerPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ BookmarkMarkerPayload payload,
+ PptxRenderEnvironment environment) {
+ // Intentionally empty: finishRenderedFragment registers the bookmark.
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxCapabilityNotes.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxCapabilityNotes.java
index 7768a708..df740d14 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxCapabilityNotes.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxCapabilityNotes.java
@@ -32,10 +32,22 @@ static void perCornerRadiiApproximated() {
+ "the PPTX roundRect preset carries a single adjust value");
}
+ static void clipSkipped() {
+ warnOnce("clip",
+ "clip regions render unclipped — DrawingML has no graphics-state clipping; "
+ + "use the PDF backend or raster-slide mode for exact clipping");
+ }
+
+ static void gradientGeometryApproximated() {
+ warnOnce("gradient-geometry",
+ "radial gradient centres and explicit linear axes approximate — DrawingML "
+ + "expresses gradients relative to the shape box, not exact endpoints");
+ }
+
static void gradientApproximated() {
warnOnce("gradient",
- "gradient fills render as the gradient's primary color until DrawingML gradient "
- + "support lands");
+ "inline SVG gradient layers render rasterized or as their primary color - "
+ + "per-layer gradient geometry has no native DrawingML mapping inside a glyph box");
}
static void inlineSvgRasterized() {
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxEllipseFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxEllipseFragmentRenderHandler.java
index f8ba850a..38906928 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxEllipseFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxEllipseFragmentRenderHandler.java
@@ -39,7 +39,7 @@ public void render(PlacedFragment fragment,
if (payload.fillColor() == null && !PptxShapeStyle.drawable(payload.stroke())) {
return;
}
- XSLFAutoShape shape = environment.slide(fragment.pageIndex()).createAutoShape();
+ XSLFAutoShape shape = environment.surface(fragment.pageIndex()).createAutoShape();
shape.setShapeType(ShapeType.ELLIPSE);
shape.setAnchor(PptxCoordinates.anchorOf(environment.canvasHeight(), fragment));
PptxShapeStyle.applyFill(shape, payload.fillColor());
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java
new file mode 100644
index 00000000..dbf9a3b0
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java
@@ -0,0 +1,152 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.style.DocumentPaint;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTGradientFillProperties;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTGradientStop;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTLineProperties;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTSRgbColor;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTShapeProperties;
+import org.openxmlformats.schemas.drawingml.x2006.main.STPathShadeType;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTConnector;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;
+
+import java.awt.Color;
+import java.util.List;
+
+/**
+ * Stamps DrawingML gradient fills and gradient strokes from the engine's
+ * {@link DocumentPaint} values.
+ *
+ * Linear gradients are native ({@code lin} with the angle converted from
+ * the engine's counter-clockwise 0 = left→right convention to
+ * DrawingML's clockwise degrees). An explicit {@code LinearAxis} keeps its
+ * direction but DrawingML cannot place exact endpoints, so the axis extent is
+ * approximate. Radial gradients map to the {@code circle} path shade — the
+ * closest DrawingML has to PDF's radius-to-farthest-corner shading — with the
+ * centre expressed through the fill-to rectangle. Both approximations warn
+ * once per JVM.
+ */
+final class PptxGradientFill {
+
+ private PptxGradientFill() {
+ }
+
+ static void applyGradientFill(XSLFShape shape, DocumentPaint paint) {
+ CTShapeProperties properties = shapeProperties(shape);
+ if (properties == null) {
+ return;
+ }
+ if (properties.isSetSolidFill()) {
+ properties.unsetSolidFill();
+ }
+ if (properties.isSetNoFill()) {
+ properties.unsetNoFill();
+ }
+ gradient(properties.isSetGradFill() ? clearGradient(properties) : properties.addNewGradFill(), paint);
+ }
+
+ /**
+ * Applies a gradient stroking colour — DrawingML lines accept
+ * {@code gradFill} natively, unlike PDF's pattern workaround.
+ */
+ static void applyGradientStroke(XSLFShape shape, DocumentPaint paint, double width) {
+ CTShapeProperties properties = shapeProperties(shape);
+ if (properties == null) {
+ return;
+ }
+ CTLineProperties line = properties.isSetLn() ? properties.getLn() : properties.addNewLn();
+ line.setW((int) Math.round(width * 12700.0));
+ if (line.isSetSolidFill()) {
+ line.unsetSolidFill();
+ }
+ // POI's setLineColor(null) writes an explicit noFill; leaving it next
+ // to gradFill makes PowerPoint honour noFill and drop the stroke.
+ if (line.isSetNoFill()) {
+ line.unsetNoFill();
+ }
+ if (line.isSetPattFill()) {
+ line.unsetPattFill();
+ }
+ gradient(line.isSetGradFill() ? line.getGradFill() : line.addNewGradFill(), paint);
+ }
+
+ private static CTGradientFillProperties clearGradient(CTShapeProperties properties) {
+ properties.unsetGradFill();
+ return properties.addNewGradFill();
+ }
+
+ private static void gradient(CTGradientFillProperties fill, DocumentPaint paint) {
+ if (paint instanceof DocumentPaint.Solid solid) {
+ // Contractually unreachable (solid paints normalize to fillColor);
+ // guard so a slipped-through Solid still paints something.
+ stops(fill, List.of(new DocumentPaint.Stop(0.0, solid.primaryColor()),
+ new DocumentPaint.Stop(1.0, solid.primaryColor())));
+ fill.addNewLin().setAng(0);
+ } else if (paint instanceof DocumentPaint.Linear linear) {
+ stops(fill, linear.stops());
+ fill.addNewLin().setAng(drawingMlAngle(linear.angleDegrees()));
+ } else if (paint instanceof DocumentPaint.LinearAxis axis) {
+ PptxCapabilityNotes.gradientGeometryApproximated();
+ stops(fill, axis.stops());
+ double degrees = Math.toDegrees(Math.atan2(axis.y1() - axis.y0(), axis.x1() - axis.x0()));
+ fill.addNewLin().setAng(drawingMlAngle(degrees));
+ } else if (paint instanceof DocumentPaint.Radial radial) {
+ PptxCapabilityNotes.gradientGeometryApproximated();
+ stops(fill, radial.stops());
+ radialPath(fill, radial.cx(), radial.cy());
+ } else if (paint instanceof DocumentPaint.RadialCircle circle) {
+ PptxCapabilityNotes.gradientGeometryApproximated();
+ stops(fill, circle.stops());
+ radialPath(fill, circle.cx(), circle.cy());
+ }
+ }
+
+ private static void radialPath(CTGradientFillProperties fill, double cx, double cy) {
+ // fillToRect pins the gradient origin: all four edges collapse onto the
+ // centre point, expressed in thousandths of a percent of the box. The
+ // engine's normalized cy is y-up, DrawingML's is top-down.
+ CTRelativeRect fillTo = fill.addNewPath().addNewFillToRect();
+ int left = (int) Math.round(cx * 100000.0);
+ int top = (int) Math.round((1.0 - cy) * 100000.0);
+ fillTo.setL(left);
+ fillTo.setT(top);
+ fillTo.setR(100000 - left);
+ fillTo.setB(100000 - top);
+ fill.getPath().setPath(STPathShadeType.CIRCLE);
+ }
+
+ private static void stops(CTGradientFillProperties fill, List stops) {
+ var stopList = fill.isSetGsLst() ? fill.getGsLst() : fill.addNewGsLst();
+ for (DocumentPaint.Stop stop : stops) {
+ CTGradientStop gradientStop = stopList.addNewGs();
+ gradientStop.setPos((int) Math.round(stop.offset() * 100000.0));
+ // Stop colors are opaque by contract (DocumentPaint.Stop rejects
+ // alpha), so only the RGB channels travel.
+ Color color = stop.color().color();
+ CTSRgbColor rgb = gradientStop.addNewSrgbClr();
+ rgb.setVal(new byte[]{
+ (byte) color.getRed(), (byte) color.getGreen(), (byte) color.getBlue()});
+ }
+ }
+
+ /**
+ * Engine angles are counter-clockwise with 0 = left→right; DrawingML
+ * angles are clockwise sixtieths of a degree from the same axis.
+ */
+ private static int drawingMlAngle(double engineDegrees) {
+ double clockwise = ((360.0 - engineDegrees) % 360.0 + 360.0) % 360.0;
+ return (int) Math.round(clockwise * 60000.0);
+ }
+
+ private static CTShapeProperties shapeProperties(XSLFShape shape) {
+ if (shape.getXmlObject() instanceof CTShape ctShape) {
+ return ctShape.getSpPr();
+ }
+ if (shape.getXmlObject() instanceof CTConnector ctConnector) {
+ return ctConnector.getSpPr();
+ }
+ return null;
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxImageFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxImageFragmentRenderHandler.java
new file mode 100644
index 00000000..88a4793e
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxImageFragmentRenderHandler.java
@@ -0,0 +1,93 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.ImageFragmentPayload;
+import org.apache.poi.xslf.usermodel.XSLFPictureData;
+import org.apache.poi.xslf.usermodel.XSLFPictureShape;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTRelativeRect;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTPicture;
+
+import java.awt.Dimension;
+import java.awt.geom.Rectangle2D;
+
+/**
+ * Renders resolved image fragments as pictures with the PDF handler's fit
+ * modes: STRETCH fills the box, CONTAIN letter-boxes the centered image
+ * inside it, and COVER crops via the picture's source rectangle — DrawingML's
+ * native crop, so no clipping is needed.
+ *
+ * @since 2.1.0
+ */
+public final class PptxImageFragmentRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the image fragment renderer.
+ */
+ public PptxImageFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return ImageFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ ImageFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ if (fragment.width() <= 0 || fragment.height() <= 0) {
+ return;
+ }
+ XSLFPictureData data = environment.resolvePicture(payload.imageData());
+ Dimension size = environment.pictureSize(payload.imageData());
+ if (size == null) {
+ size = data.getImageDimensionInPixels();
+ }
+ double sourceWidth = Math.max(1, size.getWidth());
+ double sourceHeight = Math.max(1, size.getHeight());
+ double boxWidth = fragment.width();
+ double boxHeight = fragment.height();
+ double boxTop = PptxCoordinates.topY(
+ environment.canvasHeight(), fragment.y(), fragment.height());
+
+ XSLFPictureShape picture = environment.surface(fragment.pageIndex()).createPicture(data);
+ if (payload.fitMode() == DocumentImageFitMode.STRETCH) {
+ picture.setAnchor(new Rectangle2D.Double(fragment.x(), boxTop, boxWidth, boxHeight));
+ return;
+ }
+ if (payload.fitMode() == DocumentImageFitMode.COVER) {
+ // The picture anchors on the full fragment box and the overflow is
+ // cropped away in source space: the same centered-crop geometry the
+ // PDF handler produces with a clip, expressed as srcRect fractions.
+ picture.setAnchor(new Rectangle2D.Double(fragment.x(), boxTop, boxWidth, boxHeight));
+ double scale = Math.max(boxWidth / sourceWidth, boxHeight / sourceHeight);
+ double horizontalCrop = (sourceWidth * scale - boxWidth) / (sourceWidth * scale) / 2.0;
+ double verticalCrop = (sourceHeight * scale - boxHeight) / (sourceHeight * scale) / 2.0;
+ CTRelativeRect srcRect = ((CTPicture) picture.getXmlObject())
+ .getBlipFill().addNewSrcRect();
+ srcRect.setL(toThousandthPercent(horizontalCrop));
+ srcRect.setR(toThousandthPercent(horizontalCrop));
+ srcRect.setT(toThousandthPercent(verticalCrop));
+ srcRect.setB(toThousandthPercent(verticalCrop));
+ return;
+ }
+ // CONTAIN: centered letter-box inside the fragment, no crop.
+ double scale = Math.min(boxWidth / sourceWidth, boxHeight / sourceHeight);
+ double drawWidth = sourceWidth * scale;
+ double drawHeight = sourceHeight * scale;
+ picture.setAnchor(new Rectangle2D.Double(
+ fragment.x() + (boxWidth - drawWidth) / 2.0,
+ boxTop + (boxHeight - drawHeight) / 2.0,
+ drawWidth,
+ drawHeight));
+ }
+
+ private static int toThousandthPercent(double fraction) {
+ return (int) Math.round(Math.max(0, fraction) * 100000.0);
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java
index fe0f3d25..752997ba 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineGeometry.java
@@ -7,7 +7,7 @@
import org.apache.poi.sl.usermodel.ShapeType;
import org.apache.poi.xslf.usermodel.XSLFAutoShape;
import org.apache.poi.xslf.usermodel.XSLFFreeformShape;
-import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import java.awt.Color;
import java.awt.geom.Path2D;
@@ -20,33 +20,33 @@ final class PptxInlineGeometry {
private PptxInlineGeometry() {
}
- static void drawOutline(XSLFSlide slide,
+ static void drawOutline(XSLFShapeContainer surface,
ShapeOutline outline,
Rectangle2D box,
Color fill,
Stroke stroke) {
if (outline instanceof ShapeOutline.Rectangle) {
- drawPreset(slide, ShapeType.RECT, box, fill, stroke);
+ drawPreset(surface, ShapeType.RECT, box, fill, stroke);
} else if (outline instanceof ShapeOutline.RoundedRectangle rounded) {
- XSLFAutoShape shape = drawPreset(slide, ShapeType.ROUND_RECT, box, fill, stroke);
+ XSLFAutoShape shape = drawPreset(surface, ShapeType.ROUND_RECT, box, fill, stroke);
PptxShapeStyle.applyRoundRectAdjust(shape, rounded.cornerRadius(),
box.getWidth(), box.getHeight());
} else if (outline instanceof ShapeOutline.RoundedRectanglePerCorner rounded) {
PptxCapabilityNotes.perCornerRadiiApproximated();
- XSLFAutoShape shape = drawPreset(slide, ShapeType.ROUND_RECT, box, fill, stroke);
+ XSLFAutoShape shape = drawPreset(surface, ShapeType.ROUND_RECT, box, fill, stroke);
PptxShapeStyle.applyRoundRectAdjust(shape, rounded.corners().topLeft(),
box.getWidth(), box.getHeight());
} else if (outline instanceof ShapeOutline.Ellipse) {
- drawPreset(slide, ShapeType.ELLIPSE, box, fill, stroke);
+ drawPreset(surface, ShapeType.ELLIPSE, box, fill, stroke);
} else if (outline instanceof ShapeOutline.Polygon polygon) {
- drawPath(slide, polygonPath(polygon.points(), box), fill, stroke);
+ drawPath(surface, polygonPath(polygon.points(), box), fill, stroke);
} else if (outline instanceof ShapeOutline.Path path) {
- drawPath(slide, path(path.segments(), box), fill, stroke);
+ drawPath(surface, path(path.segments(), box), fill, stroke);
}
}
- static XSLFFreeformShape drawPath(XSLFSlide slide, Path2D path, Color fill, Stroke stroke) {
- XSLFFreeformShape shape = slide.createFreeform();
+ static XSLFFreeformShape drawPath(XSLFShapeContainer surface, Path2D path, Color fill, Stroke stroke) {
+ XSLFFreeformShape shape = surface.createFreeform();
shape.setPath(path);
PptxShapeStyle.applyFill(shape, fill);
PptxShapeStyle.applyStroke(shape, stroke);
@@ -72,6 +72,15 @@ static Path2D path(List segments, Rectangle2D box) {
return path;
}
+ /** Draws a normalized vertex ring scaled to the box as a closed freeform. */
+ static XSLFFreeformShape drawPolygon(XSLFShapeContainer surface,
+ List points,
+ Rectangle2D box,
+ Color fill,
+ Stroke stroke) {
+ return drawPath(surface, polygonPath(points, box), fill, stroke);
+ }
+
private static Path2D polygonPath(List points, Rectangle2D box) {
Path2D.Double path = new Path2D.Double(Path2D.WIND_NON_ZERO);
for (int i = 0; i < points.size(); i++) {
@@ -86,12 +95,12 @@ private static Path2D polygonPath(List points, Rectangle2D box) {
return path;
}
- private static XSLFAutoShape drawPreset(XSLFSlide slide,
+ private static XSLFAutoShape drawPreset(XSLFShapeContainer surface,
ShapeType type,
Rectangle2D box,
Color fill,
Stroke stroke) {
- XSLFAutoShape shape = slide.createAutoShape();
+ XSLFAutoShape shape = surface.createAutoShape();
shape.setShapeType(type);
shape.setAnchor(box);
PptxShapeStyle.applyFill(shape, fill);
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
index ddefe58e..d9d902b0 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxLineFragmentRenderHandler.java
@@ -51,7 +51,7 @@ public void render(PlacedFragment fragment,
double x2 = fragment.x() + payload.endX();
double y2 = PptxCoordinates.flipPoint(canvasHeight, fragment.y() + payload.endY());
- XSLFConnectorShape line = environment.slide(fragment.pageIndex()).createConnector();
+ XSLFConnectorShape line = environment.surface(fragment.pageIndex()).createConnector();
line.setShapeType(ShapeType.LINE);
line.setAnchor(new Rectangle2D.Double(
Math.min(x1, x2),
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
index 9031451d..ecca477d 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxParagraphFragmentRenderHandler.java
@@ -12,6 +12,7 @@
import com.demcha.compose.engine.render.pdf.PdfFont;
import com.demcha.compose.font.FontLibrary;
import org.apache.poi.sl.usermodel.ShapeType;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import org.apache.poi.xslf.usermodel.*;
import java.awt.Color;
@@ -70,7 +71,7 @@ private static void renderLine(int pageIndex,
if (line.spans().isEmpty()) {
return;
}
- XSLFSlide slide = environment.slide(pageIndex);
+ XSLFShapeContainer surface = environment.surface(pageIndex);
// PowerPoint seats one shared baseline per paragraph using its largest
// run, so one shared frame is only safe while every plain run agrees on
// font size; chips, inline graphics, and mixed sizes go through
@@ -82,7 +83,7 @@ private static void renderLine(int pageIndex,
if (!usesAbsoluteSpans) {
double top = environment.canvasHeight() - baselineY
- sharedBoxViewerAscent(line, environment);
- XSLFTextBox lineBox = PptxTextFrames.newTextBox(slide,
+ XSLFTextBox lineBox = PptxTextFrames.newTextBox(surface,
new Rectangle2D.Double(lineX, top,
Math.max(PptxTextFrames.FRAME_EPSILON, line.width() + PptxTextFrames.FRAME_EPSILON),
Math.max(PptxTextFrames.FRAME_EPSILON, line.textLineHeight())));
@@ -99,28 +100,28 @@ private static void renderLine(int pageIndex,
String text = sanitized(fonts, textSpan);
if (textSpan.background() == null) {
if (paragraph == null) {
- renderTextSpan(slide, textSpan, text, cursorX, baselineY, fonts,
+ renderTextSpan(surface, textSpan, text, cursorX, baselineY, fonts,
environment.canvasHeight(), environment);
} else {
PptxTextFrames.addRun(paragraph, text, textSpan.textStyle(), environment);
}
} else {
- renderChip(slide, textSpan, text, cursorX, baselineY, line, fonts, environment);
+ renderChip(surface, textSpan, text, cursorX, baselineY, line, fonts, environment);
}
} else if (span instanceof ParagraphImageSpan imageSpan) {
- renderImage(slide, imageSpan, cursorX, baselineY, line, environment);
+ renderImage(surface, imageSpan, cursorX, baselineY, line, environment);
} else if (span instanceof ParagraphShapeSpan shapeSpan) {
- renderShape(slide, shapeSpan, cursorX, baselineY, line, environment.canvasHeight());
+ renderShape(surface, shapeSpan, cursorX, baselineY, line, environment.canvasHeight());
} else if (span instanceof ParagraphSvgSpan svgSpan) {
- renderSvg(slide, svgSpan, cursorX, baselineY, line, environment);
+ renderSvg(surface, svgSpan, cursorX, baselineY, line, environment);
}
- renderExternalLinkOverlay(slide, spanLinkTarget(span), cursorX, span.width(),
+ renderExternalLinkOverlay(surface, spanLinkTarget(span), cursorX, span.width(),
lineTop, line, environment);
cursorX += span.width();
}
// Paragraph-level links get one hotspot per line, tight to the measured
// line box — the same per-line emission the PDF backend uses.
- renderExternalLinkOverlay(slide, payload.linkTarget(), lineX, line.width(),
+ renderExternalLinkOverlay(surface, payload.linkTarget(), lineX, line.width(),
lineTop, line, environment);
}
@@ -176,7 +177,7 @@ private static com.demcha.compose.document.node.DocumentLinkTarget spanLinkTarge
return null;
}
- private static void renderTextSpan(XSLFSlide slide,
+ private static void renderTextSpan(XSLFShapeContainer surface,
ParagraphTextSpan span,
String text,
double cursorX,
@@ -187,14 +188,14 @@ private static void renderTextSpan(XSLFSlide slide,
PdfFont.VerticalMetrics metrics = verticalMetrics(fonts, span);
double top = canvasHeight - baselineY
- environment.viewerAscent(span.textStyle(), metrics.ascent());
- XSLFTextBox textBox = PptxTextFrames.newTextBox(slide, new Rectangle2D.Double(
+ XSLFTextBox textBox = PptxTextFrames.newTextBox(surface, new Rectangle2D.Double(
cursorX, top, Math.max(PptxTextFrames.FRAME_EPSILON, span.width() + PptxTextFrames.FRAME_EPSILON),
Math.max(PptxTextFrames.FRAME_EPSILON, metrics.lineHeight())));
PptxTextFrames.setShapeName(textBox, "GraphCompose Inline Text Span");
PptxTextFrames.addRun(PptxTextFrames.preparedParagraph(textBox), text, span.textStyle(), environment);
}
- private static void renderChip(XSLFSlide slide,
+ private static void renderChip(XSLFShapeContainer surface,
ParagraphTextSpan span,
String text,
double cursorX,
@@ -212,7 +213,7 @@ private static void renderChip(XSLFSlide slide,
double canvasHeight = environment.canvasHeight();
Rectangle2D chipAnchor = new Rectangle2D.Double(
cursorX, canvasHeight - chipBottom - chipHeight, span.width(), chipHeight);
- XSLFAutoShape chip = slide.createAutoShape();
+ XSLFAutoShape chip = surface.createAutoShape();
chip.setShapeType(background.cornerRadius() > 0 ? ShapeType.ROUND_RECT : ShapeType.RECT);
chip.setAnchor(chipAnchor);
if (background.cornerRadius() > 0) {
@@ -225,14 +226,14 @@ private static void renderChip(XSLFSlide slide,
double textTop = canvasHeight - baselineY
- environment.viewerAscent(span.textStyle(), metrics.ascent());
double textWidth = Math.max(PptxTextFrames.FRAME_EPSILON, span.width() - padding.horizontal());
- XSLFTextBox textBox = PptxTextFrames.newTextBox(slide, new Rectangle2D.Double(
+ XSLFTextBox textBox = PptxTextFrames.newTextBox(surface, new Rectangle2D.Double(
cursorX + padding.left(), textTop, textWidth,
Math.max(PptxTextFrames.FRAME_EPSILON, metrics.lineHeight())));
PptxTextFrames.setShapeName(textBox, "GraphCompose Inline Chip Text");
PptxTextFrames.addRun(PptxTextFrames.preparedParagraph(textBox), text, span.textStyle(), environment);
}
- private static void renderExternalLinkOverlay(XSLFSlide slide,
+ private static void renderExternalLinkOverlay(XSLFShapeContainer surface,
com.demcha.compose.document.node.DocumentLinkTarget target,
double x,
double width,
@@ -242,7 +243,7 @@ private static void renderExternalLinkOverlay(XSLFSlide slide,
if (!(target instanceof ExternalLinkTarget external) || width <= 0) {
return;
}
- XSLFAutoShape hotspot = slide.createAutoShape();
+ XSLFAutoShape hotspot = surface.createAutoShape();
hotspot.setShapeType(ShapeType.RECT);
hotspot.setAnchor(new Rectangle2D.Double(x,
environment.canvasHeight() - lineTop, width, line.lineHeight()));
@@ -254,19 +255,19 @@ private static void renderExternalLinkOverlay(XSLFSlide slide,
hotspot.createHyperlink().linkToUrl(external.options().uri());
}
- private static void renderImage(XSLFSlide slide,
+ private static void renderImage(XSLFShapeContainer surface,
ParagraphImageSpan span,
double cursorX,
double baselineY,
ParagraphLine line,
PptxRenderEnvironment environment) {
double bottom = inlineBottom(span.height(), span.alignment(), span.baselineOffset(), baselineY, line);
- XSLFPictureShape picture = slide.createPicture(environment.resolvePicture(span.imageData()));
+ XSLFPictureShape picture = surface.createPicture(environment.resolvePicture(span.imageData()));
picture.setAnchor(new Rectangle2D.Double(cursorX,
environment.canvasHeight() - bottom - span.height(), span.width(), span.height()));
}
- private static void renderShape(XSLFSlide slide,
+ private static void renderShape(XSLFShapeContainer surface,
ParagraphShapeSpan span,
double cursorX,
double baselineY,
@@ -283,11 +284,11 @@ private static void renderShape(XSLFSlide slide,
cursorX + (span.width() - width) / 2.0,
canvasHeight - bottom - (span.height() + height) / 2.0,
width, height);
- PptxInlineGeometry.drawOutline(slide, layer.outline(), box, layer.fillColor(), layer.stroke());
+ PptxInlineGeometry.drawOutline(surface, layer.outline(), box, layer.fillColor(), layer.stroke());
}
}
- private static void renderSvg(XSLFSlide slide,
+ private static void renderSvg(XSLFShapeContainer surface,
ParagraphSvgSpan span,
double cursorX,
double baselineY,
@@ -305,7 +306,7 @@ private static void renderSvg(XSLFSlide slide,
layer.fillPaint() != null || layer.strokePaint() != null)) {
PptxCapabilityNotes.gradientApproximated();
}
- XSLFPictureShape picture = slide.createPicture(
+ XSLFPictureShape picture = surface.createPicture(
environment.resolvePicture(PptxInlineSvgRasterizer.rasterize(span)));
picture.setAnchor(box);
return;
@@ -321,7 +322,7 @@ private static void renderSvg(XSLFSlide slide,
stroke = new com.demcha.compose.engine.components.content.shape.Stroke(
layer.strokePaint().primaryColor().color(), stroke.width());
}
- PptxInlineGeometry.drawPath(slide,
+ PptxInlineGeometry.drawPath(surface,
PptxInlineGeometry.path(layer.segments(), box), fill, stroke);
}
}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPathFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPathFragmentRenderHandler.java
new file mode 100644
index 00000000..145212a7
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPathFragmentRenderHandler.java
@@ -0,0 +1,71 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
+import org.apache.poi.xslf.usermodel.XSLFFreeformShape;
+
+import java.awt.Color;
+
+/**
+ * Renders free path fragments as freeform shapes: segments scale to the
+ * fragment box (y-up normalized coordinates flipped into slide space), solid
+ * fills and strokes apply natively, and gradient fills or gradient strokes
+ * stamp DrawingML {@code gradFill} — natively, unlike PDF's shading-pattern
+ * workaround. Dash patterns, caps, and joins map through the shared style
+ * helper.
+ *
+ * @since 2.1.0
+ */
+public final class PptxPathFragmentRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the path fragment renderer.
+ */
+ public PptxPathFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return PathFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ PathFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ if (fragment.width() <= 0 || fragment.height() <= 0 || payload.segments().isEmpty()) {
+ return;
+ }
+ boolean hasFill = payload.fillColor() != null || payload.fillPaint() != null;
+ // The payload contract keeps the width on the plain stroke even when a
+ // gradient paints it, matching the PDF painter: no stroke width, no ink.
+ boolean hasGradientStroke = payload.strokePaint() != null
+ && payload.stroke() != null && payload.stroke().width() > 0;
+ boolean hasStroke = PptxShapeStyle.drawable(payload.stroke()) || hasGradientStroke;
+ if (!hasFill && !hasStroke) {
+ return;
+ }
+
+ Color solidFill = payload.fillPaint() == null ? payload.fillColor() : null;
+ XSLFFreeformShape shape = PptxInlineGeometry.drawPath(
+ environment.surface(fragment.pageIndex()),
+ PptxInlineGeometry.path(payload.segments(),
+ PptxCoordinates.anchorOf(environment.canvasHeight(), fragment)),
+ solidFill,
+ hasGradientStroke ? null : payload.stroke());
+ if (payload.fillPaint() != null) {
+ PptxGradientFill.applyGradientFill(shape, payload.fillPaint());
+ }
+ if (hasGradientStroke) {
+ PptxGradientFill.applyGradientStroke(
+ shape, payload.strokePaint(), payload.stroke().width());
+ }
+ PptxShapeStyle.applyLineCap(shape, payload.lineCap());
+ PptxShapeStyle.applyLineJoin(shape, payload.lineJoin());
+ PptxShapeStyle.applyDashPattern(shape, payload.dashPattern());
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPolygonFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPolygonFragmentRenderHandler.java
new file mode 100644
index 00000000..321a7579
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPolygonFragmentRenderHandler.java
@@ -0,0 +1,48 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
+
+/**
+ * Renders fixed polygon fragments as freeform shapes: the payload's
+ * normalized vertex ring scales to the fragment box with the engine's y-up
+ * convention flipped into slide space — the same scaling the PDF handler
+ * applies through {@code PdfShapeGeometry.addPolygonPath}.
+ *
+ * @since 2.1.0
+ */
+public final class PptxPolygonFragmentRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the polygon fragment renderer.
+ */
+ public PptxPolygonFragmentRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return PolygonFragmentPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ PolygonFragmentPayload payload,
+ PptxRenderEnvironment environment) {
+ if (fragment.width() <= 0 || fragment.height() <= 0 || payload.points().size() < 3) {
+ return;
+ }
+ if (payload.fillColor() == null && !PptxShapeStyle.drawable(payload.stroke())) {
+ return;
+ }
+ PptxInlineGeometry.drawPolygon(
+ environment.surface(fragment.pageIndex()),
+ payload.points(),
+ PptxCoordinates.anchorOf(environment.canvasHeight(), fragment),
+ payload.fillColor(),
+ payload.stroke());
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java
new file mode 100644
index 00000000..52fda59b
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java
@@ -0,0 +1,38 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+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.
+ *
+ * @since 2.1.0
+ */
+public final class PptxShapeClipBeginRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the clip-begin handler.
+ */
+ public PptxShapeClipBeginRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return ShapeClipBeginPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ ShapeClipBeginPayload payload,
+ PptxRenderEnvironment environment) {
+ PptxCapabilityNotes.clipSkipped();
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipEndRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipEndRenderHandler.java
new file mode 100644
index 00000000..433f2ffe
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipEndRenderHandler.java
@@ -0,0 +1,34 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload;
+
+/**
+ * Matching end marker for {@link PptxShapeClipBeginRenderHandler}; a no-op,
+ * since the begin marker opens no state in PPTX.
+ *
+ * @since 2.1.0
+ */
+public final class PptxShapeClipEndRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the clip-end handler.
+ */
+ public PptxShapeClipEndRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return ShapeClipEndPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ ShapeClipEndPayload payload,
+ PptxRenderEnvironment environment) {
+ // Intentionally empty: clip-begin degrades to a warning, no state opens.
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeFragmentRenderHandler.java
index 0e882c5d..7ecd40d0 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeFragmentRenderHandler.java
@@ -10,7 +10,7 @@
import org.apache.poi.sl.usermodel.ShapeType;
import org.apache.poi.xslf.usermodel.XSLFAutoShape;
import org.apache.poi.xslf.usermodel.XSLFConnectorShape;
-import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import java.awt.geom.Rectangle2D;
@@ -59,11 +59,11 @@ public void render(PlacedFragment fragment,
return;
}
- XSLFSlide slide = environment.slide(fragment.pageIndex());
+ XSLFShapeContainer surface = environment.surface(fragment.pageIndex());
Rectangle2D.Double anchor = PptxCoordinates.anchorOf(environment.canvasHeight(), fragment);
if (hasFill || (hasStroke && !hasSideBorders)) {
- XSLFAutoShape shape = slide.createAutoShape();
+ XSLFAutoShape shape = surface.createAutoShape();
double radius = uniformRadius(payload.cornerRadius(), anchor.width, anchor.height);
if (radius > 0) {
shape.setShapeType(ShapeType.ROUND_RECT);
@@ -72,15 +72,13 @@ public void render(PlacedFragment fragment,
shape.setShapeType(ShapeType.RECT);
}
shape.setAnchor(anchor);
- // Solid paints are pre-normalised to fillColor; a non-null fillPaint is
- // always a gradient, drawn as its primary color until gradient fills land.
+ // Solid paints are pre-normalised to fillColor; a non-null fillPaint
+ // is always a gradient and stamps native DrawingML gradFill.
if (payload.fillPaint() != null) {
- PptxCapabilityNotes.gradientApproximated();
+ PptxGradientFill.applyGradientFill(shape, payload.fillPaint());
+ } else {
+ PptxShapeStyle.applyFill(shape, payload.fillColor());
}
- java.awt.Color fill = payload.fillColor() != null
- ? payload.fillColor()
- : (payload.fillPaint() != null ? payload.fillPaint().primaryColor().color() : null);
- PptxShapeStyle.applyFill(shape, fill);
PptxShapeStyle.applyStroke(shape, hasSideBorders ? null : payload.stroke());
}
@@ -89,10 +87,10 @@ public void render(PlacedFragment fragment,
double bottom = anchor.y + anchor.height;
double left = anchor.x;
double right = anchor.x + anchor.width;
- drawSideBorder(slide, payload.sideBorders().top(), left, top, right, top);
- drawSideBorder(slide, payload.sideBorders().right(), right, top, right, bottom);
- drawSideBorder(slide, payload.sideBorders().bottom(), left, bottom, right, bottom);
- drawSideBorder(slide, payload.sideBorders().left(), left, top, left, bottom);
+ drawSideBorder(surface, payload.sideBorders().top(), left, top, right, top);
+ drawSideBorder(surface, payload.sideBorders().right(), right, top, right, bottom);
+ drawSideBorder(surface, payload.sideBorders().bottom(), left, bottom, right, bottom);
+ drawSideBorder(surface, payload.sideBorders().left(), left, top, left, bottom);
}
}
@@ -116,13 +114,13 @@ private static double clamp(double raw, double maxAllowed) {
return Math.min(raw, maxAllowed);
}
- private static void drawSideBorder(XSLFSlide slide,
+ private static void drawSideBorder(XSLFShapeContainer surface,
Stroke side,
double x1, double y1, double x2, double y2) {
if (!PptxShapeStyle.drawable(side)) {
return;
}
- XSLFConnectorShape line = slide.createConnector();
+ XSLFConnectorShape line = surface.createConnector();
line.setShapeType(ShapeType.LINE);
line.setAnchor(new Rectangle2D.Double(
Math.min(x1, x2),
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
index ebb13406..8019b4a6 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeStyle.java
@@ -86,6 +86,29 @@ static void applyLineCap(XSLFSimpleShape shape, DocumentLineCap cap) {
});
}
+ /** Applies a line join through the raw line properties (POI has no setter). */
+ static void applyLineJoin(XSLFSimpleShape shape,
+ com.demcha.compose.document.style.DocumentLineJoin join) {
+ if (join == null) {
+ return;
+ }
+ org.openxmlformats.schemas.drawingml.x2006.main.CTShapeProperties properties;
+ if (shape.getXmlObject() instanceof org.openxmlformats.schemas.presentationml.x2006.main.CTShape ctShape) {
+ properties = ctShape.getSpPr();
+ } else if (shape.getXmlObject() instanceof org.openxmlformats.schemas.presentationml.x2006.main.CTConnector ctConnector) {
+ properties = ctConnector.getSpPr();
+ } else {
+ return;
+ }
+ org.openxmlformats.schemas.drawingml.x2006.main.CTLineProperties line =
+ properties.isSetLn() ? properties.getLn() : properties.addNewLn();
+ switch (join) {
+ case MITER -> line.addNewMiter();
+ case ROUND -> line.addNewRound();
+ case BEVEL -> line.addNewBevel();
+ }
+ }
+
/**
* Applies a dash pattern. DrawingML dashes are percent-of-line-width
* presets rather than point arrays, so a non-solid pattern maps to the
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java
index e1baab80..10c0df9c 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTableRowFragmentRenderHandler.java
@@ -15,7 +15,7 @@
import org.apache.poi.sl.usermodel.ShapeType;
import org.apache.poi.xslf.usermodel.XSLFAutoShape;
import org.apache.poi.xslf.usermodel.XSLFConnectorShape;
-import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import java.awt.Color;
import java.awt.geom.Rectangle2D;
@@ -75,7 +75,7 @@ public void render(PlacedFragment fragment,
public void renderFills(PlacedFragment fragment,
TableRowFragmentPayload payload,
PptxRenderEnvironment environment) {
- XSLFSlide slide = environment.slide(fragment.pageIndex());
+ XSLFShapeContainer surface = environment.surface(fragment.pageIndex());
for (TableResolvedCell cell : payload.cells()) {
Color fill = cell.style().fillColor();
if (fill == null || cell.width() <= 0 || cell.height() <= 0) {
@@ -83,7 +83,7 @@ public void renderFills(PlacedFragment fragment,
}
double cellX = fragment.x() + cell.x();
double cellY = fragment.y() + cell.yOffset();
- XSLFAutoShape rectangle = slide.createAutoShape();
+ XSLFAutoShape rectangle = surface.createAutoShape();
rectangle.setShapeType(ShapeType.RECT);
rectangle.setAnchor(new Rectangle2D.Double(
cellX,
@@ -107,7 +107,7 @@ public void renderFills(PlacedFragment fragment,
public void renderBordersAndText(PlacedFragment fragment,
TableRowFragmentPayload payload,
PptxRenderEnvironment environment) {
- XSLFSlide slide = environment.slide(fragment.pageIndex());
+ XSLFShapeContainer surface = environment.surface(fragment.pageIndex());
FontLibrary fonts = environment.fonts();
for (TableResolvedCell cell : payload.cells()) {
double cellX = fragment.x() + cell.x();
@@ -115,13 +115,13 @@ public void renderBordersAndText(PlacedFragment fragment,
// cells, shifting the bottom edge downward through the merged
// rows — same semantics as the PDF handler.
double cellY = fragment.y() + cell.yOffset();
- renderCellBorders(slide, cell, cellX, cellY,
+ renderCellBorders(surface, cell, cellX, cellY,
fragment.width(), payload.startsPageFragment(), environment);
- renderCellText(slide, fonts, cell, cellX, cellY, environment);
+ renderCellText(surface, fonts, cell, cellX, cellY, environment);
}
}
- private static void renderCellBorders(XSLFSlide slide,
+ private static void renderCellBorders(XSLFShapeContainer surface,
TableResolvedCell cell,
double cellX,
double cellY,
@@ -148,27 +148,27 @@ private static void renderCellBorders(XSLFSlide slide,
double topY = PptxCoordinates.flipPoint(canvasHeight, cellY + cell.height());
double bottomY = PptxCoordinates.flipPoint(canvasHeight, cellY);
if (sides.contains(Side.TOP)) {
- edgeLine(slide, strokeColor, strokeWidth,
+ edgeLine(surface, strokeColor, strokeWidth,
cellX - leftCap, topY, cellX + cell.width() + rightCap, topY);
}
if (sides.contains(Side.BOTTOM)) {
- edgeLine(slide, strokeColor, strokeWidth,
+ edgeLine(surface, strokeColor, strokeWidth,
cellX - leftCap, bottomY, cellX + cell.width() + rightCap, bottomY);
}
if (sides.contains(Side.LEFT)) {
- edgeLine(slide, strokeColor, strokeWidth, cellX, topY, cellX, bottomY);
+ edgeLine(surface, strokeColor, strokeWidth, cellX, topY, cellX, bottomY);
}
if (sides.contains(Side.RIGHT)) {
- edgeLine(slide, strokeColor, strokeWidth,
+ edgeLine(surface, strokeColor, strokeWidth,
cellX + cell.width(), topY, cellX + cell.width(), bottomY);
}
}
- private static void edgeLine(XSLFSlide slide,
+ private static void edgeLine(XSLFShapeContainer surface,
Color color,
double width,
double x1, double y1, double x2, double y2) {
- XSLFConnectorShape line = slide.createConnector();
+ XSLFConnectorShape line = surface.createConnector();
line.setShapeType(ShapeType.LINE);
line.setAnchor(new Rectangle2D.Double(
Math.min(x1, x2),
@@ -180,7 +180,7 @@ private static void edgeLine(XSLFSlide slide,
PptxTextFrames.setShapeName(line, "GraphCompose Table Cell Border");
}
- private static void renderCellText(XSLFSlide slide,
+ private static void renderCellText(XSLFShapeContainer surface,
FontLibrary fonts,
TableResolvedCell cell,
double cellX,
@@ -220,7 +220,7 @@ private static void renderCellText(XSLFSlide slide,
double lineBoxY = blockY + lineHeight * (safeLines.size() - lineIndex - 1);
double baselineY = lineBoxY + metrics.baselineOffsetFromBottom();
String safeText = font.sanitizeForRender(cell.style().textStyle(), line);
- PptxTextFrames.singleRunBox(slide,
+ PptxTextFrames.singleRunBox(surface,
"GraphCompose Table Cell Text",
new Rectangle2D.Double(
lineX,
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
index e2b5622f..f4372b1d 100644
--- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTextFrames.java
@@ -6,7 +6,7 @@
import org.apache.poi.sl.usermodel.TextShape.TextAutofit;
import org.apache.poi.sl.usermodel.VerticalAlignment;
import org.apache.poi.xslf.usermodel.XSLFSimpleShape;
-import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.poi.xslf.usermodel.XSLFShapeContainer;
import org.apache.poi.xslf.usermodel.XSLFTextBox;
import org.apache.poi.xslf.usermodel.XSLFTextParagraph;
import org.apache.poi.xslf.usermodel.XSLFTextRun;
@@ -37,8 +37,8 @@ private PptxTextFrames() {
* Creates an absolute text frame that PowerPoint cannot re-lay-out: wrap
* off, autofit off, zero insets, top-anchored.
*/
- static XSLFTextBox newTextBox(XSLFSlide slide, Rectangle2D anchor) {
- XSLFTextBox box = slide.createTextBox();
+ static XSLFTextBox newTextBox(XSLFShapeContainer surface, Rectangle2D anchor) {
+ XSLFTextBox box = surface.createTextBox();
box.setAnchor(anchor);
box.setWordWrap(false);
box.setTextAutofit(TextAutofit.NONE);
@@ -70,13 +70,13 @@ static XSLFTextParagraph preparedParagraph(XSLFTextBox box) {
* Creates a named single-run text frame — the common case for table cells
* and per-span paragraph frames.
*/
- static XSLFTextBox singleRunBox(XSLFSlide slide,
+ static XSLFTextBox singleRunBox(XSLFShapeContainer surface,
String shapeName,
Rectangle2D anchor,
String text,
TextStyle style,
PptxRenderEnvironment environment) {
- XSLFTextBox box = newTextBox(slide, anchor);
+ XSLFTextBox box = newTextBox(surface, anchor);
setShapeName(box, shapeName);
addRun(preparedParagraph(box), text, style, environment);
return box;
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformBeginRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformBeginRenderHandler.java
new file mode 100644
index 00000000..376009dd
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformBeginRenderHandler.java
@@ -0,0 +1,66 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxCoordinates;
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.TransformBeginPayload;
+import com.demcha.compose.document.style.DocumentTransform;
+import org.apache.poi.xslf.usermodel.XSLFGroupShape;
+
+import java.awt.geom.Rectangle2D;
+
+/**
+ * Opens a transform region as a PPTX group shape. PPTX has no graphics-state
+ * stack, so the PDF backend's ambient {@code cm} matrix becomes containment:
+ * every fragment between the begin and end markers is created inside the
+ * group, whose frame then rotates and scales about the fragment centre —
+ * PowerPoint rotates a shape about its own centre, and scaling is the ratio
+ * between the group's exterior frame and its interior coordinate space, so
+ * the composite transforms exactly like the PDF's
+ * {@code T(c)·R(θ)·S(sx,sy)·T(−c)} matrix. The engine's clockwise rotation
+ * convention matches PowerPoint's, so the angle passes through unchanged.
+ *
+ * @since 2.1.0
+ */
+public final class PptxTransformBeginRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the transform-begin handler.
+ */
+ public PptxTransformBeginRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return TransformBeginPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ TransformBeginPayload payload,
+ PptxRenderEnvironment environment) {
+ XSLFGroupShape group = environment.surface(fragment.pageIndex()).createGroup();
+ Rectangle2D.Double box = PptxCoordinates.anchorOf(environment.canvasHeight(), fragment);
+ // Children keep drawing in absolute slide coordinates: the interior
+ // space equals the untransformed fragment box.
+ group.setInteriorAnchor(box);
+ DocumentTransform transform = payload.transform();
+ if (transform.isIdentity()) {
+ group.setAnchor(box);
+ } else {
+ double scaledWidth = box.width * Math.abs(transform.scaleX());
+ double scaledHeight = box.height * Math.abs(transform.scaleY());
+ group.setAnchor(new Rectangle2D.Double(
+ box.x + (box.width - scaledWidth) / 2.0,
+ box.y + (box.height - scaledHeight) / 2.0,
+ scaledWidth,
+ scaledHeight));
+ group.setFlipHorizontal(transform.scaleX() < 0);
+ group.setFlipVertical(transform.scaleY() < 0);
+ group.setRotation(transform.rotationDegrees());
+ }
+ environment.pushGroup(group);
+ }
+}
diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformEndRenderHandler.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformEndRenderHandler.java
new file mode 100644
index 00000000..b8a8eae2
--- /dev/null
+++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformEndRenderHandler.java
@@ -0,0 +1,35 @@
+package com.demcha.compose.document.backend.fixed.pptx.handlers;
+
+import com.demcha.compose.document.backend.fixed.pptx.PptxFragmentRenderHandler;
+import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.TransformEndPayload;
+
+/**
+ * Closes the innermost transform group opened by
+ * {@link PptxTransformBeginRenderHandler}. The layout compiler guarantees the
+ * begin/end pairing on one page, so the pop always matches the push.
+ *
+ * @since 2.1.0
+ */
+public final class PptxTransformEndRenderHandler
+ implements PptxFragmentRenderHandler {
+
+ /**
+ * Creates the transform-end handler.
+ */
+ public PptxTransformEndRenderHandler() {
+ }
+
+ @Override
+ public Class payloadType() {
+ return TransformEndPayload.class;
+ }
+
+ @Override
+ public void render(PlacedFragment fragment,
+ TransformEndPayload payload,
+ PptxRenderEnvironment environment) {
+ environment.popGroup();
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendTest.java
index 8aa2929c..20803993 100644
--- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendTest.java
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendTest.java
@@ -148,13 +148,25 @@ public void close() {
@Test
void unsupportedPayloadFailsWithACapabilityError() {
- try (DocumentSession session = GraphCompose.document()
- .pageSize(300, 200)
- .create()) {
- session.add(new BarcodeBuilder().data("still unsupported").qrCode().size(40, 40).build());
- assertThatThrownBy(() -> session.render(new PptxFixedLayoutBackend()))
- .isInstanceOf(UnsupportedNodeCapabilityException.class)
- .hasMessageContaining("BarcodeFragmentPayload");
+ // Every engine payload now has a handler, so the diagnostic is pinned
+ // with a payload type the registry can never know, rendered through
+ // the public render(graph, context) path.
+ record UnknownPayload() {
}
+ com.demcha.compose.document.layout.LayoutCanvas canvas =
+ new com.demcha.compose.document.layout.LayoutCanvas(
+ 300, 200, 280, 180,
+ com.demcha.compose.engine.components.style.Margin.of(10));
+ com.demcha.compose.document.layout.LayoutGraph graph =
+ new com.demcha.compose.document.layout.LayoutGraph(
+ canvas, 1, java.util.List.of(),
+ java.util.List.of(new com.demcha.compose.document.layout.PlacedFragment(
+ "root/unknown", 0, 0, 10, 10, 50, 50, null, null,
+ new UnknownPayload())));
+ assertThatThrownBy(() -> new PptxFixedLayoutBackend().render(graph,
+ new com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext(
+ canvas, java.util.List.of(), null, null)))
+ .isInstanceOf(UnsupportedNodeCapabilityException.class)
+ .hasMessageContaining("UnknownPayload");
}
}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java
new file mode 100644
index 00000000..25a6949d
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java
@@ -0,0 +1,248 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources;
+import com.demcha.compose.document.dsl.BarcodeBuilder;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.payloads.ImageFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PathFragmentPayload;
+import com.demcha.compose.document.layout.payloads.PolygonFragmentPayload;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentPaint;
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.DocumentTransform;
+import com.demcha.compose.document.style.ShapePoint;
+import com.demcha.compose.engine.components.content.shape.Stroke;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFFreeformShape;
+import org.apache.poi.xslf.usermodel.XSLFGroupShape;
+import org.apache.poi.xslf.usermodel.XSLFPictureShape;
+import org.apache.poi.xslf.usermodel.XSLFShape;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.drawingml.x2006.main.CTLineProperties;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTPicture;
+import org.openxmlformats.schemas.presentationml.x2006.main.CTShape;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The remaining vector payloads land at graph coordinates: image fit modes
+ * (including the COVER source crop), the ZXing barcode picture, polygon and
+ * free-path freeforms with native gradient fills and strokes, and transform
+ * markers materializing as rotated, scaled group shapes.
+ */
+class PptxVectorFragmentsTest {
+
+ private static byte[] twoByOnePng() throws Exception {
+ BufferedImage image = new BufferedImage(2, 1, BufferedImage.TYPE_INT_RGB);
+ image.setRGB(0, 0, Color.RED.getRGB());
+ image.setRGB(1, 0, Color.BLUE.getRGB());
+ try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "png", output);
+ return output.toByteArray();
+ }
+ }
+
+ @Test
+ void imageFitModesAnchorAndCropLikeThePdfHandler() throws Exception {
+ byte[] png = twoByOnePng();
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(400, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ for (DocumentImageFitMode mode : new DocumentImageFitMode[]{
+ DocumentImageFitMode.STRETCH,
+ DocumentImageFitMode.CONTAIN,
+ DocumentImageFitMode.COVER}) {
+ session.add(new ImageBuilder().source(png).size(80, 40).fitMode(mode).build());
+ }
+ LayoutGraph graph = session.render(new GraphCapturingBackend());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+
+ List imageFragments = graph.fragments().stream()
+ .filter(fragment -> fragment.payload() instanceof ImageFragmentPayload)
+ .toList();
+ assertThat(imageFragments).hasSize(3);
+ double canvasHeight = graph.canvas().height();
+
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ List pictures = show.getSlides().get(0).getShapes().stream()
+ .filter(XSLFPictureShape.class::isInstance)
+ .map(XSLFPictureShape.class::cast)
+ .toList();
+ assertThat(pictures).hasSize(3);
+
+ // STRETCH: anchor is exactly the fragment box.
+ PlacedFragment stretch = imageFragments.get(0);
+ assertRect(pictures.get(0).getAnchor(),
+ stretch.x(), canvasHeight - stretch.y() - stretch.height(),
+ stretch.width(), stretch.height());
+
+ // CONTAIN on a 2:1 source in an 80×40 box fills it exactly
+ // (same aspect), so the letter-box equals the fragment box.
+ PlacedFragment contain = imageFragments.get(1);
+ assertRect(pictures.get(1).getAnchor(),
+ contain.x(), canvasHeight - contain.y() - contain.height(),
+ contain.width(), contain.height());
+
+ // COVER: full box plus a symmetric source crop.
+ PlacedFragment cover = imageFragments.get(2);
+ assertRect(pictures.get(2).getAnchor(),
+ cover.x(), canvasHeight - cover.y() - cover.height(),
+ cover.width(), cover.height());
+ var srcRect = ((CTPicture) pictures.get(2).getXmlObject())
+ .getBlipFill().getSrcRect();
+ assertThat(srcRect).as("COVER crops in source space").isNotNull();
+ }
+ }
+ }
+
+ @Test
+ void barcodeRendersAsAPictureOnTheFragmentBox() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 200)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.add(new BarcodeBuilder().data("graphcompose").qrCode().size(60, 60).build());
+ LayoutGraph graph = session.render(new GraphCapturingBackend());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+ PlacedFragment fragment = graph.fragments().stream()
+ .filter(candidate -> candidate.payload().getClass().getSimpleName()
+ .equals("BarcodeFragmentPayload"))
+ .findFirst().orElseThrow();
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ XSLFPictureShape picture = show.getSlides().get(0).getShapes().stream()
+ .filter(XSLFPictureShape.class::isInstance)
+ .map(XSLFPictureShape.class::cast)
+ .findFirst().orElseThrow();
+ assertRect(picture.getAnchor(),
+ fragment.x(),
+ graph.canvas().height() - fragment.y() - fragment.height(),
+ fragment.width(), fragment.height());
+ assertThat(picture.getPictureData().getData().length).isPositive();
+ }
+ }
+ }
+
+ @Test
+ void polygonAndGradientPathRenderAsFreeforms() throws Exception {
+ try (XMLSlideShow show = new XMLSlideShow();
+ PdfMeasurementResources measurement = PdfMeasurementResources.open(List.of())) {
+ PptxRenderSession session = new PptxRenderSession(show, 300, 200, 1);
+ PptxRenderEnvironment environment = new PptxRenderEnvironment(
+ show, session, 0, 200, measurement.fontLibrary(), List.of());
+
+ PolygonFragmentPayload triangle = new PolygonFragmentPayload(
+ List.of(new ShapePoint(0, 0), new ShapePoint(1, 0), new ShapePoint(0.5, 1)),
+ Color.ORANGE, new Stroke(Color.BLACK, 1), null, null);
+ new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxPolygonFragmentRenderHandler()
+ .render(new PlacedFragment("root/poly", 0, 0, 40, 60, 100, 80, null, null,
+ triangle), triangle, environment);
+
+ PathFragmentPayload wave = new PathFragmentPayload(
+ List.of(new DocumentPathSegment.MoveTo(0, 0.5),
+ new DocumentPathSegment.CubicTo(0.25, 1, 0.75, 0, 1, 0.5)),
+ null,
+ new DocumentPaint.Linear(List.of(
+ new DocumentPaint.Stop(0.0, DocumentColor.ROYAL_BLUE),
+ new DocumentPaint.Stop(1.0, DocumentColor.ORANGE)), 90.0),
+ new Stroke(Color.BLACK, 2),
+ new DocumentPaint.Linear(List.of(
+ new DocumentPaint.Stop(0.0, DocumentColor.ORANGE),
+ new DocumentPaint.Stop(1.0, DocumentColor.ROYAL_BLUE)), 0.0),
+ null, null, null, null, null);
+ new com.demcha.compose.document.backend.fixed.pptx.handlers
+ .PptxPathFragmentRenderHandler()
+ .render(new PlacedFragment("root/path", 0, 0, 40, 30, 120, 40, null, null,
+ wave), wave, environment);
+
+ List shapes = show.getSlides().get(0).getShapes();
+ assertThat(shapes).hasSize(2).allMatch(XSLFFreeformShape.class::isInstance);
+
+ Rectangle2D triangleBounds = shapes.get(0).getAnchor();
+ assertRect(triangleBounds, 40, 200 - 60 - 80, 100, 80);
+
+ var pathProperties = ((CTShape) shapes.get(1).getXmlObject()).getSpPr();
+ assertThat(pathProperties.isSetGradFill())
+ .as("gradient fill must be stamped as DrawingML gradFill").isTrue();
+ assertThat(pathProperties.getGradFill().getGsLst().sizeOfGsArray()).isEqualTo(2);
+ // Engine 90 degrees (bottom-to-top, counter-clockwise) is DrawingML
+ // 270 degrees clockwise, in sixtieths of a degree.
+ assertThat(pathProperties.getGradFill().getLin().getAng()).isEqualTo(270 * 60000);
+ CTLineProperties line = pathProperties.getLn();
+ assertThat(line.isSetGradFill())
+ .as("gradient stroke must stamp ln/gradFill").isTrue();
+ assertThat(line.isSetNoFill())
+ .as("a leftover noFill would make PowerPoint drop the stroke").isFalse();
+ assertThat(line.getW()).isEqualTo(2 * 12700);
+ }
+ }
+
+ @Test
+ void transformMarkersBecomeRotatedScaledGroups() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 260)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.add(new ShapeContainerBuilder()
+ .ellipse(80, 80)
+ .layer(new com.demcha.compose.document.dsl.EllipseBuilder()
+ .circle(40).fillColor(DocumentColor.ROYAL_BLUE).build())
+ .transform(new DocumentTransform(30, 1.5, 1.5))
+ .build());
+ LayoutGraph graph = session.render(new GraphCapturingBackend());
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+
+ PlacedFragment beginFragment = graph.fragments().stream()
+ .filter(candidate -> candidate.payload().getClass().getSimpleName()
+ .equals("TransformBeginPayload"))
+ .findFirst().orElseThrow();
+ double canvasHeight = graph.canvas().height();
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ XSLFGroupShape group = show.getSlides().get(0).getShapes().stream()
+ .filter(XSLFGroupShape.class::isInstance)
+ .map(XSLFGroupShape.class::cast)
+ .findFirst().orElseThrow();
+ assertThat(group.getRotation()).isEqualTo(30.0);
+ assertThat(group.getShapes())
+ .as("the transformed composite draws inside the group")
+ .isNotEmpty();
+ Rectangle2D interior = group.getInteriorAnchor();
+ assertRect(interior,
+ beginFragment.x(),
+ canvasHeight - beginFragment.y() - beginFragment.height(),
+ beginFragment.width(), beginFragment.height());
+ Rectangle2D exterior = group.getAnchor();
+ assertThat(exterior.getWidth())
+ .isCloseTo(beginFragment.width() * 1.5,
+ org.assertj.core.data.Offset.offset(0.5));
+ assertThat(exterior.getCenterX())
+ .as("scaling keeps the fragment centre as the pivot")
+ .isCloseTo(interior.getCenterX(), org.assertj.core.data.Offset.offset(0.5));
+ }
+ }
+ }
+
+ private static void assertRect(Rectangle2D actual,
+ double x, double y, double width, double height) {
+ assertThat(actual.getX()).isCloseTo(x, org.assertj.core.data.Offset.offset(0.5));
+ assertThat(actual.getY()).isCloseTo(y, org.assertj.core.data.Offset.offset(0.5));
+ assertThat(actual.getWidth()).isCloseTo(width, org.assertj.core.data.Offset.offset(0.5));
+ assertThat(actual.getHeight()).isCloseTo(height, org.assertj.core.data.Offset.offset(0.5));
+ }
+}
diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorParityDemoTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorParityDemoTest.java
new file mode 100644
index 00000000..d11795d5
--- /dev/null
+++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorParityDemoTest.java
@@ -0,0 +1,119 @@
+package com.demcha.compose.document.backend.fixed.pptx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.BarcodeBuilder;
+import com.demcha.compose.document.dsl.EllipseBuilder;
+import com.demcha.compose.document.dsl.ImageBuilder;
+import com.demcha.compose.document.dsl.ShapeContainerBuilder;
+import com.demcha.compose.document.image.DocumentImageFitMode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTransform;
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.GradientPaint;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reviewable vector parity pair: image fit modes, a ZXing QR code, and a
+ * rotated, scaled shape-container badge rendered to PDF and PPTX with
+ * per-page PNG previews at matching scale.
+ */
+class PptxVectorParityDemoTest {
+
+ private static final double SCALE = 2.0;
+
+ @Test
+ void writesTheVectorParityDemoPair() throws Exception {
+ Path output = Path.of("target", "visual-tests", "pptx-parity", "vector-fragments");
+ Files.createDirectories(output);
+
+ try (DocumentSession session = composeDemo()) {
+ byte[] pdf = session.toPdfBytes();
+ Files.write(output.resolve("vectors.pdf"), pdf);
+ byte[] pptx = session.render(new PptxFixedLayoutBackend());
+ Files.write(output.resolve("vectors.pptx"), pptx);
+
+ List pdfPages = session.toImages((int) Math.round(72 * SCALE));
+ try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
+ assertThat(show.getSlides()).hasSameSizeAs(pdfPages);
+ for (int i = 0; i < pdfPages.size(); i++) {
+ ImageIO.write(pdfPages.get(i), "png",
+ output.resolve("vectors-page-" + (i + 1) + ".pdf.png").toFile());
+ ImageIO.write(rasterize(show.getSlides().get(i),
+ show.getPageSize().width, show.getPageSize().height),
+ "png", output.resolve("vectors-page-" + (i + 1) + ".pptx.png").toFile());
+ }
+ }
+ }
+ }
+
+ private static DocumentSession composeDemo() throws Exception {
+ byte[] photo = gradientPhoto();
+ DocumentSession session = GraphCompose.document()
+ .pageSize(480, 320)
+ .margin(DocumentInsets.of(24))
+ .create();
+ session.add(new ImageBuilder().source(photo).size(120, 60)
+ .fitMode(DocumentImageFitMode.STRETCH).build());
+ session.add(new ImageBuilder().source(photo).size(120, 60)
+ .fitMode(DocumentImageFitMode.COVER).build());
+ session.add(new BarcodeBuilder().data("https://github.com/DemchaAV/GraphCompose")
+ .qrCode().size(70, 70).build());
+ session.add(new ShapeContainerBuilder()
+ .ellipse(70, 70)
+ .layer(new EllipseBuilder().circle(36)
+ .fillColor(DocumentColor.ROYAL_BLUE).build())
+ .transform(new DocumentTransform(25, 1.3, 1.3))
+ .build());
+ return session;
+ }
+
+ private static byte[] gradientPhoto() throws Exception {
+ BufferedImage image = new BufferedImage(160, 40, BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setPaint(new GradientPaint(0, 0, new Color(30, 64, 175),
+ 160, 40, new Color(249, 115, 22)));
+ graphics.fillRect(0, 0, 160, 40);
+ } finally {
+ graphics.dispose();
+ }
+ try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "png", output);
+ return output.toByteArray();
+ }
+ }
+
+ private static BufferedImage rasterize(XSLFSlide slide, int widthPt, int heightPt) {
+ BufferedImage image = new BufferedImage(
+ (int) Math.round(widthPt * SCALE),
+ (int) Math.round(heightPt * SCALE),
+ BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ graphics.setColor(Color.WHITE);
+ graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
+ graphics.scale(SCALE, SCALE);
+ slide.draw(graphics);
+ } finally {
+ graphics.dispose();
+ }
+ return image;
+ }
+}