From fa1ba43370e66db75d190ad8efd13bb23b7cbbe8 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 18 Jul 2026 01:08:49 +0100 Subject: [PATCH 1/2] feat(render-pptx): chrome, navigation, multi-section, and deterministic output --- CHANGELOG.md | 33 ++ .../architecture/backend-capability-matrix.md | 30 +- .../fixed/pptx/PptxDeterminismWriter.java | 70 +++ .../fixed/pptx/PptxFixedLayoutBackend.java | 436 +++++++++++++++-- .../pptx/PptxFixedLayoutBackendProvider.java | 53 +- .../fixed/pptx/PptxNavigationWriter.java | 95 ++++ .../fixed/pptx/PptxRenderEnvironment.java | 43 +- .../pptx/handlers/PptxChromeRenderer.java | 457 ++++++++++++++++++ .../PptxParagraphFragmentRenderHandler.java | 72 ++- .../pptx/PptxChromeNavParityDemoTest.java | 151 ++++++ .../backend/fixed/pptx/PptxChromeTest.java | 198 ++++++++ .../fixed/pptx/PptxDeterminismTest.java | 78 +++ .../fixed/pptx/PptxGeometryAssertions.java | 2 +- .../fixed/pptx/PptxNavigationTest.java | 156 ++++++ ...ptxParagraphFragmentRenderHandlerTest.java | 2 +- .../backend/fixed/pptx/PptxSectionsTest.java | 189 ++++++++ 16 files changed, 1982 insertions(+), 83 deletions(-) create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismWriter.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationWriter.java create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeNavParityDemoTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationTest.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSectionsTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index cb75ca93..e433f9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,31 @@ follow semantic versioning; release dates are ISO 8601. `PptxFixedLayoutBackend.Builder.clipRasterFallback(false)` restores the previous unclipped vector rendering with its one-time warning. +- The fixed PPTX backend renders document chrome and emits navigation. + Metadata lands in the OPC core properties (title, author, subject, keywords, + plus the extended application property; OPC has no producer field). + Watermarks render per slide with the PDF placement math — text rotated about + its baseline start, images with native alpha, tiling supported — and + behind-content watermarks apply before fragment rendering, so document order + produces the PDF's layering without z-order surgery. Repeating + headers/footers resolve `{page}` / `{pages}` / `{date}` tokens with the + numbering window rules (start-at, count-from, show-on-first-page, numeral + styles). Recorded link rectangles become transparent hotspots after every + fragment is placed: external targets as URL hyperlinks, internal targets as + slide-jump hyperlinks (forward references included; a dangling anchor is + skipped with a warning), and the first bookmark on a page names its slide. + Protection, viewer preferences, and debug overlays have no PPTX counterpart + and are skipped with a one-time warning. +- `PptxFixedLayoutBackend.renderSections` / `writeSections` concatenate + multi-section documents into one deck: each section keeps its own chrome + with section-local page numbering, navigation resolves across section + boundaries, and deck metadata follows the first section that declares it. + A deck carries one slide size, so sections with differing page canvases are + rejected. +- `PptxFixedLayoutBackend.Builder.deterministic(boolean | Instant)` renders + byte-identical decks across runs by pinning the OPC created/modified + properties and normalizing every zip entry timestamp (zone-independent). + ### Fixed - `DocumentSession.toImages` / `toImage` rasterized documents that use binary @@ -109,6 +134,14 @@ follow semantic versioning; release dates are ISO 8601. from the resolved layout graph across a page break with a repeated header and a row-spanning cell, and pin the fills-before-ink paint order plus the page-start top-border branch at the handler level. +- PPTX chrome, navigation, section, and determinism tests pin the OPC core + properties, the watermark's layer order and pass-through rotation, header/ + footer token resolution with numbering windows and roman styles, resolved + URL and slide-jump hotspots (a dangling anchor degrades to a warning, not a + failure), section-local footer restarts with a cross-section slide jump, the + differing-page-size rejection, and render-twice byte equality with pinned + zip entry times; a chrome-and-navigation parity demo renders the PDF/PPTX + pair with per-page PNG previews. ## v2.0.0 — 2026-07-13 diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index bcccc377..87db4f54 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -56,19 +56,19 @@ Payload records live in `core` under | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ semantic images (`DocxSemanticBackend`) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (ZXing raster) | ✅ `PptxBarcodeFragmentRenderHandler` (identical ZXing raster) | ❌ | | Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ✅ semantic tables (`DocxSemanticBackend`) | -| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ raster fallback in `PptxFixedLayoutBackend` — the clip region renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning) | ⚠️ inline fallback + one-time capability warning | +| Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ raster fallback in `PptxFixedLayoutBackend` — the clip region renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | -| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ⚠️ `PptxAnchorMarkerRenderHandler` (destinations recorded; slide-jump emission pending) | ❌ | -| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` (records collected; slide-name mapping pending — PPTX has no document outline) | ❌ | +| Anchor markers (`AnchorMarkerPayload`) | ✅ `PdfAnchorMarkerRenderHandler` + `PdfInternalLinkWriter` | ✅ `PptxAnchorMarkerRenderHandler` + `PptxNavigationWriter` (slide-jump hyperlinks resolved after all fragments, so forward references work) | ❌ | +| Bookmark markers (`BookmarkMarkerPayload`) | ✅ `PdfBookmarkMarkerRenderHandler` + `PdfBookmarkOutlineWriter` | ⚠️ `PptxBookmarkMarkerRenderHandler` + `PptxNavigationWriter` (PPTX has no outline tree — the first bookmark on a page names its slide, further bookmarks on the same page are dropped with a debug note) | ❌ | | Alpha / opacity | ✅ `PdfAlphaSupport` (`PDExtendedGraphicsState`) | 🚧 (native fill alpha) | ❌ | ## Navigation and interactivity | Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ⚠️ `PptxParagraphFragmentRenderHandler` (transparent measured-span shape hotspots preserve resolved text styling; fragment rectangles arrive with navigation support) | ❌ | -| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | 🚧 (slide-jump hyperlinks) | ❌ | -| Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | 🚧 (no PPTX outline concept — slide names where 1:1, otherwise dropped with a note) | ❌ | +| External hyperlinks (fragment- and run-level) | ✅ `PdfLinkAnnotationWriter` + link rects in `PdfFixedLayoutBackend` | ✅ `PptxNavigationWriter` (transparent hotspots for measured span, line, and fragment rectangles, emitted above all content after the fragment pass) | ❌ | +| Internal links (anchor jump, forward references) | ✅ `PdfInternalLinkWriter` (two-pass) | ✅ `PptxNavigationWriter` (deferred slide-jump hyperlinks, resolved after all fragments — including across sections) | ❌ | +| Document outline / bookmarks tree | ✅ `PdfBookmarkOutlineWriter` | ⚠️ `PptxNavigationWriter` (no PPTX outline concept — slide names where 1:1, extra bookmarks dropped with a note) | ❌ | ## Document chrome and output options @@ -77,22 +77,22 @@ honour an option ignores it (documented contract). | Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| -| Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | 🚧 (OPC core properties) | ✅ (`DocxSemanticBackend` via `SemanticExportContext`) | -| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | 🚧 (per-slide shape, send-to-back/front) | ❌ | -| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` | 🚧 (positioned per-slide text boxes) | ❌ | -| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored — no OOXML encryption support planned) | ❌ | -| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored — PDF-viewer concept) | n/a | -| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | 🚧 | n/a | +| Metadata (title, author, …) | ✅ `PdfDocumentPostProcessor` | ⚠️ `applyMetadata` in `PptxFixedLayoutBackend` (OPC core properties + extended `Application`; OPC has no producer field, so that value is not representable) | ✅ (`DocxSemanticBackend` via `SemanticExportContext`) | +| Watermark (front/back layers) | ✅ `PdfWatermarkRenderer` | ✅ `PptxChromeRenderer` (per-slide shape at the PDF placement math; behind-content applies before fragments, so no z-order surgery) | ❌ | +| Repeating headers / footers | ✅ `PdfHeaderFooterRenderer` | ✅ `PptxChromeRenderer` (positioned per-slide text boxes; `{page}` / `{pages}` / `{date}` tokens with the numbering window rules) | ❌ | +| Protection / encryption | ✅ `PdfDocumentPostProcessor` | ❌ (ignored with a one-time warning — no OOXML encryption support planned) | ❌ | +| Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a | +| Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | ## Output surface and lifecycle | Capability | PDF (fixed) | PPTX (fixed) | DOCX (semantic) | |---|---|---|---| | Render to bytes / stream / file (`FixedLayoutRenderer`) | ✅ `PdfFixedLayoutBackend` | ✅ `PptxFixedLayoutBackend` | ✅ `DocxSemanticBackend` (`SemanticBackend`) | -| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | 🚧 (decision pending: POI `XSLFSlide.draw` quality) | ❌ | +| Render to images (`renderToImages`) | ✅ PDFBox `PDFRenderer` | ❌ (throws with a pointer to the PDF backend — POI's slide rasterizer cannot honour embedded fonts, and the PDF raster of the same graph is the canonical image output) | ❌ | | Raster-slide mode — every page as one full-slide picture, pixel-exact to the PDF/PNG output (`Builder.rasterSlides(dpi)`) | n/a (the PDF raster is the source) | ✅ `PptxFixedLayoutBackend` | ❌ | -| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | 🚧 | ❌ | -| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | 🚧 (pinned OPC timestamps + zip normalization) | ❌ | +| Multi-section documents (`renderSections`, per-section chrome, cross-section links) | ✅ `buildSectionsDocument` in `PdfFixedLayoutBackend` | ⚠️ `renderSections` in `PptxFixedLayoutBackend` (a deck carries one slide size, so every section must share the same page canvas — differing sizes throw) | ❌ | +| Deterministic output (render twice → identical bytes) | ✅ `PdfDeterminismWriter` | ✅ `PptxDeterminismWriter` (pinned OPC created/modified + zip entry-time normalization) | ❌ | | ServiceLoader discovery (`FixedLayoutBackendProvider`) | ✅ `PdfFixedLayoutBackendProvider` (`format() == "pdf"`) | ✅ `PptxFixedLayoutBackendProvider` (`format() == "pptx"`) | n/a (semantic SPI: `SemanticBackend`) | | `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | 🚧 (`buildPptx` / `writePptx` / `toPptxBytes` planned) | via `session.export(new DocxSemanticBackend(...))` | diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismWriter.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismWriter.java new file mode 100644 index 00000000..ff5dc824 --- /dev/null +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismWriter.java @@ -0,0 +1,70 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import org.apache.poi.ooxml.POIXMLProperties; +import org.apache.poi.xslf.usermodel.XMLSlideShow; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * Makes a rendered .pptx byte-identical across runs. POI seeds two clocks into + * every written deck: the OPC core properties ({@code dcterms:created} / + * {@code dcterms:modified}) and the modification time of every zip entry. + * {@link #pinCoreProperties} replaces the former with a fixed instant before + * the show is written; {@link #normalizeZipEntries} rewrites the finished + * archive with every entry's timestamp pinned. Applied by + * {@link PptxFixedLayoutBackend} only when deterministic output is enabled. + * + * @author Artem Demchyshyn + */ +final class PptxDeterminismWriter { + + private PptxDeterminismWriter() { + // Utility class, no instantiation. + } + + /** + * Pins the presentation's OPC created / modified core properties to + * {@code timestamp}, replacing the wall-clock values POI stamps at + * creation time. + */ + static void pinCoreProperties(XMLSlideShow show, Instant timestamp) { + POIXMLProperties.CoreProperties core = show.getProperties().getCoreProperties(); + Optional pinned = Optional.of(Date.from(timestamp)); + core.setCreated(pinned); + core.setModified(pinned); + } + + /** + * Rewrites the archive with every entry's modification time pinned to + * {@code timestamp}. The zip DOS time fields are written from a + * zone-independent {@link LocalDateTime} (derived at UTC), so the output is + * byte-identical across machines regardless of their default time zone; + * entry order and content are preserved, extra fields (which can carry + * high-resolution timestamps) are dropped. + */ + static byte[] normalizeZipEntries(byte[] pptx, Instant timestamp) throws IOException { + LocalDateTime pinned = LocalDateTime.ofInstant(timestamp, ZoneOffset.UTC); + ByteArrayOutputStream output = new ByteArrayOutputStream(pptx.length); + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(pptx)); + ZipOutputStream zip = new ZipOutputStream(output)) { + for (ZipEntry entry; (entry = input.getNextEntry()) != null; ) { + ZipEntry copy = new ZipEntry(entry.getName()); + copy.setTimeLocal(pinned); + zip.putNextEntry(copy); + input.transferTo(zip); + zip.closeEntry(); + } + } + return output.toByteArray(); + } +} 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 471d7a55..5f812a65 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 @@ -17,18 +17,29 @@ 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; +import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer; import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxTableRowFragmentRenderHandler; +import com.demcha.compose.document.api.Beta; import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; +import com.demcha.compose.document.backend.fixed.pdf.PdfOutputOptionsTranslator; import com.demcha.compose.document.exceptions.UnsupportedNodeCapabilityException; import com.demcha.compose.document.layout.LayoutCanvas; import com.demcha.compose.document.layout.LayoutGraph; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; import com.demcha.compose.document.layout.payloads.PdfSemanticFragmentPayload; import com.demcha.compose.document.layout.payloads.ShapeClipBeginPayload; import com.demcha.compose.document.layout.payloads.ShapeClipEndPayload; import com.demcha.compose.document.layout.payloads.TableRowFragmentPayload; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.font.FontFamilyDefinition; +import com.demcha.compose.font.FontName; +import org.apache.poi.ooxml.POIXMLProperties; import org.apache.poi.sl.usermodel.PictureData; import org.apache.poi.xslf.usermodel.XMLSlideShow; import org.apache.poi.xslf.usermodel.XSLFPictureData; @@ -44,6 +55,8 @@ import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -63,14 +76,18 @@ * throws {@link UnsupportedNodeCapabilityException}). Custom handlers replace * built-in defaults per payload type via {@link Builder#addHandler}.

* - *

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 + *

The backend 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 (hyperlinks, + * slide-jump internal links, bookmark slide names) — plus document chrome + * (metadata, watermark, repeating headers/footers), multi-section + * concatenation, and deterministic byte-identical output; clip regions render + * through a pixel-exact raster fallback — see * {@code docs/architecture/backend-capability-matrix.md} for the live - * per-capability status and documented deviations. Multi-section rendering and render-to-images are not - * implemented yet and throw {@link UnsupportedOperationException}.

+ * per-capability status and documented deviations. Render-to-images is not + * implemented and throws {@link UnsupportedOperationException}; rasterize + * through the PDF backend, which shares the same resolved geometry.

* *

