From 1c293232822f4b23e6c28a89dde3676d660d9779 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 17 Jul 2026 14:04:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(pptx):=20complete=20the=20fragment=20matri?= =?UTF-8?q?x=20=E2=80=94=20images,=20barcodes,=20paths,=20transforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every payload the layout compiler emits now has a PPTX handler. Images render with the PDF fit modes — STRETCH fills the box, CONTAIN letter-boxes the centered bitmap, COVER crops through the picture's source rectangle (DrawingML's native crop, no clipping needed) — with source dimensions decoded from the real bitmap, because POI's header sniffing mis-reports some images and would break the aspect math the PDF backend computes from decoded pixels. Barcodes reuse the PDF handler's ZXing raster byte for byte. Polygons and free paths render as freeform geometry with the engine's y-up normalization flipped into slide space; gradient fills and gradient strokes stamp native DrawingML gradFill (linear angles convert from the engine's counter-clockwise convention to DrawingML's clockwise sixtieths; radial centres and explicit axes approximate with a one-time note), and a gradient stroke clears the noFill POI writes for a null line color — PowerPoint would otherwise honour noFill and drop the stroke entirely. Transform markers become containment: handlers draw on PptxRenderEnvironment.surface(page) — the innermost open group or the slide — and the begin marker opens a group whose interior anchor is the fragment box while the exterior anchor scales about the centre and carries the clockwise rotation, PPTX's equivalent of the PDF backend's graphics-state matrix. Clip markers degrade to a one-time warning (DrawingML has no graphics-state clipping; exact clips belong to the PDF backend or raster-slide mode). Anchor and bookmark markers record their destinations for the navigation pass. Tests (39 in render-pptx): fit-mode anchors and the COVER crop against the captured graph, barcode anchor, synthetic polygon and gradient-path freeforms asserting the CT gradFill (stop count, 90°→270° angle conversion, stroke gradFill present with no leftover noFill and the EMU width), and transform groups (rotation, interior/exterior anchors, centre pivot); a vector parity demo writes the PDF/PPTX pair and PNGs. Full 10-module gate green. --- CHANGELOG.md | 12 + .../architecture/backend-capability-matrix.md | 22 +- .../fixed/pptx/PptxFixedLayoutBackend.java | 32 ++- .../fixed/pptx/PptxRenderEnvironment.java | 78 +++++- .../PptxAnchorMarkerRenderHandler.java | 34 +++ .../PptxBarcodeFragmentRenderHandler.java | 135 ++++++++++ .../PptxBookmarkMarkerRenderHandler.java | 35 +++ .../pptx/handlers/PptxCapabilityNotes.java | 16 +- .../PptxEllipseFragmentRenderHandler.java | 2 +- .../fixed/pptx/handlers/PptxGradientFill.java | 152 +++++++++++ .../PptxImageFragmentRenderHandler.java | 93 +++++++ .../pptx/handlers/PptxInlineGeometry.java | 33 ++- .../PptxLineFragmentRenderHandler.java | 2 +- .../PptxParagraphFragmentRenderHandler.java | 47 ++-- .../PptxPathFragmentRenderHandler.java | 71 +++++ .../PptxPolygonFragmentRenderHandler.java | 48 ++++ .../PptxShapeClipBeginRenderHandler.java | 38 +++ .../PptxShapeClipEndRenderHandler.java | 34 +++ .../PptxShapeFragmentRenderHandler.java | 30 +-- .../fixed/pptx/handlers/PptxShapeStyle.java | 23 ++ .../PptxTableRowFragmentRenderHandler.java | 30 +-- .../fixed/pptx/handlers/PptxTextFrames.java | 10 +- .../PptxTransformBeginRenderHandler.java | 66 +++++ .../PptxTransformEndRenderHandler.java | 35 +++ .../pptx/PptxFixedLayoutBackendTest.java | 26 +- .../fixed/pptx/PptxVectorFragmentsTest.java | 248 ++++++++++++++++++ .../fixed/pptx/PptxVectorParityDemoTest.java | 119 +++++++++ 27 files changed, 1370 insertions(+), 101 deletions(-) create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxAnchorMarkerRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBarcodeFragmentRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxBookmarkMarkerRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxGradientFill.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxImageFragmentRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPathFragmentRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxPolygonFragmentRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipBeginRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxShapeClipEndRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformBeginRenderHandler.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxTransformEndRenderHandler.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorFragmentsTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxVectorParityDemoTest.java 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; + } +}