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/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/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..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
@@ -17,21 +17,28 @@
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 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;
@@ -44,6 +51,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 +72,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,13 +94,26 @@ 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;
+ // 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.
+ */
+ 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
@@ -113,6 +139,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 +242,119 @@ 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 (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(
+ "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.");
+ }
+ }
+ if (deterministicTimestamp == null) {
+ PptxDeckAssembly.renderSections(sections, firstCanvas, this, output);
+ return;
+ }
+ try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
+ PptxDeckAssembly.renderSections(sections, firstCanvas, this, buffer);
+ writeFinishedDeck(buffer.toByteArray(), output);
+ }
}
private void renderToOutput(LayoutGraph graph,
FixedLayoutRenderContext context,
OutputStream output) throws Exception {
- if (rasterSlidesDpi > 0) {
- renderRasterSlides(graph, context, output);
+ // 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);
+ } 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 +363,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) {
+ PptxDeckAssembly.applyMetadata(show, metadataOptions);
+ }
+ if (deterministicTimestamp != null) {
+ PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp);
+ }
show.write(output);
}
}
@@ -252,32 +385,50 @@ 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());
+ PptxDeckAssembly.placeRasterPages(environment, pages, graph.canvas());
+ if (metadataOptions != null) {
+ PptxDeckAssembly.applyMetadata(show, metadataOptions);
+ }
+ if (deterministicTimestamp != null) {
+ PptxDeterminismWriter.pinCoreProperties(show, deterministicTimestamp);
}
show.write(output);
}
}
- private static byte[] encodePng(BufferedImage image) throws IOException {
- try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
- ImageIO.write(image, "png", buffer);
- return buffer.toByteArray();
+ /**
+ * 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.
+ */
+ 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();
}
/**
@@ -285,9 +436,9 @@ private static byte[] encodePng(BufferedImage image) throws IOException {
* fragments so every cell fill of a table lands beneath its borders and
* text — the PDF backend's two-pass discipline.
*/
- private void renderGraph(LayoutGraph graph,
- PptxRenderEnvironment environment,
- FixedLayoutRenderContext context) throws Exception {
+ void renderGraph(LayoutGraph graph,
+ PptxRenderEnvironment environment,
+ FixedLayoutRenderContext context) throws Exception {
PptxFragmentRenderHandler> tableRowHandler =
handlers.get(TableRowFragmentPayload.class);
List fragments = graph.fragments();
@@ -390,7 +541,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 +575,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 +584,23 @@ private void renderFragment(PlacedFragment fragment, PptxRenderEnvironment envir
Object payload = fragment.payload();
PptxFragmentRenderHandler