Thread-safety: immutable and reusable across renders; each render * pass owns its own slide show and environment.

@@ -81,8 +98,20 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer { private static final Logger RENDER_LOG = LoggerFactory.getLogger("com.demcha.compose.engine.render"); + private static final Instant DEFAULT_DETERMINISTIC_INSTANT = Instant.parse("2000-01-01T00:00:00Z"); + private final Map, PptxFragmentRenderHandler> handlers; + private final DocumentMetadata metadataOptions; + private final DocumentWatermark watermarkOptions; + private final List headerFooterOptions; + + /** + * Timestamp all output clocks pin to when deterministic output is enabled; + * {@code null} keeps POI's wall-clock stamps. + */ + private final Instant deterministicTimestamp; + /** * Raster-slide resolution in DPI; {@code 0} keeps the default editable * vector mode. @@ -113,6 +142,10 @@ private PptxFixedLayoutBackend(Builder builder) { this.handlers = Map.copyOf(merged); this.rasterSlidesDpi = builder.rasterSlidesDpi; this.clipRasterFallback = builder.clipRasterFallback; + this.metadataOptions = builder.metadataOptions; + this.watermarkOptions = builder.watermarkOptions; + this.headerFooterOptions = List.copyOf(builder.headerFooterOptions); + this.deterministicTimestamp = builder.deterministicTimestamp; } /** @@ -212,30 +245,184 @@ public List renderToImages(LayoutGraph graph, } /** - * Multi-section concatenation is not implemented for the PPTX backend yet. + * Concatenates several {@link SectionUnit sections} into one .pptx and + * returns its bytes. Each section renders through its own + * {@link SectionUnit#chrome() chrome} backend — handlers, watermark, + * header/footer with section-local page numbering, and raster-slide mode + * are all per-section settings; only deck-global concerns come from the + * backend this method is invoked on (deterministic output) or from the + * first section that declares them (metadata), matching the PDF backend's + * combined-document rules. Anchors, links, and slide names resolve across + * section boundaries against the combined deck; a raster-slide section + * still resolves incoming links and bookmarks, but its own outgoing + * navigation is baked into the pixels. A PPTX deck carries one slide + * size, so every section must share the same page canvas. + * + * @param sections ordered, non-empty list of sections + * @return rendered combined-deck bytes + * @throws Exception if rendering or saving fails + * @throws UnsupportedOperationException if sections declare differing page sizes + * @since 2.1.0 */ + @Beta @Override - public byte[] renderSections(List sections) { - throw new UnsupportedOperationException( - "The PPTX backend does not render multi-section documents yet."); + public byte[] renderSections(List sections) throws Exception { + try (ByteArrayOutputStream output = new ByteArrayOutputStream()) { + writeSections(sections, output); + return output.toByteArray(); + } } /** - * Multi-section concatenation is not implemented for the PPTX backend yet. + * Concatenates several {@link SectionUnit sections} into one .pptx written + * to the caller-owned stream (never closed by the backend). See + * {@link #renderSections(List)} for the shared-slide-size constraint. + * + * @param sections ordered, non-empty list of sections + * @param output caller-owned output stream + * @throws Exception if rendering or saving fails + * @throws UnsupportedOperationException if sections declare differing page sizes + * @since 2.1.0 */ + @Beta @Override - public void writeSections(List sections, OutputStream output) { - throw new UnsupportedOperationException( - "The PPTX backend does not render multi-section documents yet."); + public void writeSections(List sections, OutputStream output) throws Exception { + Objects.requireNonNull(sections, "sections"); + Objects.requireNonNull(output, "output"); + if (sections.isEmpty()) { + throw new IllegalArgumentException("A multi-section document needs at least one section."); + } + LayoutCanvas firstCanvas = sections.get(0).canvas(); + for (SectionUnit section : sections) { + if (section.canvas().width() != firstCanvas.width() + || section.canvas().height() != firstCanvas.height()) { + throw new UnsupportedOperationException( + "A PPTX deck carries one slide size, so every section must share the same " + + "page canvas; got " + firstCanvas.width() + "x" + firstCanvas.height() + + " and " + section.canvas().width() + "x" + section.canvas().height() + + " pt. Render differing page sizes through the PDF backend."); + } + } + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + renderSectionsToBuffer(sections, firstCanvas, buffer); + writeFinishedDeck(buffer.toByteArray(), output); + } + } + + private void renderSectionsToBuffer(List sections, + LayoutCanvas canvas, + ByteArrayOutputStream buffer) throws Exception { + int totalPages = sections.stream() + .mapToInt(section -> Math.max(section.graph().totalPages(), 1)) + .sum(); + List unionFonts = unionCustomFonts(sections); + try (XMLSlideShow show = new XMLSlideShow(); + PdfMeasurementResources measurement = PdfMeasurementResources.open(unionFonts)) { + PptxRenderSession session = new PptxRenderSession( + show, canvas.width(), canvas.height(), totalPages); + PptxRenderEnvironment environment = new PptxRenderEnvironment( + show, session, 0, canvas.height(), measurement.fontLibrary(), unionFonts); + int pageOffset = 0; + for (SectionUnit section : sections) { + // Each section renders with its OWN backend's handlers and + // chrome, but records navigation against the combined deck + // through the environment's section window. + PptxFixedLayoutBackend chrome = (PptxFixedLayoutBackend) section.chrome(); + LayoutGraph graph = section.graph(); + int pages = Math.max(graph.totalPages(), 1); + environment.beginSection(pageOffset); + FixedLayoutRenderContext sectionContext = new FixedLayoutRenderContext( + section.canvas(), section.customFonts(), null, null); + if (chrome.rasterSlidesDpi > 0) { + placeRasterPages(environment, + chrome.pdfBackendWithVisibleChrome().renderToImages( + graph, sectionContext, chrome.rasterSlidesDpi, false, -1), + section.canvas()); + // The pixels bake this section's outgoing navigation, but + // its anchors and bookmarks still exist as destinations + // for the other sections' links and for slide names. + recordRasterSectionDestinations(graph, environment); + } else { + PptxChromeRenderer.applyWatermarkBehindContent( + environment, chrome.watermarkOptions, section.canvas(), pages); + chrome.renderGraph(graph, environment, sectionContext); + PptxChromeRenderer.applyWatermarkAboveContent( + environment, chrome.watermarkOptions, section.canvas(), pages); + PptxChromeRenderer.applyHeadersAndFooters( + environment, chrome.headerFooterOptions, section.canvas(), pages); + } + pageOffset += pages; + } + environment.beginSection(0); + PptxNavigationWriter.apply(environment); + // Metadata is deck-global: the first section that declares it wins, + // matching the PDF backend's combined-document rule. + sections.stream() + .map(section -> ((PptxFixedLayoutBackend) section.chrome()).metadataOptions) + .filter(Objects::nonNull) + .findFirst() + .ifPresent(metadata -> applyMetadata(show, metadata)); + if (deterministicTimestamp != null) { + PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); + } + show.write(buffer); + } + } + + /** + * Registers a raster section's anchor destinations and bookmarks so + * incoming cross-section links and slide names still resolve even though + * the section's content is one picture. + */ + private static void recordRasterSectionDestinations(LayoutGraph graph, + PptxRenderEnvironment environment) { + for (PlacedFragment fragment : graph.fragments()) { + if (fragment.payload() instanceof AnchorMarkerPayload anchor) { + environment.registerAnchor(fragment, anchor.anchor()); + } else if (fragment.payload() instanceof PdfSemanticFragmentPayload semanticPayload + && semanticPayload.bookmarkOptions() != null) { + environment.registerBookmark(fragment, semanticPayload.bookmarkOptions()); + } + } + } + + private static List unionCustomFonts(List sections) { + Map byName = new LinkedHashMap<>(); + for (SectionUnit section : sections) { + for (FontFamilyDefinition family : section.customFonts()) { + byName.putIfAbsent(family.name(), family); + } + } + return List.copyOf(byName.values()); } private void renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { - if (rasterSlidesDpi > 0) { - renderRasterSlides(graph, context, output); - return; + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + if (rasterSlidesDpi > 0) { + renderRasterSlides(graph, context, buffer); + } else { + renderVectorSlides(graph, context, buffer); + } + writeFinishedDeck(buffer.toByteArray(), output); + } + } + + /** + * Applies the deterministic zip normalization (when enabled) and streams + * the finished deck bytes. + */ + private void writeFinishedDeck(byte[] deck, OutputStream output) throws IOException { + if (deterministicTimestamp != null) { + deck = PptxDeterminismWriter.normalizeZipEntries(deck, deterministicTimestamp); } + output.write(deck); + } + + private void renderVectorSlides(LayoutGraph graph, + FixedLayoutRenderContext context, + OutputStream output) throws Exception { try (XMLSlideShow show = new XMLSlideShow(); PdfMeasurementResources measurement = PdfMeasurementResources.open(context.customFontFamilies())) { @@ -244,7 +431,21 @@ private void renderToOutput(LayoutGraph graph, PptxRenderEnvironment environment = new PptxRenderEnvironment(show, session, 0, graph.canvas().height(), measurement.fontLibrary(), context.customFontFamilies()); + int pageCount = Math.max(graph.totalPages(), 1); + PptxChromeRenderer.applyWatermarkBehindContent( + environment, watermarkOptions, graph.canvas(), pageCount); renderGraph(graph, environment, context); + PptxChromeRenderer.applyWatermarkAboveContent( + environment, watermarkOptions, graph.canvas(), pageCount); + PptxChromeRenderer.applyHeadersAndFooters( + environment, headerFooterOptions, graph.canvas(), pageCount); + PptxNavigationWriter.apply(environment); + if (metadataOptions != null) { + applyMetadata(show, metadataOptions); + } + if (deterministicTimestamp != null) { + PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); + } show.write(output); } } @@ -252,27 +453,92 @@ private void renderToOutput(LayoutGraph graph, /** * Raster-slide mode: every page is rendered through the PDF backend at the * configured DPI and placed as one full-slide picture — a pixel-exact copy - * of the PDF/PNG output in .pptx form. Slides are not editable as text. + * of the PDF/PNG output in .pptx form. Slides are not editable as text, + * and visible chrome (watermark, headers/footers) is baked into the pixels + * by the PDF backend; metadata still lands in the deck's core properties. */ private void renderRasterSlides(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { - List pages = new PdfFixedLayoutBackend() + List pages = pdfBackendWithVisibleChrome() .renderToImages(graph, context, rasterSlidesDpi, false, -1); try (XMLSlideShow show = new XMLSlideShow()) { PptxRenderSession session = new PptxRenderSession( show, graph.canvas().width(), graph.canvas().height(), graph.totalPages()); - for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { - XSLFPictureData data = show.addPicture( - encodePng(pages.get(pageIndex)), PictureData.PictureType.PNG); - XSLFPictureShape picture = session.slide(pageIndex).createPicture(data); - picture.setAnchor(new Rectangle2D.Double( - 0, 0, graph.canvas().width(), graph.canvas().height())); + PptxRenderEnvironment environment = new PptxRenderEnvironment( + show, session, 0, graph.canvas().height()); + placeRasterPages(environment, pages, graph.canvas()); + if (metadataOptions != null) { + applyMetadata(show, metadataOptions); + } + if (deterministicTimestamp != null) { + PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); } show.write(output); } } + /** + * Places one full-slide picture per rendered page onto the environment's + * active section window. + */ + private static void placeRasterPages(PptxRenderEnvironment environment, + List pages, + LayoutCanvas canvas) throws IOException { + for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { + XSLFPictureData data = environment.slideShow().addPicture( + encodePng(pages.get(pageIndex)), PictureData.PictureType.PNG); + XSLFPictureShape picture = environment.slide(pageIndex).createPicture(data); + picture.setAnchor(new Rectangle2D.Double(0, 0, canvas.width(), canvas.height())); + } + } + + /** + * Builds the PDF sub-backend raster renders go through, carrying this + * backend's visible chrome so watermarks and headers/footers land in the + * rasterized pixels. + */ + private PdfFixedLayoutBackend pdfBackendWithVisibleChrome() { + if (watermarkOptions == null && headerFooterOptions.isEmpty()) { + return new PdfFixedLayoutBackend(); + } + PdfFixedLayoutBackend.Builder builder = PdfFixedLayoutBackend.builder() + .watermark(PdfOutputOptionsTranslator.toPdf(watermarkOptions)); + for (DocumentHeaderFooter entry : headerFooterOptions) { + if (entry.getZone() == DocumentHeaderFooterZone.FOOTER) { + builder.footer(PdfOutputOptionsTranslator.toPdf(entry)); + } else { + builder.header(PdfOutputOptionsTranslator.toPdf(entry)); + } + } + return builder.build(); + } + + /** + * Writes the canonical metadata into the deck's OPC properties: title, + * author, subject, and keywords map onto the Dublin Core fields, the + * creating application onto the extended {@code Application} property. + * OPC has no producer field, so that value is not representable in .pptx. + */ + private static void applyMetadata(XMLSlideShow show, DocumentMetadata metadata) { + POIXMLProperties.CoreProperties core = show.getProperties().getCoreProperties(); + if (metadata.getTitle() != null) { + core.setTitle(metadata.getTitle()); + } + if (metadata.getAuthor() != null) { + core.setCreator(metadata.getAuthor()); + } + if (metadata.getSubject() != null) { + core.setSubjectProperty(metadata.getSubject()); + } + if (metadata.getKeywords() != null) { + core.setKeywords(metadata.getKeywords()); + } + if (metadata.getCreator() != null) { + show.getProperties().getExtendedProperties().setApplication(metadata.getCreator()); + } + } + private static byte[] encodePng(BufferedImage image) throws IOException { try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { ImageIO.write(image, "png", buffer); @@ -390,7 +656,9 @@ private void recordRegionNavigation(List fragments, if (fragment.payload() instanceof AnchorMarkerPayload anchor) { environment.registerAnchor(fragment, anchor.anchor()); } else { - finishRenderedFragment(fragment, fragment.payload(), environment); + // Inside a rasterized composite no per-line hotspots were + // emitted, so paragraph links keep their fragment rectangle. + finishRenderedFragment(fragment, fragment.payload(), environment, true); } } } @@ -422,7 +690,7 @@ private int renderTableRowGroup(List fragments, TableRowFragmentPayload payload = (TableRowFragmentPayload) fragment.payload(); handler.renderBordersAndText(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment); + finishRenderedFragment(fragment, payload, environment, false); } return endExclusive - 1; } @@ -431,19 +699,23 @@ private void renderFragment(PlacedFragment fragment, PptxRenderEnvironment envir Object payload = fragment.payload(); PptxFragmentRenderHandler handler = handlerFor(payload); handler.render(fragment, payload, environment); - finishRenderedFragment(fragment, payload, environment); + finishRenderedFragment(fragment, payload, environment, false); } /** * Generic post-render bookkeeping shared by every semantic payload: * fragment-rectangle links and bookmark records are collected here so no - * individual handler has to remember navigation concerns. + * individual handler has to remember navigation concerns. Paragraph link + * rectangles are skipped on the normal path — the paragraph handler emits + * tighter per-line hotspots itself — unless the caller says otherwise. */ private void finishRenderedFragment(PlacedFragment fragment, Object payload, - PptxRenderEnvironment environment) { + PptxRenderEnvironment environment, + boolean includeParagraphLinks) { if (payload instanceof PdfSemanticFragmentPayload semanticPayload) { - if (semanticPayload.linkTarget() != null) { + if (semanticPayload.linkTarget() != null + && (includeParagraphLinks || !(payload instanceof ParagraphFragmentPayload))) { environment.recordFragmentLink(fragment, semanticPayload.linkTarget()); } if (semanticPayload.bookmarkOptions() != null) { @@ -486,10 +758,110 @@ public static final class Builder { private final List> customHandlers = new ArrayList<>(); private int rasterSlidesDpi; private boolean clipRasterFallback = true; + private DocumentMetadata metadataOptions; + private DocumentWatermark watermarkOptions; + private final List headerFooterOptions = new ArrayList<>(); + private Instant deterministicTimestamp; private Builder() { } + /** + * Configures deck metadata, written into the OPC core properties + * (title, author, subject, keywords) and the extended application + * property. + * + * @param metadata canonical metadata, or {@code null} for none + * @return this builder + */ + public Builder metadata(DocumentMetadata metadata) { + this.metadataOptions = metadata; + return this; + } + + /** + * Configures a document-wide watermark, rendered on every slide with + * the PDF backend's placement math — behind or above the content per + * its layer. + * + * @param watermark canonical watermark, or {@code null} for none + * @return this builder + */ + public Builder watermark(DocumentWatermark watermark) { + this.watermarkOptions = watermark; + return this; + } + + /** + * Adds a repeating header, rendered on every slide with resolved + * {@code {page}} / {@code {pages}} / {@code {date}} tokens. + * + * @param options header chrome + * @return this builder + */ + public Builder header(DocumentHeaderFooter options) { + headerFooterOptions.add(Objects.requireNonNull(options, "options") + .withZone(DocumentHeaderFooterZone.HEADER)); + return this; + } + + /** + * Adds a repeating footer, rendered on every slide with resolved + * {@code {page}} / {@code {pages}} / {@code {date}} tokens. + * + * @param options footer chrome + * @return this builder + */ + public Builder footer(DocumentHeaderFooter options) { + headerFooterOptions.add(Objects.requireNonNull(options, "options") + .withZone(DocumentHeaderFooterZone.FOOTER)); + return this; + } + + /** + * Enables (or disables) deterministic output. When enabled, the deck's + * OPC created / modified core properties are pinned to a fixed default + * timestamp and every zip entry's modification time is normalized, so + * the same document renders to byte-identical output across runs — for + * reproducible builds and byte-level output tests. Disabled by + * default. + * + *

A {@code {date}} token in header/footer chrome resolves against + * the wall-clock date, so decks using it stay byte-identical only + * within one day — the same limitation the PDF backend has.

+ * + * @param enabled {@code true} to pin output at the default timestamp, + * {@code false} to keep POI's live timestamps + * @return this builder + * @since 2.1.0 + */ + @Beta + public Builder deterministic(boolean enabled) { + this.deterministicTimestamp = enabled ? DEFAULT_DETERMINISTIC_INSTANT : null; + return this; + } + + /** + * Enables deterministic output with an explicit timestamp. See + * {@link #deterministic(boolean)}. + * + *

The instant is truncated to whole seconds up front: zip DOS + * times carry two-second resolution and OPC dates whole seconds, so + * truncating keeps every serialized clock in agreement for sub-second + * inputs.

+ * + * @param timestamp the instant to pin the deck's clocks to + * @return this builder + * @throws NullPointerException if {@code timestamp} is null + * @since 2.1.0 + */ + @Beta + public Builder deterministic(Instant timestamp) { + this.deterministicTimestamp = + Objects.requireNonNull(timestamp, "timestamp").truncatedTo(ChronoUnit.SECONDS); + return this; + } + /** * Switches the backend to raster-slide mode: every page renders * through the PDF backend at the given DPI and lands as one diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendProvider.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendProvider.java index 7a6d4bec..981926ae 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendProvider.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxFixedLayoutBackendProvider.java @@ -3,23 +3,33 @@ import com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider; import com.demcha.compose.document.backend.fixed.FixedLayoutRenderer; import com.demcha.compose.document.output.DocumentDebugOptions; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; import com.demcha.compose.document.output.DocumentOutputOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; /** * POI-backed {@link FixedLayoutBackendProvider} for the {@code "pptx"} format, * registered through {@code META-INF/services} so the document API can resolve * the PPTX backend by format without importing it. * - *

Document chrome (metadata, watermark, headers/footers) is not translated - * yet: options a backend cannot honour are ignored per the - * {@link DocumentOutputOptions} contract, and the chrome translation arrives - * together with the corresponding PPTX capabilities — see - * {@code docs/architecture/backend-capability-matrix.md}.

+ *

{@link #create} translates the session's backend-neutral chrome onto the + * {@link PptxFixedLayoutBackend} builder: metadata, watermark, and repeating + * headers/footers apply natively. Protection and PDF viewer preferences have + * no .pptx counterpart and are ignored with a one-time warning, per the + * {@link DocumentOutputOptions} contract; debug overlays are PDF-only.

* * @since 2.1.0 */ public final class PptxFixedLayoutBackendProvider implements FixedLayoutBackendProvider { + private static final Logger LOG = LoggerFactory.getLogger("com.demcha.compose.engine.render"); + private static final Set WARNED = ConcurrentHashMap.newKeySet(); + /** * Creates the provider. Invoked reflectively by {@link java.util.ServiceLoader}. */ @@ -33,6 +43,37 @@ public String format() { @Override public FixedLayoutRenderer create(DocumentOutputOptions chrome, DocumentDebugOptions debug) { - return new PptxFixedLayoutBackend(); + if (debug.enabled()) { + warnOnce("debug", "debug overlays (guides, node labels) are not implemented for " + + "the PPTX backend and are skipped; render through the PDF backend to see them"); + } + if (chrome.protection() != null) { + warnOnce("protection", "document protection has no .pptx counterpart and is " + + "skipped — OOXML presentations carry no PDF-style permission model"); + } + if (chrome.viewerPreferences() != null) { + warnOnce("viewer-preferences", "PDF viewer preferences have no .pptx counterpart " + + "and are skipped"); + } + if (!chrome.hasAny()) { + return new PptxFixedLayoutBackend(); + } + PptxFixedLayoutBackend.Builder builder = PptxFixedLayoutBackend.builder() + .metadata(chrome.metadata()) + .watermark(chrome.watermark()); + for (DocumentHeaderFooter entry : chrome.headersAndFooters()) { + if (entry.getZone() == DocumentHeaderFooterZone.FOOTER) { + builder.footer(entry); + } else { + builder.header(entry); + } + } + return builder.build(); + } + + private static void warnOnce(String key, String message) { + if (WARNED.add(key)) { + LOG.warn("render.pptx.capability {}", message); + } } } diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationWriter.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationWriter.java new file mode 100644 index 00000000..832e0085 --- /dev/null +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationWriter.java @@ -0,0 +1,95 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.document.node.ExternalLinkTarget; +import com.demcha.compose.document.node.InternalLinkTarget; +import org.apache.poi.sl.usermodel.ShapeType; +import org.apache.poi.xslf.usermodel.XSLFAutoShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.openxmlformats.schemas.presentationml.x2006.main.CTShape; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.awt.geom.Rectangle2D; +import java.util.HashSet; +import java.util.Set; + +/** + * Emits the navigation the render pass recorded, once every fragment (and so + * every anchor) is placed: fragment- and span-level link rectangles become + * transparent click hotspots — external targets as URL hyperlinks, internal + * targets resolved through the anchor map into slide-jump hyperlinks — and + * bookmark records name their slides, the closest PPTX equivalent of a PDF + * outline (slide names drive PowerPoint's navigation pane and grid view). + * + *

Runs after all sections of a render, so cross-section internal links + * resolve against the combined deck exactly like the PDF backend's pass-B + * link resolution.

+ * + * @author Artem Demchyshyn + */ +final class PptxNavigationWriter { + + private static final Logger LOG = LoggerFactory.getLogger("com.demcha.compose.engine.render"); + + private PptxNavigationWriter() { + // Utility class, no instantiation. + } + + /** + * Emits every recorded link hotspot and bookmark slide name onto the + * environment's slides. Hotspots land on the slide itself (page space), + * so a link recorded inside a transform group stays clickable at the + * fragment's untransformed page rectangle — the same behaviour as PDF + * link annotations, which are page-space objects. + */ + static void apply(PptxRenderEnvironment environment) { + for (PptxRenderEnvironment.FragmentLink link : environment.fragmentLinks()) { + if (link.target() instanceof ExternalLinkTarget external) { + hotspot(environment.globalSlide(link.pageIndex()), link.rectangle()) + .createHyperlink().linkToUrl(external.options().uri()); + } else if (link.target() instanceof InternalLinkTarget internal) { + PptxRenderEnvironment.AnchorDestination destination = + environment.anchorDestinations().get(internal.anchor()); + if (destination == null) { + LOG.warn("render.pptx.link.unresolved anchor={} — no anchor with that " + + "name was placed, the hotspot is skipped", internal.anchor()); + continue; + } + hotspot(environment.globalSlide(link.pageIndex()), link.rectangle()) + .createHyperlink() + .linkToSlide(environment.globalSlide(destination.pageIndex())); + } + } + + // A slide carries one name, a page can carry several bookmarks: the + // first (outermost) bookmark wins, matching reading order. + Set named = new HashSet<>(); + for (PptxRenderEnvironment.BookmarkRecord bookmark : environment.bookmarkRecords()) { + if (named.add(bookmark.pageIndex())) { + environment.globalSlide(bookmark.pageIndex()) + .getXmlObject().getCSld().setName(bookmark.title()); + } else { + LOG.debug("render.pptx.bookmark.collapsed title={} pageIndex={} — the slide " + + "is already named by an earlier bookmark", bookmark.title(), + bookmark.pageIndex()); + } + } + } + + /** + * Creates one transparent, borderless hotspot rectangle. A fully + * transparent solid fill keeps the whole rectangle clickable in + * slide-show mode — noFill shapes only hit-test their outline. + */ + private static XSLFAutoShape hotspot(XSLFSlide slide, Rectangle2D.Double rectangle) { + XSLFAutoShape hotspot = slide.createAutoShape(); + hotspot.setShapeType(ShapeType.RECT); + hotspot.setAnchor(rectangle); + hotspot.setFillColor(new Color(0, 0, 0, 0)); + hotspot.setLineColor(null); + ((CTShape) hotspot.getXmlObject()).getNvSpPr().getCNvPr() + .setName("GraphCompose Link Hotspot"); + return hotspot; + } +} 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 945b26f8..73726945 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 @@ -58,7 +58,7 @@ public final class PptxRenderEnvironment { private final XMLSlideShow show; private final PptxRenderSession session; - private final int pageIndexOffset; + private int pageIndexOffset; private final double canvasHeight; private final FontLibrary fonts; private final Map customFontFamilies; @@ -129,13 +129,32 @@ public XMLSlideShow slideShow() { } /** - * Returns the drawing surface for one resolved page. + * Moves the environment to the next section window of a multi-section + * render: section-local page indexes now resolve to slides (and record + * navigation) at the given offset into the combined deck. + */ + void beginSection(int pageIndexOffset) { + this.pageIndexOffset = pageIndexOffset; + } + + /** + * Returns the drawing surface for one resolved page of the active section + * window. * - * @param pageIndex zero-based page index + * @param pageIndex zero-based section-local page index * @return the slide backing that page */ public XSLFSlide slide(int pageIndex) { - return session.slide(pageIndex); + return session.slide(pageIndex + pageIndexOffset); + } + + /** + * Returns a slide by its absolute index in the combined deck — the space + * the navigation records live in. Used by the post-pass emission, which + * runs after every section window has been rendered. + */ + XSLFSlide globalSlide(int globalPageIndex) { + return session.slide(globalPageIndex); } /** @@ -149,7 +168,7 @@ public XSLFSlide slide(int pageIndex) { */ public XSLFShapeContainer surface(int pageIndex) { XSLFGroupShape group = groupStack.peek(); - return group != null ? group : session.slide(pageIndex); + return group != null ? group : slide(pageIndex); } /** @@ -387,6 +406,20 @@ void recordFragmentLink(PlacedFragment fragment, DocumentLinkTarget target) { target)); } + /** + * Records a clickable rectangle for deferred emission once every anchor is + * placed — the path span- and line-level internal links take, since their + * targets may still be unrendered forward references. + * + * @param pageIndex zero-based section-local page index + * @param rectangle clickable rectangle in slide top-down points + * @param target resolved link target + */ + @Internal + public void deferLink(int pageIndex, Rectangle2D.Double rectangle, DocumentLinkTarget target) { + fragmentLinks.add(new FragmentLink(pageIndex + pageIndexOffset, rectangle, target)); + } + List bookmarkRecords() { return List.copyOf(bookmarkRecords); } diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java new file mode 100644 index 00000000..7881ce70 --- /dev/null +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java @@ -0,0 +1,457 @@ +package com.demcha.compose.document.backend.fixed.pptx.handlers; + +import com.demcha.compose.document.api.Internal; +import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment; +import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.output.DocumentPageNumbering; +import com.demcha.compose.document.output.DocumentPageNumberStyle; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.document.output.DocumentWatermarkLayer; +import com.demcha.compose.document.output.DocumentWatermarkPosition; +import com.demcha.compose.engine.components.content.header_footer.HeaderFooterConfig; +import com.demcha.compose.engine.components.content.header_footer.HeaderFooterZone; +import com.demcha.compose.engine.components.content.header_footer.PageNumberStyle; +import com.demcha.compose.engine.components.content.text.TextDecoration; +import com.demcha.compose.engine.components.content.text.TextStyle; +import com.demcha.compose.engine.components.style.Margin; +import com.demcha.compose.engine.render.pdf.GlyphFallbackLogger; +import com.demcha.compose.font.FontName; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.poi.sl.usermodel.PictureData; +import org.apache.poi.xslf.usermodel.XSLFConnectorShape; +import org.apache.poi.xslf.usermodel.XSLFPictureShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.apache.poi.xslf.usermodel.XSLFTextBox; +import org.openxmlformats.schemas.presentationml.x2006.main.CTPicture; + +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.IOException; +import java.nio.file.Files; +import java.util.List; + +/** + * Renders document-level chrome — watermarks and repeating headers/footers — + * as slide shapes, reproducing the PDF backend's post-processing geometry: + * the same position math, the same {@code {page}} / {@code {pages}} / + * {@code {date}} token resolution with section-local numbering windows, and + * the same Helvetica(-Bold) measurement so text lands where the PDF's does. + * + *

Layering needs no z-order surgery: the backend applies behind-content + * watermarks before fragment rendering and above-content chrome after it, so + * document order produces the PDF's prepend/append stacking.

+ * + *

All slide lookups go through the environment's section-local page index, + * so a multi-section render applies each section's chrome to that section's + * slide window.

+ * + * @since 2.1.0 + */ +@Internal +public final class PptxChromeRenderer { + + /** Chrome text frames seat their baseline like every other PPTX text frame. */ + private static final double LINE_HEIGHT_FACTOR = 1.3; + + private PptxChromeRenderer() { + } + + /** + * Renders the watermark on every page of the active section window when it + * layers behind the content. Must run before fragment rendering so the + * slide's document order keeps the watermark underneath. + * + * @param environment active render environment + * @param watermark watermark chrome, may be {@code null} + * @param canvas section page canvas + * @param pageCount pages in the active section window + * @throws IOException if watermark text measurement or image decoding fails + */ + public static void applyWatermarkBehindContent(PptxRenderEnvironment environment, + DocumentWatermark watermark, + LayoutCanvas canvas, + int pageCount) throws IOException { + applyWatermark(environment, watermark, canvas, pageCount, DocumentWatermarkLayer.BEHIND_CONTENT); + } + + /** + * Renders the watermark on every page of the active section window when it + * layers above the content. Must run after fragment rendering. + * + * @param environment active render environment + * @param watermark watermark chrome, may be {@code null} + * @param canvas section page canvas + * @param pageCount pages in the active section window + * @throws IOException if watermark text measurement or image decoding fails + */ + public static void applyWatermarkAboveContent(PptxRenderEnvironment environment, + DocumentWatermark watermark, + LayoutCanvas canvas, + int pageCount) throws IOException { + applyWatermark(environment, watermark, canvas, pageCount, DocumentWatermarkLayer.ABOVE_CONTENT); + } + + private static void applyWatermark(PptxRenderEnvironment environment, + DocumentWatermark watermark, + LayoutCanvas canvas, + int pageCount, + DocumentWatermarkLayer targetLayer) throws IOException { + if (watermark == null) { + return; + } + DocumentWatermarkLayer layer = watermark.getLayer() == null + ? DocumentWatermarkLayer.BEHIND_CONTENT + : watermark.getLayer(); + if (layer != targetLayer) { + return; + } + for (int local = 0; local < pageCount; local++) { + if (watermark.isTextBased()) { + renderTextWatermark(environment, watermark, canvas, local); + } else if (watermark.isImageBased()) { + renderImageWatermark(environment, watermark, canvas, local); + } + } + } + + private static void renderTextWatermark(PptxRenderEnvironment environment, + DocumentWatermark watermark, + LayoutCanvas canvas, + int localPageIndex) throws IOException { + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + float fontSize = watermark.getFontSize(); + String text = GlyphFallbackLogger.sanitize(font, watermark.getText()); + float textWidth = font.getStringWidth(text) / 1000f * fontSize; + Color color = withOpacity( + watermark.getColor() == null ? Color.LIGHT_GRAY : watermark.getColor().color(), + watermark.getOpacity()); + TextStyle style = new TextStyle(FontName.HELVETICA_BOLD, fontSize, TextDecoration.DEFAULT, color); + + DocumentWatermarkPosition position = watermark.getPosition() == null + ? DocumentWatermarkPosition.CENTER + : watermark.getPosition(); + if (position == DocumentWatermarkPosition.TILE) { + float spacingX = textWidth + 80; + float spacingY = fontSize + 120; + // Rotation can carry glyphs at most this far from the baseline + // start; tiles that cannot reach the slide are skipped instead of + // stored as invisible off-slide shapes (the PDF just overdraws). + float reach = textWidth + fontSize; + for (float y = (float) -canvas.height(); y < canvas.height() * 2; y += spacingY) { + for (float x = (float) -canvas.width(); x < canvas.width() * 2; x += spacingX) { + if (x + reach < 0 || x - reach > canvas.width() + || y + reach < 0 || y - reach > canvas.height()) { + continue; + } + placeWatermarkText(environment, localPageIndex, text, style, + textWidth, x, y, watermark.getRotation()); + } + } + } else { + float[] baseline = resolvePosition(position, canvas, textWidth, fontSize); + placeWatermarkText(environment, localPageIndex, text, style, + textWidth, baseline[0], baseline[1], watermark.getRotation()); + } + } + + /** + * Places one watermark text frame whose baseline start sits at the given + * bottom-left-space point, rotated about that point like the PDF's text + * matrix. PowerPoint rotates a shape about its own centre and clockwise, + * so the frame centre is orbited to where the PDF rotation would carry it + * and the shape rotation carries the negated angle. + */ + private static void placeWatermarkText(PptxRenderEnvironment environment, + int localPageIndex, + String text, + TextStyle style, + double textWidth, + double baselineX, + double baselineY, + double rotationDegrees) { + double canvasHeight = environment.canvasHeight(); + double ascent = environment.viewerAscent(style, style.size() * 0.9); + double frameWidth = textWidth + PptxTextFrames.FRAME_EPSILON; + double frameHeight = style.size() * LINE_HEIGHT_FACTOR; + double frameLeft = baselineX; + double frameTop = canvasHeight - baselineY - ascent; + + if (rotationDegrees != 0) { + double pivotX = baselineX; + double pivotY = canvasHeight - baselineY; + double centerX = frameLeft + frameWidth / 2.0; + double centerY = frameTop + frameHeight / 2.0; + double theta = Math.toRadians(rotationDegrees); + double dx = centerX - pivotX; + double dy = centerY - pivotY; + // Visually counter-clockwise orbit in the slide's top-down space, + // matching the PDF's counter-clockwise rotation in y-up space. + double orbitedX = pivotX + dx * Math.cos(theta) + dy * Math.sin(theta); + double orbitedY = pivotY - dx * Math.sin(theta) + dy * Math.cos(theta); + frameLeft = orbitedX - frameWidth / 2.0; + frameTop = orbitedY - frameHeight / 2.0; + } + + XSLFTextBox box = PptxTextFrames.singleRunBox( + environment.slide(localPageIndex), + "GraphCompose Watermark", + new Rectangle2D.Double(frameLeft, frameTop, frameWidth, frameHeight), + text, style, environment); + if (rotationDegrees != 0) { + box.setRotation(-rotationDegrees); + } + } + + private static void renderImageWatermark(PptxRenderEnvironment environment, + DocumentWatermark watermark, + LayoutCanvas canvas, + int localPageIndex) throws IOException { + byte[] bytes = watermark.getImagePath() != null + ? Files.readAllBytes(watermark.getImagePath()) + : watermark.getImageBytes(); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(bytes)); + if (decoded == null) { + throw new IOException("The watermark image could not be decoded."); + } + float imageWidth = decoded.getWidth(); + float imageHeight = decoded.getHeight(); + + DocumentWatermarkPosition position = watermark.getPosition() == null + ? DocumentWatermarkPosition.CENTER + : watermark.getPosition(); + if (position == DocumentWatermarkPosition.TILE) { + float spacingX = imageWidth + 60; + float spacingY = imageHeight + 60; + for (float y = 0; y < canvas.height(); y += spacingY) { + for (float x = 0; x < canvas.width(); x += spacingX) { + placeWatermarkImage(environment, localPageIndex, bytes, + x, y, imageWidth, imageHeight, watermark.getOpacity()); + } + } + } else { + float[] bottomLeft = resolvePosition(position, canvas, imageWidth, imageHeight); + placeWatermarkImage(environment, localPageIndex, bytes, + bottomLeft[0], bottomLeft[1], imageWidth, imageHeight, watermark.getOpacity()); + } + } + + private static void placeWatermarkImage(PptxRenderEnvironment environment, + int localPageIndex, + byte[] bytes, + double x, + double y, + double width, + double height, + float opacity) { + XSLFSlide slide = environment.slide(localPageIndex); + XSLFPictureShape picture = slide.createPicture( + environment.slideShow().addPicture(bytes, sniffPictureType(bytes))); + picture.setAnchor(new Rectangle2D.Double( + x, environment.canvasHeight() - y - height, width, height)); + CTPicture xml = (CTPicture) picture.getXmlObject(); + xml.getNvPicPr().getCNvPr().setName("GraphCompose Watermark"); + if (opacity < 1f) { + xml.getBlipFill().getBlip().addNewAlphaModFix() + .setAmt(Math.round(Math.min(1f, Math.max(0f, opacity)) * 100000)); + } + } + + private static PictureData.PictureType sniffPictureType(byte[] bytes) { + if (bytes.length >= 2 && (bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8) { + return PictureData.PictureType.JPEG; + } + if (bytes.length >= 3 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F') { + return PictureData.PictureType.GIF; + } + if (bytes.length >= 2 && bytes[0] == 'B' && bytes[1] == 'M') { + return PictureData.PictureType.BMP; + } + return PictureData.PictureType.PNG; + } + + /** + * Bottom-left anchor of a watermark object in the engine's y-up page + * space — the same 20pt-inset placement grid the PDF watermark uses. + */ + private static float[] resolvePosition(DocumentWatermarkPosition position, + LayoutCanvas canvas, + float objectWidth, + float objectHeight) { + float pageWidth = (float) canvas.width(); + float pageHeight = (float) canvas.height(); + return switch (position) { + case CENTER -> new float[]{(pageWidth - objectWidth) / 2f, (pageHeight - objectHeight) / 2f}; + case TOP_LEFT -> new float[]{20, pageHeight - objectHeight - 20}; + case TOP_RIGHT -> new float[]{pageWidth - objectWidth - 20, pageHeight - objectHeight - 20}; + case BOTTOM_LEFT -> new float[]{20, 20}; + case BOTTOM_RIGHT -> new float[]{pageWidth - objectWidth - 20, 20}; + case TILE -> new float[]{0, 0}; // handled by the tiling loops + }; + } + + /** + * Renders every header/footer entry on the active section window, with + * {@code {page}} / {@code {pages}} tokens and the show-on-page rules + * evaluated against the section-local window — the multi-section numbering + * contract the PDF backend applies. + * + * @param environment active render environment + * @param entries header/footer chrome entries + * @param canvas section page canvas (supplies the text margins) + * @param pageCount pages in the active section window + * @throws IOException if header/footer text measurement fails + */ + public static void applyHeadersAndFooters(PptxRenderEnvironment environment, + List entries, + LayoutCanvas canvas, + int pageCount) throws IOException { + if (entries == null || entries.isEmpty()) { + return; + } + Margin margin = canvas.margin(); + float marginLeft = margin != null ? (float) margin.left() : 24f; + float marginRight = margin != null ? (float) margin.right() : 24f; + List configs = entries.stream() + .map(PptxChromeRenderer::toEngine) + .toList(); + for (int local = 0; local < pageCount; local++) { + for (HeaderFooterConfig config : configs) { + if (!config.appliesTo(local + 1)) { + continue; + } + renderZone(environment, config, canvas, local, pageCount, marginLeft, marginRight); + } + } + } + + private static void renderZone(PptxRenderEnvironment environment, + HeaderFooterConfig config, + LayoutCanvas canvas, + int localPageIndex, + int pageCount, + float marginLeft, + float marginRight) throws IOException { + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = config.getFontSize(); + float pageWidth = (float) canvas.width(); + float usableWidth = pageWidth - marginLeft - marginRight; + int currentPage = localPageIndex + 1; + + // Baseline in the engine's y-up space — identical to the PDF zones. + float baseY = config.getZone() == HeaderFooterZone.HEADER + ? (float) canvas.height() - config.getHeight() + fontSize / 2f + : config.getHeight() - fontSize; + String shapeName = config.getZone() == HeaderFooterZone.HEADER + ? "GraphCompose Header" + : "GraphCompose Footer"; + TextStyle style = new TextStyle(FontName.HELVETICA, fontSize, TextDecoration.DEFAULT, + config.getTextColor() == null ? Color.GRAY : config.getTextColor()); + + String leftText = GlyphFallbackLogger.sanitize(font, + config.resolveTokens(config.getLeftText(), currentPage, pageCount)); + if (!leftText.isEmpty()) { + placeZoneText(environment, localPageIndex, shapeName, leftText, style, marginLeft, baseY, + font.getStringWidth(leftText) / 1000f * fontSize); + } + String centerText = GlyphFallbackLogger.sanitize(font, + config.resolveTokens(config.getCenterText(), currentPage, pageCount)); + if (!centerText.isEmpty()) { + float textWidth = font.getStringWidth(centerText) / 1000f * fontSize; + placeZoneText(environment, localPageIndex, shapeName, centerText, style, + marginLeft + (usableWidth - textWidth) / 2f, baseY, textWidth); + } + String rightText = GlyphFallbackLogger.sanitize(font, + config.resolveTokens(config.getRightText(), currentPage, pageCount)); + if (!rightText.isEmpty()) { + float textWidth = font.getStringWidth(rightText) / 1000f * fontSize; + placeZoneText(environment, localPageIndex, shapeName, rightText, style, + pageWidth - marginRight - textWidth, baseY, textWidth); + } + + if (config.isShowSeparator()) { + float separatorY = config.getZone() == HeaderFooterZone.HEADER + ? (float) canvas.height() - config.getHeight() + : config.getHeight(); + XSLFConnectorShape separator = environment.slide(localPageIndex).createConnector(); + separator.setAnchor(new Rectangle2D.Double( + marginLeft, environment.canvasHeight() - separatorY, + pageWidth - marginRight - marginLeft, 0)); + separator.setLineColor(config.getSeparatorColor()); + separator.setLineWidth(config.getSeparatorThickness()); + PptxTextFrames.setShapeName(separator, shapeName + " Separator"); + } + } + + private static void placeZoneText(PptxRenderEnvironment environment, + int localPageIndex, + String shapeName, + String text, + TextStyle style, + double x, + double baseY, + double textWidth) { + double top = environment.canvasHeight() - baseY + - environment.viewerAscent(style, style.size() * 0.9); + PptxTextFrames.singleRunBox( + environment.slide(localPageIndex), + shapeName, + new Rectangle2D.Double(x, top, + textWidth + PptxTextFrames.FRAME_EPSILON, + style.size() * LINE_HEIGHT_FACTOR), + text, style, environment); + } + + /** + * Maps the canonical header/footer entry onto the engine config that owns + * token resolution and the numbering window rules — the same mapping the + * PDF options adapter applies. + */ + private static HeaderFooterConfig toEngine(DocumentHeaderFooter entry) { + DocumentPageNumbering numbering = entry.getNumbering() == null + ? DocumentPageNumbering.DEFAULT + : entry.getNumbering(); + return HeaderFooterConfig.builder() + .zone(entry.getZone() == DocumentHeaderFooterZone.FOOTER + ? HeaderFooterZone.FOOTER + : HeaderFooterZone.HEADER) + .height(entry.getHeight()) + .leftText(entry.getLeftText()) + .centerText(entry.getCenterText()) + .rightText(entry.getRightText()) + .fontSize(entry.getFontSize()) + .textColor(entry.getTextColor() == null ? null : entry.getTextColor().color()) + .showSeparator(entry.isShowSeparator()) + .separatorColor(entry.getSeparatorColor() == null ? null : entry.getSeparatorColor().color()) + .separatorThickness(entry.getSeparatorThickness()) + .numberStartAt(numbering.getStartAt()) + .numberCountFrom(numbering.getCountFrom()) + .numberShowOnFirstPage(numbering.isShowOnFirstPage()) + .numberStyle(toEngine(numbering.getStyle())) + .build(); + } + + private static PageNumberStyle toEngine(DocumentPageNumberStyle style) { + if (style == null) { + return PageNumberStyle.DECIMAL; + } + return switch (style) { + case DECIMAL -> PageNumberStyle.DECIMAL; + case LOWER_ROMAN -> PageNumberStyle.LOWER_ROMAN; + case UPPER_ROMAN -> PageNumberStyle.UPPER_ROMAN; + case LOWER_ALPHA -> PageNumberStyle.LOWER_ALPHA; + case UPPER_ALPHA -> PageNumberStyle.UPPER_ALPHA; + }; + } + + private static Color withOpacity(Color color, float opacity) { + int alpha = Math.round(Math.min(1f, Math.max(0f, opacity)) * 255); + return new Color(color.getRed(), color.getGreen(), color.getBlue(), alpha); + } +} 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 ecca477d..9a6501ac 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 @@ -4,7 +4,6 @@ import com.demcha.compose.document.backend.fixed.pptx.PptxRenderEnvironment; import com.demcha.compose.document.layout.PlacedFragment; import com.demcha.compose.document.layout.payloads.*; -import com.demcha.compose.document.node.ExternalLinkTarget; import com.demcha.compose.document.node.InlineImageAlignment; import com.demcha.compose.document.node.TextVerticalAlign; import com.demcha.compose.document.style.DocumentInsets; @@ -115,14 +114,14 @@ private static void renderLine(int pageIndex, } else if (span instanceof ParagraphSvgSpan svgSpan) { renderSvg(surface, svgSpan, cursorX, baselineY, line, environment); } - renderExternalLinkOverlay(surface, spanLinkTarget(span), cursorX, span.width(), - lineTop, line, environment); + deferLinkOverlay(pageIndex, spanLinkTarget(span), span, cursorX, span.width(), + lineTop, baselineY, 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(surface, payload.linkTarget(), lineX, line.width(), - lineTop, line, environment); + deferLinkOverlay(pageIndex, payload.linkTarget(), null, lineX, line.width(), + lineTop, baselineY, line, environment); } /** @@ -233,26 +232,53 @@ private static void renderChip(XSLFShapeContainer surface, PptxTextFrames.addRun(PptxTextFrames.preparedParagraph(textBox), text, span.textStyle(), environment); } - private static void renderExternalLinkOverlay(XSLFShapeContainer surface, - com.demcha.compose.document.node.DocumentLinkTarget target, - double x, - double width, - double lineTop, - ParagraphLine line, - PptxRenderEnvironment environment) { - if (!(target instanceof ExternalLinkTarget external) || width <= 0) { + /** + * Records one clickable span or line rectangle for deferred emission: + * hotspots land on the slide after every fragment is painted, so they sit + * above all content (the PDF annotation contract) and internal targets can + * resolve forward references. Inline-graphic spans get their + * baseline-aligned box, everything else the full line box — the PDF + * backend's span-rectangle rules. + */ + private static void deferLinkOverlay(int pageIndex, + com.demcha.compose.document.node.DocumentLinkTarget target, + ParagraphSpan span, + double x, + double width, + double lineTop, + double baselineY, + ParagraphLine line, + PptxRenderEnvironment environment) { + if (target == null || width <= 0) { return; } - XSLFAutoShape hotspot = surface.createAutoShape(); - hotspot.setShapeType(ShapeType.RECT); - hotspot.setAnchor(new Rectangle2D.Double(x, - environment.canvasHeight() - lineTop, width, line.lineHeight())); - // A fully transparent solid fill keeps the whole rectangle clickable - // in slide-show mode. noFill shapes only hit-test their outline. - hotspot.setFillColor(new Color(0, 0, 0, 0)); - hotspot.setLineColor(null); - PptxTextFrames.setShapeName(hotspot, "GraphCompose Text Link Hotspot"); - hotspot.createHyperlink().linkToUrl(external.options().uri()); + double canvasHeight = environment.canvasHeight(); + InlineImageAlignment alignment = null; + double graphicHeight = 0; + double baselineOffset = 0; + if (span instanceof ParagraphImageSpan imageSpan) { + alignment = imageSpan.alignment(); + graphicHeight = imageSpan.height(); + baselineOffset = imageSpan.baselineOffset(); + } else if (span instanceof ParagraphShapeSpan shapeSpan) { + alignment = shapeSpan.alignment(); + graphicHeight = shapeSpan.height(); + baselineOffset = shapeSpan.baselineOffset(); + } else if (span instanceof ParagraphSvgSpan svgSpan) { + alignment = svgSpan.alignment(); + graphicHeight = svgSpan.height(); + baselineOffset = svgSpan.baselineOffset(); + } + Rectangle2D.Double rectangle; + if (graphicHeight > 0) { + double bottom = inlineBottom(graphicHeight, alignment, baselineOffset, baselineY, line); + rectangle = new Rectangle2D.Double( + x, canvasHeight - bottom - graphicHeight, width, graphicHeight); + } else { + rectangle = new Rectangle2D.Double( + x, canvasHeight - lineTop, width, line.lineHeight()); + } + environment.deferLink(pageIndex, rectangle, target); } private static void renderImage(XSLFShapeContainer surface, diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeNavParityDemoTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeNavParityDemoTest.java new file mode 100644 index 00000000..04b785b6 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeNavParityDemoTest.java @@ -0,0 +1,151 @@ +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.PdfFixedLayoutBackend; +import com.demcha.compose.document.backend.fixed.pdf.PdfOutputOptionsTranslator; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +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.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Reviewable chrome-and-navigation parity pair: the same two-page document + * with metadata, a DRAFT watermark, tokenized header/footer, an internal + * table-of-contents link, and a bookmark rendered to PDF and PPTX with + * per-page PNG previews at matching scale. + */ +class PptxChromeNavParityDemoTest { + + private static final double SCALE = 2.0; + + @Test + void writesTheChromeNavDemoPair() throws Exception { + Path output = Path.of("target", "visual-tests", "pptx-parity", "chrome-nav"); + Files.createDirectories(output); + + DocumentMetadata metadata = DocumentMetadata.builder() + .title("Engine Chrome Demo") + .author("DemchaAV") + .subject("Chrome and navigation parity") + .keywords("graph-compose, pptx, chrome") + .build(); + DocumentWatermark watermark = DocumentWatermark.builder() + .text("DRAFT") + .build(); + DocumentHeaderFooter header = DocumentHeaderFooter.builder() + .leftText("Engine Report") + .rightText("Page {page} of {pages}") + .showSeparator(true) + .build(); + DocumentHeaderFooter footer = DocumentHeaderFooter.builder() + .centerText("graph-compose · chrome & navigation") + .build(); + + try (DocumentSession session = composeDemo()) { + byte[] pdf = session.render(PdfFixedLayoutBackend.builder() + .metadata(PdfOutputOptionsTranslator.toPdf(metadata)) + .watermark(PdfOutputOptionsTranslator.toPdf(watermark)) + .header(PdfOutputOptionsTranslator.toPdf(header)) + .footer(PdfOutputOptionsTranslator.toPdf(footer)) + .build()); + Files.write(output.resolve("chrome-nav.pdf"), pdf); + + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .metadata(metadata) + .watermark(watermark) + .header(header) + .footer(footer) + .build()); + Files.write(output.resolve("chrome-nav.pptx"), pptx); + + try (PDDocument document = Loader.loadPDF(pdf); + XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides()).hasSize(document.getNumberOfPages()); + PDFRenderer renderer = new PDFRenderer(document); + for (int page = 0; page < document.getNumberOfPages(); page++) { + ImageIO.write(renderer.renderImageWithDPI(page, (float) (72 * SCALE)), + "png", output.resolve("chrome-nav-page-" + (page + 1) + ".pdf.png").toFile()); + ImageIO.write(rasterize(show.getSlides().get(page), + show.getPageSize().width, show.getPageSize().height), + "png", output.resolve("chrome-nav-page-" + (page + 1) + ".pptx.png").toFile()); + } + } + } + } + + private static DocumentSession composeDemo() { + DocumentSession session = GraphCompose.document() + .pageSize(480, 320) + .margin(DocumentInsets.of(36)) + .create(); + session.add(new ParagraphBuilder().name("Title") + .text("Engine Chrome Demo") + .textStyle(DocumentTextStyle.builder().size(20) + .decoration(DocumentTextDecoration.BOLD).build()) + .build()); + session.add(new ParagraphBuilder().name("Toc") + .inlineText("Contents: ") + .inlineLinkTo("Benchmark details »", "details") + .build()); + session.add(session.dsl().shape().name("CoverCard").size(180, 50) + .fillColor(DocumentColor.ROYAL_BLUE) + .cornerRadius(8) + .linkTo("details") + .build()); + session.add(new PageBreakNode("Break", DocumentInsets.zero())); + session.add(new ParagraphBuilder().name("DetailsTitle") + .text("Benchmark details") + .textStyle(DocumentTextStyle.builder().size(16) + .decoration(DocumentTextDecoration.BOLD).build()) + .anchor("details") + .bookmark(new DocumentBookmarkOptions("Benchmark details")) + .build()); + session.add(new ParagraphBuilder().name("DetailsBody") + .text("This page is the target of the cover's internal links; " + + "the slide carries the bookmark's name.") + .build()); + return session; + } + + 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; + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeTest.java new file mode 100644 index 00000000..378abe82 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxChromeTest.java @@ -0,0 +1,198 @@ +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.node.PageBreakNode; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.output.DocumentPageNumbering; +import com.demcha.compose.document.output.DocumentPageNumberStyle; +import com.demcha.compose.document.output.DocumentWatermark; +import com.demcha.compose.document.output.DocumentWatermarkLayer; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFConnectorShape; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.apache.poi.xslf.usermodel.XSLFTextBox; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * Document chrome on the PPTX backend: metadata lands in the OPC core + * properties, watermarks render per slide at the PDF's placement math and on + * the PDF's layer, and repeating headers/footers resolve their page tokens + * with the numbering rules. + */ +class PptxChromeTest { + + private static DocumentSession composeTwoPages() { + DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(24)) + .create(); + session.add(session.dsl().shape().name("First").size(120, 40) + .fillColor(DocumentColor.ROYAL_BLUE).build()); + session.add(new PageBreakNode("Break", DocumentInsets.zero())); + session.add(session.dsl().shape().name("Second").size(120, 40) + .fillColor(DocumentColor.ORANGE).build()); + return session; + } + + @Test + void metadataLandsInTheCoreProperties() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .metadata(DocumentMetadata.builder() + .title("Quarterly Deck") + .author("DemchaAV") + .subject("Chrome") + .keywords("graphs, decks") + .build()) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + var core = show.getProperties().getCoreProperties(); + assertThat(core.getTitle()).isEqualTo("Quarterly Deck"); + assertThat(core.getCreator()).isEqualTo("DemchaAV"); + assertThat(core.getSubject()).isEqualTo("Chrome"); + assertThat(core.getKeywords()).isEqualTo("graphs, decks"); + // The canonical default creating application. + assertThat(show.getProperties().getExtendedProperties().getApplication()) + .isEqualTo("GraphCompose"); + } + } + } + + @Test + void behindContentWatermarkRendersFirstOnEverySlide() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .watermark(DocumentWatermark.builder().text("DRAFT").build()) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFSlide slide : show.getSlides()) { + XSLFShape first = slide.getShapes().get(0); + assertThat(first.getShapeName()).isEqualTo("GraphCompose Watermark"); + XSLFTextBox watermark = (XSLFTextBox) first; + assertThat(watermark.getText()).isEqualTo("DRAFT"); + // Engine rotation is counter-clockwise in page space; + // PowerPoint's positive rotation is clockwise. + assertThat(watermark.getRotation()).isEqualTo(-45.0); + assertThat(slide.getShapes().size()).isGreaterThan(1); + } + } + } + } + + @Test + void theWatermarkFrameOrbitsThePdfBaselineRotationPivot() throws Exception { + // Hand-derived for DRAFT @ Helvetica-Bold 72pt, CENTER, 45° on a + // 480x320 page: text width = 3388/1000*72 = 243.936pt, Arial viewer + // ascent = 72*1854/2048 = 65.180pt, PDF baseline start + // (118.032, 124) y-up. Orbiting the unrotated frame centre + // counter-clockwise about that point lands it at (191.315, 96.724) + // in slide space; PowerPoint then rotates the frame -45 about its + // own centre. A sign flip in the orbit math moves the centre by + // tens of points, so this pins the placement, not just the angle. + try (DocumentSession session = GraphCompose.document() + .pageSize(480, 320) + .margin(DocumentInsets.of(24)) + .create()) { + session.add(session.dsl().shape().name("Content").size(100, 40) + .fillColor(DocumentColor.GRAY).build()); + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .watermark(DocumentWatermark.builder().text("DRAFT").build()) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + XSLFTextBox watermark = (XSLFTextBox) show.getSlides().get(0).getShapes().get(0); + assertThat(watermark.getShapeName()).isEqualTo("GraphCompose Watermark"); + var anchor = watermark.getAnchor(); + assertThat(anchor.getCenterX()).isCloseTo(191.315, within(0.05)); + assertThat(anchor.getCenterY()).isCloseTo(96.724, within(0.05)); + assertThat(watermark.getRotation()).isEqualTo(-45.0); + } + } + } + + @Test + void aboveContentWatermarkRendersLast() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .watermark(DocumentWatermark.builder() + .text("CONFIDENTIAL") + .layer(DocumentWatermarkLayer.ABOVE_CONTENT) + .build()) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + for (XSLFSlide slide : show.getSlides()) { + List shapes = slide.getShapes(); + assertThat(shapes.get(shapes.size() - 1).getShapeName()) + .isEqualTo("GraphCompose Watermark"); + } + } + } + } + + @Test + void headersAndFootersResolveTokensPerPageWithNumberingRules() throws Exception { + try (DocumentSession session = composeTwoPages()) { + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .header(DocumentHeaderFooter.builder() + .leftText("Engine Report") + .rightText("Page {page} of {pages}") + .showSeparator(true) + .build()) + .footer(DocumentHeaderFooter.builder() + .centerText("{page}") + .numbering(DocumentPageNumbering.builder() + .showOnFirstPage(false) + .style(DocumentPageNumberStyle.UPPER_ROMAN) + .build()) + .build()) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + List firstHeaders = zoneTexts(show.getSlides().get(0), "GraphCompose Header"); + assertThat(firstHeaders).containsExactlyInAnyOrder("Engine Report", "Page 1 of 2"); + assertThat(zoneTexts(show.getSlides().get(1), "GraphCompose Header")) + .contains("Page 2 of 2"); + + // showOnFirstPage=false suppresses the whole zone on page 1; + // UPPER_ROMAN formats the counted value on page 2. + assertThat(zoneTexts(show.getSlides().get(0), "GraphCompose Footer")).isEmpty(); + assertThat(zoneTexts(show.getSlides().get(1), "GraphCompose Footer")) + .containsExactly("II"); + + // Header text sits inside the reserved 30pt top zone, the + // footer inside the bottom zone. + XSLFTextBox header = (XSLFTextBox) findShape(show.getSlides().get(0), "GraphCompose Header"); + assertThat(header.getAnchor().getY()).isBetween(0.0, 30.0); + XSLFTextBox footer = (XSLFTextBox) findShape(show.getSlides().get(1), "GraphCompose Footer"); + assertThat(footer.getAnchor().getY()).isBetween(270.0, 300.0); + + XSLFShape separator = findShape(show.getSlides().get(0), "GraphCompose Header Separator"); + assertThat(separator).isInstanceOf(XSLFConnectorShape.class); + assertThat(separator.getAnchor().getY()).isEqualTo(30.0); + } + } + } + + private static List zoneTexts(XSLFSlide slide, String shapeName) { + return slide.getShapes().stream() + .filter(shape -> shapeName.equals(shape.getShapeName())) + .map(shape -> ((XSLFTextBox) shape).getText()) + .toList(); + } + + private static XSLFShape findShape(XSLFSlide slide, String shapeName) { + return slide.getShapes().stream() + .filter(shape -> shapeName.equals(shape.getShapeName())) + .findFirst() + .orElseThrow(() -> new AssertionError("No shape named " + shapeName)); + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismTest.java new file mode 100644 index 00000000..44d84c21 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeterminismTest.java @@ -0,0 +1,78 @@ +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.ParagraphBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Deterministic output: with a pinned timestamp the same document renders to + * byte-identical .pptx across runs — the OPC created/modified clocks and every + * zip entry time carry the pinned instant instead of POI's wall clock. + */ +class PptxDeterminismTest { + + private static final Instant PINNED = Instant.parse("2024-05-05T12:34:56Z"); + + private static DocumentSession composeDocument() { + DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(24)) + .create(); + session.add(new ParagraphBuilder().name("Title") + .text("Deterministic deck") + .build()); + session.add(session.dsl().shape().name("Card").size(140, 40) + .fillColor(DocumentColor.ROYAL_BLUE) + .build()); + return session; + } + + @Test + void deterministicRendersAreByteIdentical() throws Exception { + try (DocumentSession session = composeDocument()) { + PptxFixedLayoutBackend backend = PptxFixedLayoutBackend.builder() + .deterministic(PINNED) + .build(); + byte[] first = session.render(backend); + byte[] second = session.render(backend); + assertThat(second).isEqualTo(first); + } + } + + @Test + void thePinnedInstantLandsInCorePropertiesAndZipEntries() throws Exception { + try (DocumentSession session = composeDocument()) { + byte[] pptx = session.render(PptxFixedLayoutBackend.builder() + .deterministic(PINNED) + .build()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + var core = show.getProperties().getCoreProperties(); + assertThat(core.getCreated().toInstant()).isEqualTo(PINNED); + assertThat(core.getModified().toInstant()).isEqualTo(PINNED); + } + LocalDateTime expected = LocalDateTime.ofInstant(PINNED, ZoneOffset.UTC); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(pptx))) { + int entries = 0; + for (ZipEntry entry; (entry = zip.getNextEntry()) != null; entries++) { + assertThat(entry.getTimeLocal()) + .as("entry %s carries the pinned time", entry.getName()) + .isEqualTo(expected); + } + assertThat(entries).isGreaterThan(3); + } + } + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java index 3b211283..00f74fc2 100644 --- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxGeometryAssertions.java @@ -210,7 +210,7 @@ && isMeasuredTextShape(textBox.getShapeName())) { for (int page = 0; page < show.getSlides().size(); page++) { for (XSLFShape shape : show.getSlides().get(page).getShapes()) { if (shape instanceof XSLFAutoShape autoShape - && "GraphCompose Text Link Hotspot".equals(shape.getShapeName())) { + && "GraphCompose Link Hotspot".equals(shape.getShapeName())) { assertThat(autoShape.getHyperlink()) .as("external link hotspot on slide %d", page) .isNotNull(); diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationTest.java new file mode 100644 index 00000000..3620125f --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxNavigationTest.java @@ -0,0 +1,156 @@ +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.EllipseBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.sl.usermodel.ShapeType; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFAutoShape; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.drawingml.x2006.main.CTHyperlink; +import org.openxmlformats.schemas.presentationml.x2006.main.CTShape; + +import java.awt.geom.Rectangle2D; +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Navigation emission: fragment- and span-level links become transparent + * hotspots — external targets as URL hyperlinks, internal targets as + * slide-jump hyperlinks resolved through the anchor map (including forward + * references) — and bookmarks name their slides. + */ +class PptxNavigationTest { + + private static final String SLIDE_JUMP_ACTION = "ppaction://hlinksldjump"; + + private static DocumentSession composeLinkedDocument() { + DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(24)) + .create(); + session.add(session.dsl().shape().name("TocEntry").size(120, 30) + .fillColor(DocumentColor.ROYAL_BLUE) + .linkTo("details") + .build()); + session.add(new EllipseBuilder().name("Badge").circle(24) + .fillColor(DocumentColor.ORANGE) + .link(new DocumentLinkOptions("https://example.com/gc")) + .build()); + session.add(new ParagraphBuilder().name("TocLine") + .inlineLinkTo("See the details section", "details") + .build()); + session.add(new PageBreakNode("Break", DocumentInsets.zero())); + session.add(session.dsl().shape().name("Details").size(160, 60) + .fillColor(DocumentColor.DARK_GRAY) + .anchor("details") + .bookmark(new DocumentBookmarkOptions("Details")) + .build()); + return session; + } + + @Test + void internalAndExternalLinksEmitResolvedHotspots() throws Exception { + try (DocumentSession session = composeLinkedDocument()) { + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + XSLFSlide first = show.getSlides().get(0); + List hotspots = hotspots(first); + // Fragment-level internal, fragment-level external, plus one + // deferred hotspot per linked text span — the engine splits + // inline link text into word spans, so the count mirrors the + // PDF backend's per-span link annotations. + assertThat(hotspots).hasSizeGreaterThanOrEqualTo(3); + + // A URL hyperlink carries no slide-jump action (POI writes an + // empty action attribute). + XSLFShape external = hotspots.stream() + .filter(shape -> !SLIDE_JUMP_ACTION.equals(hyperlink(shape).getAction())) + .findFirst().orElseThrow(); + assertThat(first.getPackagePart() + .getRelationship(hyperlink(external).getId()) + .getTargetURI().toString()) + .isEqualTo("https://example.com/gc"); + // The external hotspot hugs the ellipse fragment box. + XSLFShape badge = first.getShapes().stream() + .filter(shape -> shape instanceof XSLFAutoShape auto + && auto.getShapeType() == ShapeType.ELLIPSE) + .findFirst().orElseThrow(); + assertThat(external.getAnchor()).isEqualTo(badge.getAnchor()); + + List internal = hotspots.stream() + .filter(shape -> SLIDE_JUMP_ACTION.equals(hyperlink(shape).getAction())) + .toList(); + assertThat(internal).hasSize(hotspots.size() - 1); + for (XSLFShape hotspot : internal) { + assertThat(first.getRelationById(hyperlink(hotspot).getId())) + .isSameAs(show.getSlides().get(1)); + } + // One covers the TOC shape's fragment box, the others the + // measured span line, so their heights differ. The TOC shape + // is the slide's first rectangle that isn't a hotspot. + XSLFShape tocShape = first.getShapes().stream() + .filter(shape -> shape instanceof XSLFAutoShape auto + && auto.getShapeType() == ShapeType.RECT + && !"GraphCompose Link Hotspot".equals(shape.getShapeName())) + .findFirst().orElseThrow(); + assertThat(internal.stream().map(XSLFShape::getAnchor)) + .anyMatch(anchor -> anchor.equals(tocShape.getAnchor())); + assertThat(internal.stream() + .map(shape -> ((Rectangle2D) shape.getAnchor()).getHeight()) + .distinct()).hasSize(2); + } + } + } + + @Test + void bookmarksNameTheirSlides() throws Exception { + try (DocumentSession session = composeLinkedDocument()) { + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides().get(1).getXmlObject().getCSld().getName()) + .isEqualTo("Details"); + } + } + } + + @Test + void aLinkToAMissingAnchorIsSkippedNotFatal() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(session.dsl().shape().name("Dangling").size(100, 30) + .fillColor(DocumentColor.GRAY) + .linkTo("nowhere") + .build()); + byte[] pptx = session.render(new PptxFixedLayoutBackend()); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(hotspots(show.getSlides().get(0))).isEmpty(); + } + } + } + + private static List hotspots(XSLFSlide slide) { + return slide.getShapes().stream() + .filter(shape -> "GraphCompose Link Hotspot".equals(shape.getShapeName())) + .toList(); + } + + private static CTHyperlink hyperlink(XSLFShape shape) { + CTHyperlink hyperlink = ((CTShape) shape.getXmlObject()) + .getNvSpPr().getCNvPr().getHlinkClick(); + assertThat(hyperlink).as("hotspot must carry a hyperlink").isNotNull(); + return hyperlink; + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxParagraphFragmentRenderHandlerTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxParagraphFragmentRenderHandlerTest.java index 417fd3f6..4afa7c2c 100644 --- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxParagraphFragmentRenderHandlerTest.java +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxParagraphFragmentRenderHandlerTest.java @@ -95,7 +95,7 @@ void preservesResolvedLineFramesSanitizedTextStylesAndExternalLinks() throws Exc XSLFAutoShape linkHotspot = show.getSlides().get(0).getShapes().stream() .filter(XSLFAutoShape.class::isInstance) .map(XSLFAutoShape.class::cast) - .filter(shape -> "GraphCompose Text Link Hotspot".equals(shape.getShapeName())) + .filter(shape -> "GraphCompose Link Hotspot".equals(shape.getShapeName())) .findFirst().orElseThrow(); assertThat(linkHotspot.getHyperlink().getAddress()) .isEqualTo("https://example.com"); diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSectionsTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSectionsTest.java new file mode 100644 index 00000000..122772d8 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSectionsTest.java @@ -0,0 +1,189 @@ +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.SectionUnit; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFShape; +import org.apache.poi.xslf.usermodel.XSLFSlide; +import org.apache.poi.xslf.usermodel.XSLFTextBox; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.presentationml.x2006.main.CTShape; + +import java.io.ByteArrayInputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Multi-section concatenation: sections keep their own chrome with + * section-local page numbering, navigation resolves across section boundaries + * against the combined deck, and differing page sizes are rejected — a PPTX + * deck carries exactly one slide size. + */ +class PptxSectionsTest { + + private static final String SLIDE_JUMP_ACTION = "ppaction://hlinksldjump"; + + private static SectionUnit sectionUnit(DocumentSession session, + PptxFixedLayoutBackend chrome) throws Exception { + LayoutGraph graph = session.render(new GraphCapturingBackend()); + return new SectionUnit(graph, graph.canvas(), List.of(), chrome); + } + + @Test + void sectionsConcatenateWithSectionLocalChromeAndCrossSectionLinks() throws Exception { + PptxFixedLayoutBackend coverChrome = PptxFixedLayoutBackend.builder() + .footer(DocumentHeaderFooter.builder().centerText("{page} of {pages}").build()) + .build(); + PptxFixedLayoutBackend bodyChrome = PptxFixedLayoutBackend.builder() + .footer(DocumentHeaderFooter.builder().centerText("{page} of {pages}").build()) + .build(); + + try (DocumentSession cover = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create(); + DocumentSession body = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create()) { + cover.add(cover.dsl().shape().name("CoverCard").size(140, 40) + .fillColor(DocumentColor.ROYAL_BLUE) + .linkTo("summary") + .build()); + body.add(body.dsl().shape().name("BodyCard").size(140, 40) + .fillColor(DocumentColor.ORANGE).build()); + body.add(new PageBreakNode("Break", DocumentInsets.zero())); + body.add(body.dsl().shape().name("Summary").size(160, 60) + .fillColor(DocumentColor.DARK_GRAY) + .anchor("summary") + .bookmark(new DocumentBookmarkOptions("Summary")) + .build()); + + byte[] pptx = coverChrome.renderSections(List.of( + sectionUnit(cover, coverChrome), + sectionUnit(body, bodyChrome))); + + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides()).hasSize(3); + + // Footers restart per section: the cover numbers itself 1 of 1, + // the body numbers its own two pages. + assertThat(footerText(show.getSlides().get(0))).isEqualTo("1 of 1"); + assertThat(footerText(show.getSlides().get(1))).isEqualTo("1 of 2"); + assertThat(footerText(show.getSlides().get(2))).isEqualTo("2 of 2"); + + // The cover's link resolves across the section boundary to the + // combined deck's third slide. + XSLFSlide first = show.getSlides().get(0); + XSLFShape hotspot = first.getShapes().stream() + .filter(shape -> "GraphCompose Link Hotspot".equals(shape.getShapeName())) + .findFirst().orElseThrow(); + var hyperlink = ((CTShape) hotspot.getXmlObject()) + .getNvSpPr().getCNvPr().getHlinkClick(); + assertThat(hyperlink.getAction()).isEqualTo(SLIDE_JUMP_ACTION); + assertThat(first.getRelationById(hyperlink.getId())) + .isSameAs(show.getSlides().get(2)); + + assertThat(show.getSlides().get(2).getXmlObject().getCSld().getName()) + .isEqualTo("Summary"); + } + } + } + + @Test + void crossSectionLinksResolveIntoARasterSection() throws Exception { + PptxFixedLayoutBackend vectorChrome = new PptxFixedLayoutBackend(); + PptxFixedLayoutBackend rasterChrome = PptxFixedLayoutBackend.builder() + .rasterSlides(96) + .build(); + try (DocumentSession cover = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create(); + DocumentSession body = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create()) { + cover.add(cover.dsl().shape().name("CoverCard").size(140, 40) + .fillColor(DocumentColor.ROYAL_BLUE) + .linkTo("summary") + .build()); + body.add(body.dsl().shape().name("Summary").size(160, 60) + .fillColor(DocumentColor.DARK_GRAY) + .anchor("summary") + .bookmark(new DocumentBookmarkOptions("Summary")) + .build()); + + byte[] pptx = vectorChrome.renderSections(List.of( + sectionUnit(cover, vectorChrome), + sectionUnit(body, rasterChrome))); + + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides()).hasSize(2); + // The raster section's content is one picture, but its anchor + // and bookmark still resolve as combined-deck destinations. + XSLFSlide first = show.getSlides().get(0); + XSLFShape hotspot = first.getShapes().stream() + .filter(shape -> "GraphCompose Link Hotspot".equals(shape.getShapeName())) + .findFirst().orElseThrow(); + var hyperlink = ((CTShape) hotspot.getXmlObject()) + .getNvSpPr().getCNvPr().getHlinkClick(); + assertThat(hyperlink.getAction()).isEqualTo(SLIDE_JUMP_ACTION); + assertThat(first.getRelationById(hyperlink.getId())) + .isSameAs(show.getSlides().get(1)); + assertThat(show.getSlides().get(1).getXmlObject().getCSld().getName()) + .isEqualTo("Summary"); + } + } + } + + @Test + void deterministicSectionRendersAreByteIdentical() throws Exception { + PptxFixedLayoutBackend deterministic = PptxFixedLayoutBackend.builder() + .deterministic(java.time.Instant.parse("2024-05-05T12:34:56Z")) + .build(); + try (DocumentSession first = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create(); + DocumentSession second = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create()) { + first.add(first.dsl().shape().name("A").size(100, 30) + .fillColor(DocumentColor.GRAY).build()); + second.add(second.dsl().shape().name("B").size(120, 30) + .fillColor(DocumentColor.ORANGE).build()); + List sections = List.of( + sectionUnit(first, deterministic), + sectionUnit(second, deterministic)); + assertThat(deterministic.renderSections(sections)) + .isEqualTo(deterministic.renderSections(sections)); + } + } + + @Test + void sectionsWithDifferingPageSizesAreRejected() throws Exception { + PptxFixedLayoutBackend chrome = new PptxFixedLayoutBackend(); + try (DocumentSession letter = GraphCompose.document() + .pageSize(400, 300).margin(DocumentInsets.of(24)).create(); + DocumentSession wide = GraphCompose.document() + .pageSize(500, 300).margin(DocumentInsets.of(24)).create()) { + letter.add(letter.dsl().shape().name("A").size(100, 30) + .fillColor(DocumentColor.GRAY).build()); + wide.add(wide.dsl().shape().name("B").size(100, 30) + .fillColor(DocumentColor.GRAY).build()); + List sections = List.of( + sectionUnit(letter, chrome), + sectionUnit(wide, chrome)); + assertThatThrownBy(() -> chrome.renderSections(sections)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("one slide size"); + } + } + + private static String footerText(XSLFSlide slide) { + return slide.getShapes().stream() + .filter(shape -> "GraphCompose Footer".equals(shape.getShapeName())) + .map(shape -> ((XSLFTextBox) shape).getText()) + .findFirst() + .orElseThrow(() -> new AssertionError("No footer on the slide")); + } +} From 2d83b8c346ccdc677692387439b01913bef010b6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 18 Jul 2026 01:41:52 +0100 Subject: [PATCH 2/2] refactor(render-pptx): extract deck assembly, stream non-deterministic renders --- .../backend/fixed/pptx/PptxDeckAssembly.java | 189 +++++++++++++++++ .../fixed/pptx/PptxFixedLayoutBackend.java | 191 ++++-------------- .../fixed/pptx/PptxRenderEnvironment.java | 10 +- .../pptx/handlers/PptxChromeRenderer.java | 64 +++--- .../fixed/pptx/PptxBackendContractTest.java | 81 ++++++++ 5 files changed, 353 insertions(+), 182 deletions(-) create mode 100644 render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java create mode 100644 render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java new file mode 100644 index 00000000..5bcf0ed5 --- /dev/null +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/PptxDeckAssembly.java @@ -0,0 +1,189 @@ +package com.demcha.compose.document.backend.fixed.pptx; + +import com.demcha.compose.document.backend.fixed.FixedLayoutRenderContext; +import com.demcha.compose.document.backend.fixed.SectionUnit; +import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; +import com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer; +import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.AnchorMarkerPayload; +import com.demcha.compose.document.layout.payloads.PdfSemanticFragmentPayload; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.font.FontFamilyDefinition; +import com.demcha.compose.font.FontName; +import org.apache.poi.ooxml.POIXMLProperties; +import org.apache.poi.sl.usermodel.PictureData; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFPictureData; +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.io.OutputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Deck-level assembly shared by {@link PptxFixedLayoutBackend}'s output + * paths: the multi-section render loop with combined-deck navigation, OPC + * metadata application, and full-slide raster page placement. Extracted so + * the backend class keeps only fragment dispatch, the single-document + * pipelines, and its builder. + * + * @author Artem Demchyshyn + */ +final class PptxDeckAssembly { + + private PptxDeckAssembly() { + // Utility class, no instantiation. + } + + /** + * Renders validated same-canvas sections into one deck written to + * {@code output} (not normalized — the caller owns the deterministic zip + * pass). Each section renders through its own chrome backend against its + * window of the combined deck; navigation, deck metadata (first section + * that declares it), and the invoked backend's deterministic pinning + * apply once at the end. + */ + static void renderSections(List sections, + LayoutCanvas canvas, + PptxFixedLayoutBackend invoked, + OutputStream output) throws Exception { + int totalPages = sections.stream() + .mapToInt(section -> Math.max(section.graph().totalPages(), 1)) + .sum(); + List unionFonts = unionCustomFonts(sections); + try (XMLSlideShow show = new XMLSlideShow(); + PdfMeasurementResources measurement = PdfMeasurementResources.open(unionFonts)) { + PptxRenderSession session = new PptxRenderSession( + show, canvas.width(), canvas.height(), totalPages); + PptxRenderEnvironment environment = new PptxRenderEnvironment( + show, session, 0, canvas.height(), measurement.fontLibrary(), unionFonts); + int pageOffset = 0; + for (SectionUnit section : sections) { + // Each section renders with its OWN backend's handlers and + // chrome, but records navigation against the combined deck + // through the environment's section window. + PptxFixedLayoutBackend chrome = (PptxFixedLayoutBackend) section.chrome(); + LayoutGraph graph = section.graph(); + int pages = Math.max(graph.totalPages(), 1); + environment.beginSection(pageOffset); + FixedLayoutRenderContext sectionContext = new FixedLayoutRenderContext( + section.canvas(), section.customFonts(), null, null); + if (chrome.rasterSlidesDpi > 0) { + placeRasterPages(environment, + chrome.pdfBackendWithVisibleChrome().renderToImages( + graph, sectionContext, chrome.rasterSlidesDpi, false, -1), + section.canvas()); + // The pixels bake this section's outgoing navigation, but + // its anchors and bookmarks still exist as destinations + // for the other sections' links and for slide names. + recordRasterSectionDestinations(graph, environment); + } else { + PptxChromeRenderer.applyWatermarkBehindContent( + environment, chrome.watermarkOptions, section.canvas(), pages); + chrome.renderGraph(graph, environment, sectionContext); + PptxChromeRenderer.applyWatermarkAboveContent( + environment, chrome.watermarkOptions, section.canvas(), pages); + PptxChromeRenderer.applyHeadersAndFooters( + environment, chrome.headerFooterOptions, section.canvas(), pages); + } + pageOffset += pages; + } + environment.beginSection(0); + PptxNavigationWriter.apply(environment); + // Metadata is deck-global: the first section that declares it wins, + // matching the PDF backend's combined-document rule. + sections.stream() + .map(section -> ((PptxFixedLayoutBackend) section.chrome()).metadataOptions) + .filter(Objects::nonNull) + .findFirst() + .ifPresent(metadata -> applyMetadata(show, metadata)); + if (invoked.deterministicTimestamp != null) { + PptxDeterminismWriter.pinCoreProperties(show, invoked.deterministicTimestamp); + } + show.write(output); + } + } + + /** + * Registers a raster section's anchor destinations and bookmarks so + * incoming cross-section links and slide names still resolve even though + * the section's content is one picture. + */ + private static void recordRasterSectionDestinations(LayoutGraph graph, + PptxRenderEnvironment environment) { + for (PlacedFragment fragment : graph.fragments()) { + if (fragment.payload() instanceof AnchorMarkerPayload anchor) { + environment.registerAnchor(fragment, anchor.anchor()); + } else if (fragment.payload() instanceof PdfSemanticFragmentPayload semanticPayload + && semanticPayload.bookmarkOptions() != null) { + environment.registerBookmark(fragment, semanticPayload.bookmarkOptions()); + } + } + } + + private static List unionCustomFonts(List sections) { + Map byName = new LinkedHashMap<>(); + for (SectionUnit section : sections) { + for (FontFamilyDefinition family : section.customFonts()) { + byName.putIfAbsent(family.name(), family); + } + } + return List.copyOf(byName.values()); + } + + /** + * Places one full-slide picture per rendered page onto the environment's + * active section window. + */ + static void placeRasterPages(PptxRenderEnvironment environment, + List pages, + LayoutCanvas canvas) throws IOException { + for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { + XSLFPictureData data = environment.slideShow().addPicture( + encodePng(pages.get(pageIndex)), PictureData.PictureType.PNG); + XSLFPictureShape picture = environment.slide(pageIndex).createPicture(data); + picture.setAnchor(new Rectangle2D.Double(0, 0, canvas.width(), canvas.height())); + } + } + + /** + * Writes the canonical metadata into the deck's OPC properties: title, + * author, subject, and keywords map onto the Dublin Core fields, the + * creating application onto the extended {@code Application} property. + * OPC has no producer field, so that value is not representable in .pptx. + */ + static void applyMetadata(XMLSlideShow show, DocumentMetadata metadata) { + POIXMLProperties.CoreProperties core = show.getProperties().getCoreProperties(); + if (metadata.getTitle() != null) { + core.setTitle(metadata.getTitle()); + } + if (metadata.getAuthor() != null) { + core.setCreator(metadata.getAuthor()); + } + if (metadata.getSubject() != null) { + core.setSubjectProperty(metadata.getSubject()); + } + if (metadata.getKeywords() != null) { + core.setKeywords(metadata.getKeywords()); + } + if (metadata.getCreator() != null) { + show.getProperties().getExtendedProperties().setApplication(metadata.getCreator()); + } + } + + private static byte[] encodePng(BufferedImage image) throws IOException { + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", buffer); + return buffer.toByteArray(); + } + } +} 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 5f812a65..940cd26d 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 @@ -37,12 +37,8 @@ import com.demcha.compose.document.output.DocumentHeaderFooterZone; import com.demcha.compose.document.output.DocumentMetadata; import com.demcha.compose.document.output.DocumentWatermark; -import com.demcha.compose.font.FontFamilyDefinition; -import com.demcha.compose.font.FontName; -import org.apache.poi.ooxml.POIXMLProperties; import org.apache.poi.sl.usermodel.PictureData; import org.apache.poi.xslf.usermodel.XMLSlideShow; -import org.apache.poi.xslf.usermodel.XSLFPictureData; import org.apache.poi.xslf.usermodel.XSLFPictureShape; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -102,21 +98,22 @@ public final class PptxFixedLayoutBackend implements FixedLayoutRenderer { private final Map, PptxFragmentRenderHandler> handlers; - private final DocumentMetadata metadataOptions; - private final DocumentWatermark watermarkOptions; - private final List headerFooterOptions; + // Package-private: the deck assembly reads each section's chrome directly. + final DocumentMetadata metadataOptions; + final DocumentWatermark watermarkOptions; + final List headerFooterOptions; /** * Timestamp all output clocks pin to when deterministic output is enabled; * {@code null} keeps POI's wall-clock stamps. */ - private final Instant deterministicTimestamp; + final Instant deterministicTimestamp; /** * Raster-slide resolution in DPI; {@code 0} keeps the default editable - * vector mode. + * vector mode. Package-private for the deck assembly. */ - private final int rasterSlidesDpi; + final int rasterSlidesDpi; /** * When {@code true} (the default), a clipped composite renders through the @@ -293,7 +290,15 @@ public void writeSections(List sections, OutputStream output) throw throw new IllegalArgumentException("A multi-section document needs at least one section."); } LayoutCanvas firstCanvas = sections.get(0).canvas(); - for (SectionUnit section : sections) { + for (int index = 0; index < sections.size(); index++) { + SectionUnit section = sections.get(index); + if (!(section.chrome() instanceof PptxFixedLayoutBackend)) { + throw new IllegalArgumentException( + "Section " + index + " carries a " + + section.chrome().getClass().getSimpleName() + + " chrome; every section of a PPTX multi-section render must use a " + + "PptxFixedLayoutBackend."); + } if (section.canvas().width() != firstCanvas.width() || section.canvas().height() != firstCanvas.height()) { throw new UnsupportedOperationException( @@ -303,102 +308,29 @@ public void writeSections(List sections, OutputStream output) throw + " pt. Render differing page sizes through the PDF backend."); } } + if (deterministicTimestamp == null) { + PptxDeckAssembly.renderSections(sections, firstCanvas, this, output); + return; + } try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - renderSectionsToBuffer(sections, firstCanvas, buffer); + PptxDeckAssembly.renderSections(sections, firstCanvas, this, buffer); writeFinishedDeck(buffer.toByteArray(), output); } } - private void renderSectionsToBuffer(List sections, - LayoutCanvas canvas, - ByteArrayOutputStream buffer) throws Exception { - int totalPages = sections.stream() - .mapToInt(section -> Math.max(section.graph().totalPages(), 1)) - .sum(); - List unionFonts = unionCustomFonts(sections); - try (XMLSlideShow show = new XMLSlideShow(); - PdfMeasurementResources measurement = PdfMeasurementResources.open(unionFonts)) { - PptxRenderSession session = new PptxRenderSession( - show, canvas.width(), canvas.height(), totalPages); - PptxRenderEnvironment environment = new PptxRenderEnvironment( - show, session, 0, canvas.height(), measurement.fontLibrary(), unionFonts); - int pageOffset = 0; - for (SectionUnit section : sections) { - // Each section renders with its OWN backend's handlers and - // chrome, but records navigation against the combined deck - // through the environment's section window. - PptxFixedLayoutBackend chrome = (PptxFixedLayoutBackend) section.chrome(); - LayoutGraph graph = section.graph(); - int pages = Math.max(graph.totalPages(), 1); - environment.beginSection(pageOffset); - FixedLayoutRenderContext sectionContext = new FixedLayoutRenderContext( - section.canvas(), section.customFonts(), null, null); - if (chrome.rasterSlidesDpi > 0) { - placeRasterPages(environment, - chrome.pdfBackendWithVisibleChrome().renderToImages( - graph, sectionContext, chrome.rasterSlidesDpi, false, -1), - section.canvas()); - // The pixels bake this section's outgoing navigation, but - // its anchors and bookmarks still exist as destinations - // for the other sections' links and for slide names. - recordRasterSectionDestinations(graph, environment); - } else { - PptxChromeRenderer.applyWatermarkBehindContent( - environment, chrome.watermarkOptions, section.canvas(), pages); - chrome.renderGraph(graph, environment, sectionContext); - PptxChromeRenderer.applyWatermarkAboveContent( - environment, chrome.watermarkOptions, section.canvas(), pages); - PptxChromeRenderer.applyHeadersAndFooters( - environment, chrome.headerFooterOptions, section.canvas(), pages); - } - pageOffset += pages; - } - environment.beginSection(0); - PptxNavigationWriter.apply(environment); - // Metadata is deck-global: the first section that declares it wins, - // matching the PDF backend's combined-document rule. - sections.stream() - .map(section -> ((PptxFixedLayoutBackend) section.chrome()).metadataOptions) - .filter(Objects::nonNull) - .findFirst() - .ifPresent(metadata -> applyMetadata(show, metadata)); - if (deterministicTimestamp != null) { - PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); - } - show.write(buffer); - } - } - - /** - * Registers a raster section's anchor destinations and bookmarks so - * incoming cross-section links and slide names still resolve even though - * the section's content is one picture. - */ - private static void recordRasterSectionDestinations(LayoutGraph graph, - PptxRenderEnvironment environment) { - for (PlacedFragment fragment : graph.fragments()) { - if (fragment.payload() instanceof AnchorMarkerPayload anchor) { - environment.registerAnchor(fragment, anchor.anchor()); - } else if (fragment.payload() instanceof PdfSemanticFragmentPayload semanticPayload - && semanticPayload.bookmarkOptions() != null) { - environment.registerBookmark(fragment, semanticPayload.bookmarkOptions()); - } - } - } - - private static List unionCustomFonts(List sections) { - Map byName = new LinkedHashMap<>(); - for (SectionUnit section : sections) { - for (FontFamilyDefinition family : section.customFonts()) { - byName.putIfAbsent(family.name(), family); - } - } - return List.copyOf(byName.values()); - } - private void renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception { + // Without determinism there is no normalization pass, so the deck + // streams straight to the caller instead of paying a full-size buffer. + if (deterministicTimestamp == null) { + if (rasterSlidesDpi > 0) { + renderRasterSlides(graph, context, output); + } else { + renderVectorSlides(graph, context, output); + } + return; + } try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { if (rasterSlidesDpi > 0) { renderRasterSlides(graph, context, buffer); @@ -441,7 +373,7 @@ private void renderVectorSlides(LayoutGraph graph, environment, headerFooterOptions, graph.canvas(), pageCount); PptxNavigationWriter.apply(environment); if (metadataOptions != null) { - applyMetadata(show, metadataOptions); + PptxDeckAssembly.applyMetadata(show, metadataOptions); } if (deterministicTimestamp != null) { PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); @@ -467,9 +399,9 @@ private void renderRasterSlides(LayoutGraph graph, show, graph.canvas().width(), graph.canvas().height(), graph.totalPages()); PptxRenderEnvironment environment = new PptxRenderEnvironment( show, session, 0, graph.canvas().height()); - placeRasterPages(environment, pages, graph.canvas()); + PptxDeckAssembly.placeRasterPages(environment, pages, graph.canvas()); if (metadataOptions != null) { - applyMetadata(show, metadataOptions); + PptxDeckAssembly.applyMetadata(show, metadataOptions); } if (deterministicTimestamp != null) { PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp); @@ -478,27 +410,12 @@ private void renderRasterSlides(LayoutGraph graph, } } - /** - * Places one full-slide picture per rendered page onto the environment's - * active section window. - */ - private static void placeRasterPages(PptxRenderEnvironment environment, - List pages, - LayoutCanvas canvas) throws IOException { - for (int pageIndex = 0; pageIndex < pages.size(); pageIndex++) { - XSLFPictureData data = environment.slideShow().addPicture( - encodePng(pages.get(pageIndex)), PictureData.PictureType.PNG); - XSLFPictureShape picture = environment.slide(pageIndex).createPicture(data); - picture.setAnchor(new Rectangle2D.Double(0, 0, canvas.width(), canvas.height())); - } - } - /** * Builds the PDF sub-backend raster renders go through, carrying this * backend's visible chrome so watermarks and headers/footers land in the * rasterized pixels. */ - private PdfFixedLayoutBackend pdfBackendWithVisibleChrome() { + PdfFixedLayoutBackend pdfBackendWithVisibleChrome() { if (watermarkOptions == null && headerFooterOptions.isEmpty()) { return new PdfFixedLayoutBackend(); } @@ -514,46 +431,14 @@ private PdfFixedLayoutBackend pdfBackendWithVisibleChrome() { return builder.build(); } - /** - * Writes the canonical metadata into the deck's OPC properties: title, - * author, subject, and keywords map onto the Dublin Core fields, the - * creating application onto the extended {@code Application} property. - * OPC has no producer field, so that value is not representable in .pptx. - */ - private static void applyMetadata(XMLSlideShow show, DocumentMetadata metadata) { - POIXMLProperties.CoreProperties core = show.getProperties().getCoreProperties(); - if (metadata.getTitle() != null) { - core.setTitle(metadata.getTitle()); - } - if (metadata.getAuthor() != null) { - core.setCreator(metadata.getAuthor()); - } - if (metadata.getSubject() != null) { - core.setSubjectProperty(metadata.getSubject()); - } - if (metadata.getKeywords() != null) { - core.setKeywords(metadata.getKeywords()); - } - if (metadata.getCreator() != null) { - show.getProperties().getExtendedProperties().setApplication(metadata.getCreator()); - } - } - - private static byte[] encodePng(BufferedImage image) throws IOException { - try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { - ImageIO.write(image, "png", buffer); - return buffer.toByteArray(); - } - } - /** * Paints every fragment in graph order, grouping contiguous table-row * fragments so every cell fill of a table lands beneath its borders and * text — the PDF backend's two-pass discipline. */ - private void renderGraph(LayoutGraph graph, - PptxRenderEnvironment environment, - FixedLayoutRenderContext context) throws Exception { + void renderGraph(LayoutGraph graph, + PptxRenderEnvironment environment, + FixedLayoutRenderContext context) throws Exception { PptxFragmentRenderHandler tableRowHandler = handlers.get(TableRowFragmentPayload.class); List fragments = graph.fragments(); 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 73726945..8cabd40c 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 @@ -373,7 +373,6 @@ private static boolean embedFontFamily(XMLSlideShow show, public void registerBookmark(PlacedFragment fragment, DocumentBookmarkOptions bookmarkOptions) { bookmarkRecords.add(new BookmarkRecord( bookmarkOptions.title(), - bookmarkOptions.level(), fragment.pageIndex() + pageIndexOffset)); } @@ -432,7 +431,14 @@ List fragmentLinks() { return List.copyOf(fragmentLinks); } - record BookmarkRecord(String title, int level, int pageIndex) { + /** + * A recorded bookmark awaiting slide-name emission. PPTX has no outline + * hierarchy, so the engine bookmark's nesting level is not carried. + * + * @param title bookmark title + * @param pageIndex zero-based slide index the bookmark resolved to + */ + record BookmarkRecord(String title, int pageIndex) { } /** diff --git a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java index 7881ce70..12757117 100644 --- a/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java +++ b/render-pptx/src/main/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxChromeRenderer.java @@ -112,11 +112,32 @@ private static void applyWatermark(PptxRenderEnvironment environment, if (layer != targetLayer) { return; } - for (int local = 0; local < pageCount; local++) { - if (watermark.isTextBased()) { - renderTextWatermark(environment, watermark, canvas, local); - } else if (watermark.isImageBased()) { - renderImageWatermark(environment, watermark, canvas, local); + // Measurement, sanitization, and image decoding are page-invariant: + // prepare once, place per page. + if (watermark.isTextBased()) { + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + float fontSize = watermark.getFontSize(); + String text = GlyphFallbackLogger.sanitize(font, watermark.getText()); + float textWidth = font.getStringWidth(text) / 1000f * fontSize; + Color color = withOpacity( + watermark.getColor() == null ? Color.LIGHT_GRAY : watermark.getColor().color(), + watermark.getOpacity()); + TextStyle style = new TextStyle( + FontName.HELVETICA_BOLD, fontSize, TextDecoration.DEFAULT, color); + for (int local = 0; local < pageCount; local++) { + renderTextWatermark(environment, watermark, canvas, local, text, style, textWidth); + } + } else if (watermark.isImageBased()) { + byte[] bytes = watermark.getImagePath() != null + ? Files.readAllBytes(watermark.getImagePath()) + : watermark.getImageBytes(); + BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(bytes)); + if (decoded == null) { + throw new IOException("The watermark image could not be decoded."); + } + for (int local = 0; local < pageCount; local++) { + renderImageWatermark(environment, watermark, canvas, local, + bytes, decoded.getWidth(), decoded.getHeight()); } } } @@ -124,16 +145,11 @@ private static void applyWatermark(PptxRenderEnvironment environment, private static void renderTextWatermark(PptxRenderEnvironment environment, DocumentWatermark watermark, LayoutCanvas canvas, - int localPageIndex) throws IOException { - PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + int localPageIndex, + String text, + TextStyle style, + float textWidth) { float fontSize = watermark.getFontSize(); - String text = GlyphFallbackLogger.sanitize(font, watermark.getText()); - float textWidth = font.getStringWidth(text) / 1000f * fontSize; - Color color = withOpacity( - watermark.getColor() == null ? Color.LIGHT_GRAY : watermark.getColor().color(), - watermark.getOpacity()); - TextStyle style = new TextStyle(FontName.HELVETICA_BOLD, fontSize, TextDecoration.DEFAULT, color); - DocumentWatermarkPosition position = watermark.getPosition() == null ? DocumentWatermarkPosition.CENTER : watermark.getPosition(); @@ -212,17 +228,10 @@ private static void placeWatermarkText(PptxRenderEnvironment environment, private static void renderImageWatermark(PptxRenderEnvironment environment, DocumentWatermark watermark, LayoutCanvas canvas, - int localPageIndex) throws IOException { - byte[] bytes = watermark.getImagePath() != null - ? Files.readAllBytes(watermark.getImagePath()) - : watermark.getImageBytes(); - BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(bytes)); - if (decoded == null) { - throw new IOException("The watermark image could not be decoded."); - } - float imageWidth = decoded.getWidth(); - float imageHeight = decoded.getHeight(); - + int localPageIndex, + byte[] bytes, + float imageWidth, + float imageHeight) { DocumentWatermarkPosition position = watermark.getPosition() == null ? DocumentWatermarkPosition.CENTER : watermark.getPosition(); @@ -321,24 +330,25 @@ public static void applyHeadersAndFooters(PptxRenderEnvironment environment, List configs = entries.stream() .map(PptxChromeRenderer::toEngine) .toList(); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); for (int local = 0; local < pageCount; local++) { for (HeaderFooterConfig config : configs) { if (!config.appliesTo(local + 1)) { continue; } - renderZone(environment, config, canvas, local, pageCount, marginLeft, marginRight); + renderZone(environment, config, font, canvas, local, pageCount, marginLeft, marginRight); } } } private static void renderZone(PptxRenderEnvironment environment, HeaderFooterConfig config, + PDFont font, LayoutCanvas canvas, int localPageIndex, int pageCount, float marginLeft, float marginRight) throws IOException { - PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); float fontSize = config.getFontSize(); float pageWidth = (float) canvas.width(); float usableWidth = pageWidth - marginLeft - marginRight; diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java new file mode 100644 index 00000000..200ca602 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxBackendContractTest.java @@ -0,0 +1,81 @@ +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.SectionUnit; +import com.demcha.compose.document.backend.fixed.pdf.PdfFixedLayoutBackend; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Negative contracts of the backend's document-level surface: null and empty + * inputs fail with typed, actionable diagnostics, and the documented + * render-to-images gap points at the PDF backend. + */ +class PptxBackendContractTest { + + @Test + void builderRejectsNullChromeEntriesAndTimestamp() { + PptxFixedLayoutBackend.Builder builder = PptxFixedLayoutBackend.builder(); + assertThatThrownBy(() -> builder.header(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("options"); + assertThatThrownBy(() -> builder.footer(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("options"); + assertThatThrownBy(() -> builder.deterministic((Instant) null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("timestamp"); + } + + @Test + void renderToImagesPointsAtThePdfBackend() throws Exception { + try (DocumentSession session = composeOnePage()) { + LayoutGraph graph = session.render(new GraphCapturingBackend()); + assertThatThrownBy(() -> new PptxFixedLayoutBackend() + .renderToImages(graph, null, 144, false, -1)) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("PDF backend"); + } + } + + @Test + void emptySectionsAreRejected() { + PptxFixedLayoutBackend backend = new PptxFixedLayoutBackend(); + assertThatThrownBy(() -> backend.writeSections(List.of(), new ByteArrayOutputStream())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one section"); + } + + @Test + void aForeignSectionChromeIsRejectedWithTheSectionIndex() throws Exception { + try (DocumentSession session = composeOnePage()) { + LayoutGraph graph = session.render(new GraphCapturingBackend()); + List sections = List.of(new SectionUnit( + graph, graph.canvas(), List.of(), new PdfFixedLayoutBackend())); + assertThatThrownBy(() -> new PptxFixedLayoutBackend() + .writeSections(sections, new ByteArrayOutputStream())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Section 0") + .hasMessageContaining("PdfFixedLayoutBackend"); + } + } + + private static DocumentSession composeOnePage() { + DocumentSession session = GraphCompose.document() + .pageSize(300, 200) + .margin(DocumentInsets.of(20)) + .create(); + session.add(session.dsl().shape().name("Card").size(100, 30) + .fillColor(DocumentColor.GRAY).build()); + return session; + } +}