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; + } +}