diff --git a/CHANGELOG.md b/CHANGELOG.md index e433f9fc..a8e9c6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,16 @@ follow semantic versioning; release dates are ISO 8601. byte-identical decks across runs by pinning the OPC created/modified properties and normalizing every zip entry timestamp (zone-independent). +- `DocumentSession.toPptxBytes()` / `writePptx(OutputStream)` / + `buildPptx()` / `buildPptx(Path)` — the PPTX counterparts of the PDF + convenience trio. The backend resolves through the format-keyed provider + (`BackendProviders.fixedLayout("pptx")`), so the core stays free of a PPTX + dependency: with `graph-compose-render-pptx` on the classpath the session's + chrome (metadata, watermark, headers/footers) applies exactly as in the PDF + paths, and without it the render fails with a `MissingBackendException` + naming the artifact to add. Stream and default-output-file contracts match + the PDF trio. + ### Fixed - `DocumentSession.toImages` / `toImage` rasterized documents that use binary @@ -142,6 +152,12 @@ follow semantic versioning; release dates are ISO 8601. 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. +- Session-level PPTX convenience tests pin chrome flow-through (metadata and + footer tokens land in the deck), slide-count identity with the resolved + layout graph, the caller-owned-stream contract, file output, and the + default-output-file and empty-session failure modes; the backend-free + missing-format diagnostics were already pinned by the core's + `MissingBackendContractTest`. ## v2.0.0 — 2026-07-13 diff --git a/core/src/main/java/com/demcha/compose/GraphCompose.java b/core/src/main/java/com/demcha/compose/GraphCompose.java index ce617e47..ebb68d74 100644 --- a/core/src/main/java/com/demcha/compose/GraphCompose.java +++ b/core/src/main/java/com/demcha/compose/GraphCompose.java @@ -85,9 +85,9 @@ public static DocumentBuilder document() { /** * Starts the canonical semantic document composition flow with a default output target - * used by {@link DocumentSession#buildPdf()}. + * used by {@link DocumentSession#buildPdf()} and {@link DocumentSession#buildPptx()}. * - * @param outputFile default PDF output path for {@link DocumentSession#buildPdf()} + * @param outputFile default output path for the no-arg build methods * @return builder for creating a semantic document session */ public static DocumentBuilder document(Path outputFile) { diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java b/core/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java index 688ff3c6..7f337532 100644 --- a/core/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java +++ b/core/src/main/java/com/demcha/compose/document/api/DocumentChromeOptions.java @@ -97,15 +97,18 @@ DocumentOutputOptions snapshot() { } /** - * Resolves and configures the fixed-layout backend for the session's - * convenience output methods, translating the attached chrome through the - * registered + * Resolves and configures the fixed-layout backend of the requested format + * for the session's convenience output methods, translating the attached + * chrome through the registered * {@link com.demcha.compose.document.backend.fixed.FixedLayoutBackendProvider}. * - * @param debug debug overlay options; never {@code null} + * @param format backend format key, e.g. {@code "pdf"} or {@code "pptx"} + * @param debug debug overlay options; never {@code null} * @return a configured renderer + * @throws com.demcha.compose.document.exceptions.MissingBackendException + * if no provider for the format is on the classpath */ - FixedLayoutRenderer toConveniencePdfBackend(DocumentDebugOptions debug) { - return BackendProviders.fixedLayout("pdf").create(snapshot(), debug); + FixedLayoutRenderer toConvenienceBackend(String format, DocumentDebugOptions debug) { + return BackendProviders.fixedLayout(format).create(snapshot(), debug); } } diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java index 9b10f298..97cb083f 100644 --- a/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java +++ b/core/src/main/java/com/demcha/compose/document/api/DocumentRenderingFacade.java @@ -37,6 +37,10 @@ final class DocumentRenderingFacade { private static final Logger LIFECYCLE_LOG = LoggerFactory.getLogger("com.demcha.compose.document.lifecycle"); + /** Provider format keys for the fixed-layout convenience paths. */ + private static final String PDF = "pdf"; + private static final String PPTX = "pptx"; + private final Context context; DocumentRenderingFacade(Context context) { @@ -95,16 +99,41 @@ R export(SemanticBackend backend, Path outputFile) throws Exception { } byte[] toPdfBytes() throws Exception { + return renderBytes(PDF); + } + + void writePdf(OutputStream output) throws Exception { + writeFixedLayout(PDF, output); + } + + void buildPdf(Path outputFile) throws Exception { + buildFixedLayout(PDF, outputFile); + } + + byte[] toPptxBytes() throws Exception { + return renderBytes(PPTX); + } + + void writePptx(OutputStream output) throws Exception { + writeFixedLayout(PPTX, output); + } + + void buildPptx(Path outputFile) throws Exception { + buildFixedLayout(PPTX, outputFile); + } + + private byte[] renderBytes(String format) throws Exception { context.ensureOpen(); long startNanos = System.nanoTime(); - LIFECYCLE_LOG.debug("document.pdf.bytes.start sessionId={} revision={} roots={}", - context.sessionId(), context.revision(), context.rootCount()); + LIFECYCLE_LOG.debug("document.{}.bytes.start sessionId={} revision={} roots={}", + format, context.sessionId(), context.revision(), context.rootCount()); try { ByteArrayOutputStream output = new ByteArrayOutputStream(); - writePdf(output); + writeFixedLayout(format, output); byte[] bytes = output.toByteArray(); LIFECYCLE_LOG.debug( - "document.pdf.bytes.end sessionId={} revision={} byteCount={} durationMs={}", + "document.{}.bytes.end sessionId={} revision={} byteCount={} durationMs={}", + format, context.sessionId(), context.revision(), bytes.length, @@ -112,7 +141,8 @@ byte[] toPdfBytes() throws Exception { return bytes; } catch (Exception ex) { LIFECYCLE_LOG.error( - "document.pdf.bytes.failed sessionId={} revision={} errorType={}", + "document.{}.bytes.failed sessionId={} revision={} errorType={}", + format, context.sessionId(), context.revision(), ex.getClass().getSimpleName(), @@ -121,27 +151,29 @@ byte[] toPdfBytes() throws Exception { } } - void writePdf(OutputStream output) throws Exception { + private void writeFixedLayout(String format, OutputStream output) throws Exception { context.ensureOpen(); context.ensureRenderable(); OutputStream target = Objects.requireNonNull(output, "output"); long startNanos = System.nanoTime(); - LIFECYCLE_LOG.debug("document.pdf.stream.start sessionId={} revision={} roots={}", - context.sessionId(), context.revision(), context.rootCount()); + LIFECYCLE_LOG.debug("document.{}.stream.start sessionId={} revision={} roots={}", + format, context.sessionId(), context.revision(), context.rootCount()); try { - context.conveniencePdfBackend().write(context.layoutGraph(), new FixedLayoutRenderContext( + context.convenienceBackend(format).write(context.layoutGraph(), new FixedLayoutRenderContext( context.canvas(), context.customFontFamilies(), null, target)); LIFECYCLE_LOG.debug( - "document.pdf.stream.end sessionId={} revision={} durationMs={}", + "document.{}.stream.end sessionId={} revision={} durationMs={}", + format, context.sessionId(), context.revision(), elapsedMillis(startNanos)); } catch (Exception ex) { LIFECYCLE_LOG.error( - "document.pdf.stream.failed sessionId={} revision={} errorType={}", + "document.{}.stream.failed sessionId={} revision={} errorType={}", + format, context.sessionId(), context.revision(), ex.getClass().getSimpleName(), @@ -150,23 +182,25 @@ void writePdf(OutputStream output) throws Exception { } } - void buildPdf(Path outputFile) throws Exception { + private void buildFixedLayout(String format, Path outputFile) throws Exception { context.ensureOpen(); context.ensureRenderable(); Path target = Objects.requireNonNull(outputFile, "outputFile"); long startNanos = System.nanoTime(); - LIFECYCLE_LOG.debug("document.pdf.build.start sessionId={} revision={} roots={}", - context.sessionId(), context.revision(), context.rootCount()); + LIFECYCLE_LOG.debug("document.{}.build.start sessionId={} revision={} roots={}", + format, context.sessionId(), context.revision(), context.rootCount()); try (OutputStream output = Files.newOutputStream(target)) { - writePdf(output); + writeFixedLayout(format, output); LIFECYCLE_LOG.debug( - "document.pdf.build.end sessionId={} revision={} durationMs={}", + "document.{}.build.end sessionId={} revision={} durationMs={}", + format, context.sessionId(), context.revision(), elapsedMillis(startNanos)); } catch (Exception ex) { LIFECYCLE_LOG.error( - "document.pdf.build.failed sessionId={} revision={} errorType={}", + "document.{}.build.failed sessionId={} revision={} errorType={}", + format, context.sessionId(), context.revision(), ex.getClass().getSimpleName(), @@ -182,7 +216,7 @@ List renderImages(int dpi, boolean transparent, int pageIndex) th LIFECYCLE_LOG.debug("document.images.start sessionId={} revision={} roots={} dpi={} transparent={} pageIndex={}", context.sessionId(), context.revision(), context.rootCount(), dpi, transparent, pageIndex); try { - List images = context.conveniencePdfBackend().renderToImages( + List images = context.convenienceBackend(PDF).renderToImages( context.layoutGraph(), new FixedLayoutRenderContext(context.canvas(), context.customFontFamilies(), null, null), dpi, @@ -224,6 +258,10 @@ interface Context { DocumentOutputOptions outputOptions(); - FixedLayoutRenderer conveniencePdfBackend(); + /** + * Resolves the session-chrome-configured fixed-layout backend for the + * given format key ({@code "pdf"}, {@code "pptx"}, ...). + */ + FixedLayoutRenderer convenienceBackend(String format); } } diff --git a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java index b8146226..b60c7616 100644 --- a/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java +++ b/core/src/main/java/com/demcha/compose/document/api/DocumentSession.java @@ -53,7 +53,8 @@ * or by adding low-level {@link DocumentNode}s directly *
  • inspect {@link #layoutGraph()} / {@link #layoutSnapshot()} as needed
  • *
  • render with {@link #writePdf(OutputStream)}, {@link #toPdfBytes()}, {@link #buildPdf()}, - * or a custom backend
  • + * their PPTX counterparts ({@link #writePptx(OutputStream)}, {@link #toPptxBytes()}, + * {@link #buildPptx()}), or a custom backend * * *

    Thread-safety: this type is mutable and not thread-safe.

    @@ -88,7 +89,7 @@ public final class DocumentSession implements AutoCloseable { /** * Creates a canonical document session. * - * @param defaultOutputFile optional default PDF output path + * @param defaultOutputFile optional default output path for the no-arg build methods * @param pageSize physical page size * @param margin page margin * @param customFontFamilies document-local font families @@ -123,13 +124,14 @@ public DocumentSession(Path defaultOutputFile, } /** - * Runs a PDF convenience body and unifies the cross-cutting checked-exception - * wrapping: any underlying {@link Exception} is rewrapped as - * {@link DocumentRenderingException} with the supplied {@code action} fragment. - * {@link RuntimeException}s pass through unchanged so existing callers that - * already catch them keep their semantics. + * Runs a fixed-layout convenience body (PDF or PPTX) and unifies the + * cross-cutting checked-exception wrapping: any underlying + * {@link Exception} is rewrapped as {@link DocumentRenderingException} + * with the supplied {@code action} fragment. {@link RuntimeException}s + * pass through unchanged so existing callers that already catch them keep + * their semantics. */ - private static R wrapPdfRendering(String action, PdfRenderingBody body) throws DocumentRenderingException { + private static R wrapRendering(String action, RenderingBody body) throws DocumentRenderingException { try { return body.run(); } catch (RuntimeException e) { @@ -140,7 +142,7 @@ private static R wrapPdfRendering(String action, PdfRenderingBody body) t } /** - * Image-rendering analogue of {@link #wrapPdfRendering}: rewraps any + * Image-rendering analogue of {@link #wrapRendering}: rewraps any * underlying checked {@link Exception} as {@link DocumentRenderingException} * while letting {@link RuntimeException}s (e.g. argument/state validation) * propagate unchanged. @@ -890,7 +892,7 @@ public R export(SemanticBackend backend, Path outputFile) throws Exceptio * @throws DocumentRenderingException if PDF rendering fails */ public byte[] toPdfBytes() throws DocumentRenderingException { - return wrapPdfRendering("render PDF bytes", renderingFacade::toPdfBytes); + return wrapRendering("render PDF bytes", renderingFacade::toPdfBytes); } /** @@ -904,7 +906,7 @@ public byte[] toPdfBytes() throws DocumentRenderingException { * @throws DocumentRenderingException if PDF rendering fails */ public void writePdf(OutputStream output) throws DocumentRenderingException { - wrapPdfRendering("write PDF to stream", () -> { + wrapRendering("write PDF to stream", () -> { renderingFacade.writePdf(output); return null; }); @@ -921,7 +923,7 @@ public void buildPdf() throws DocumentRenderingException { if (defaultOutputFile == null) { throw new IllegalStateException("No default output file was configured for this document session."); } - wrapPdfRendering("build PDF at '" + defaultOutputFile + "'", () -> { + wrapRendering("build PDF at '" + defaultOutputFile + "'", () -> { renderingFacade.buildPdf(defaultOutputFile); return null; }); @@ -934,12 +936,93 @@ public void buildPdf() throws DocumentRenderingException { * @throws DocumentRenderingException if PDF rendering fails */ public void buildPdf(Path outputFile) throws DocumentRenderingException { - wrapPdfRendering("build PDF at '" + outputFile + "'", () -> { + wrapRendering("build PDF at '" + outputFile + "'", () -> { renderingFacade.buildPdf(outputFile); return null; }); } + /** + * Renders the current session through the fixed-layout PPTX backend and + * returns the .pptx bytes. One resolved page becomes one identically-sized + * slide with the exact same geometry the PDF backend paints; the session's + * chrome (metadata, watermark, headers/footers) applies the same way as in + * {@link #toPdfBytes()}, with options PPTX cannot honour skipped with a + * one-time warning. + * + *

    Requires {@code io.github.demchaav:graph-compose-render-pptx} on the + * classpath; without it the render fails with a + * {@link com.demcha.compose.document.exceptions.MissingBackendException} + * naming the artifact to add. The returned byte array is not cached by the + * session, so server code that can stream should prefer + * {@link #writePptx(OutputStream)}.

    + * + * @return rendered .pptx bytes + * @throws DocumentRenderingException if PPTX rendering fails + * @since 2.1.0 + */ + public byte[] toPptxBytes() throws DocumentRenderingException { + return wrapRendering("render PPTX bytes", renderingFacade::toPptxBytes); + } + + /** + * Streams the current session through the fixed-layout PPTX backend. See + * {@link #toPptxBytes()} for the geometry and classpath contract. + * + *

    The caller owns the supplied stream: GraphCompose writes the deck + * bytes but does not close the stream.

    + * + * @param output destination stream that receives the rendered .pptx bytes + * @throws DocumentRenderingException if PPTX rendering fails + * @since 2.1.0 + */ + public void writePptx(OutputStream output) throws DocumentRenderingException { + wrapRendering("write PPTX to stream", () -> { + renderingFacade.writePptx(output); + return null; + }); + } + + /** + * Builds the current document as a .pptx deck into the default output file + * configured on the builder. See {@link #toPptxBytes()} for the geometry + * and classpath contract. + * + *

    The default file is shared with {@link #buildPdf()}: the session has + * one configured output path, and this method writes deck bytes to it + * as-is — pass an explicit {@code .pptx} path to + * {@link #buildPptx(Path)} when the default is named for PDF output.

    + * + * @throws IllegalStateException if no default output file was configured + * @throws DocumentRenderingException if PPTX rendering fails + * @since 2.1.0 + */ + public void buildPptx() throws DocumentRenderingException { + ensureOpen(); + if (defaultOutputFile == null) { + throw new IllegalStateException("No default output file was configured for this document session."); + } + wrapRendering("build PPTX at '" + defaultOutputFile + "'", () -> { + renderingFacade.buildPptx(defaultOutputFile); + return null; + }); + } + + /** + * Builds the current document as a .pptx deck into the supplied output + * file. See {@link #toPptxBytes()} for the geometry and classpath contract. + * + * @param outputFile destination .pptx path + * @throws DocumentRenderingException if PPTX rendering fails + * @since 2.1.0 + */ + public void buildPptx(Path outputFile) throws DocumentRenderingException { + wrapRendering("build PPTX at '" + outputFile + "'", () -> { + renderingFacade.buildPptx(outputFile); + return null; + }); + } + /** * Renders every page of the current document to a raster image at the given * resolution, with an opaque white background. @@ -1080,7 +1163,9 @@ private void ensureOpen() { private void ensureRenderable() { if (roots.isEmpty()) { throw new IllegalStateException( - "Cannot render an empty document. Add at least one root before calling writePdf/toPdfBytes/buildPdf/toImages/toImage."); + "Cannot render an empty document. Add at least one root before calling a " + + "render or output method (writePdf/toPdfBytes/buildPdf, " + + "writePptx/toPptxBytes/buildPptx, toImages/toImage)."); } } @@ -1105,7 +1190,7 @@ private long elapsedMillis(long startNanos) { } @FunctionalInterface - private interface PdfRenderingBody { + private interface RenderingBody { R run() throws Exception; } @@ -1154,7 +1239,7 @@ SectionUnit toSectionRenderUnit() { layoutGraph(), canvas, List.copyOf(customFontFamilies), - chromeOptions.toConveniencePdfBackend(debug)); + chromeOptions.toConvenienceBackend("pdf", debug)); } /** @@ -1213,8 +1298,8 @@ public DocumentOutputOptions outputOptions() { } @Override - public FixedLayoutRenderer conveniencePdfBackend() { - return chromeOptions.toConveniencePdfBackend(debug); + public FixedLayoutRenderer convenienceBackend(String format) { + return chromeOptions.toConvenienceBackend(format, debug); } } diff --git a/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java b/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java index 9ebbf9be..b56d2e18 100644 --- a/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java +++ b/core/src/main/java/com/demcha/compose/document/exceptions/MissingBackendException.java @@ -6,10 +6,12 @@ * *

    Since GraphCompose 2.0 the engine and document model ship without a bundled * backend: rendering and text measurement are provided by a separate artifact - * (for example {@code io.github.demchaav:graph-compose-render-pdf}) discovered at + * (for example {@code io.github.demchaav:graph-compose-render-pdf} or + * {@code io.github.demchaav:graph-compose-render-pptx}) discovered at * runtime through {@link java.util.ServiceLoader}. Calling a convenience output - * method — {@code toPdfBytes()}, {@code buildPdf()}, {@code toImages()} — or - * requesting {@code layoutSnapshot()} without such an artifact on the classpath + * method — {@code toPdfBytes()}, {@code buildPdf()}, {@code toImages()}, + * {@code toPptxBytes()}, {@code buildPptx()} — or requesting + * {@code layoutSnapshot()} without the matching artifact on the classpath * fails with this exception. The message names the artifact to add.

    * *

    This is an unchecked exception: a missing backend is a build/classpath diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 87db4f54..ca9365b8 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -94,7 +94,7 @@ honour an option ignores it (documented contract). | 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(...))` | +| `DocumentSession` convenience methods | ✅ `buildPdf` / `writePdf` / `toPdfBytes` / `toImages` | ✅ `buildPptx` / `writePptx` / `toPptxBytes` (resolved via `BackendProviders.fixedLayout("pptx")`; session chrome applies; fails with `MissingBackendException` naming `graph-compose-render-pptx` when the backend is absent) | via `session.export(new DocxSemanticBackend(...))` | ## Fidelity notes (PPTX) diff --git a/docs/architecture/lifecycle.md b/docs/architecture/lifecycle.md index b864ed60..11d77ec8 100644 --- a/docs/architecture/lifecycle.md +++ b/docs/architecture/lifecycle.md @@ -19,8 +19,8 @@ flowchart TD Authoring --> Nodes["Semantic document nodes"] Nodes --> Layout["Layout graph"] Layout --> Snapshot["layoutSnapshot()"] - Layout --> Render["PDF fixed-layout render"] - Render --> Output["writePdf(OutputStream), buildPdf(), or toPdfBytes()"] + Layout --> Render["Fixed-layout render (PDF or PPTX)"] + Render --> Output["writePdf/buildPdf/toPdfBytes or writePptx/buildPptx/toPptxBytes"] Session --> Close["close()"] ``` diff --git a/docs/capabilities.md b/docs/capabilities.md index 5a25b012..5b5ddd10 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -53,6 +53,7 @@ tracks what is `Partial` or `Planned`. | Write a PDF file | `buildPdf()`, `buildPdf(Path)` | Stable | [Getting started](getting-started.md) | | Stream to a caller-owned stream | `writePdf(OutputStream)` | Stable | [Streaming](recipes/streaming.md) | | In-memory bytes | `toPdfBytes()` | Stable | [Getting started](getting-started.md) | +| Geometry-identical PowerPoint deck | `buildPptx()`, `buildPptx(Path)`, `writePptx(OutputStream)`, `toPptxBytes()` — needs `graph-compose-render-pptx` on the classpath | Stable | [Backend capability matrix](architecture/backend-capability-matrix.md) | | Editable Word (semantic) | `export(new DocxSemanticBackend())` | Stable (semantic, not PDF parity) | [Troubleshooting](troubleshooting.md) | | PDF chrome (metadata / watermark / header / footer / protection) | `metadata(...)`, `watermark(...)`, `header(...)`, `footer(...)`, `protect(...)` | Stable | [Getting started](getting-started.md) | | Layout snapshot regression | `LayoutSnapshotAssertions.assertMatches(...)` | Stable | [Layout snapshot testing](operations/layout-snapshot-testing.md) | diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java new file mode 100644 index 00000000..71fab6c0 --- /dev/null +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/PptxSessionConvenienceTest.java @@ -0,0 +1,152 @@ +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.layout.LayoutGraph; +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.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import org.apache.poi.xslf.usermodel.XMLSlideShow; +import org.apache.poi.xslf.usermodel.XSLFTextBox; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The session's PPTX convenience trio: {@code toPptxBytes} / {@code writePptx} + * / {@code buildPptx} resolve the backend through the format-keyed provider, + * carry the session's chrome, and keep the PDF trio's stream and default-file + * contracts. + */ +class PptxSessionConvenienceTest { + + private static DocumentSession composeWithChrome() { + DocumentSession session = GraphCompose.document() + .pageSize(400, 300) + .margin(DocumentInsets.of(24)) + .create(); + session.metadata(DocumentMetadata.builder().title("Session Deck").build()); + session.footer(DocumentHeaderFooter.builder().centerText("{page} of {pages}").build()); + session.add(session.dsl().shape().name("Card").size(140, 40) + .fillColor(DocumentColor.ROYAL_BLUE) + .build()); + session.add(new PageBreakNode("Break", DocumentInsets.zero())); + session.add(session.dsl().shape().name("SecondPage").size(120, 40) + .fillColor(DocumentColor.ORANGE) + .build()); + return session; + } + + @Test + void toPptxBytesCarriesSessionChromeAndSharedGeometry() throws Exception { + try (DocumentSession session = composeWithChrome()) { + byte[] pptx = session.toPptxBytes(); + LayoutGraph graph = session.render(new GraphCapturingBackend()); + assertThat(graph.totalPages()).isEqualTo(2); + try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) { + assertThat(show.getSlides()).hasSize(graph.totalPages()); + assertThat(show.getProperties().getCoreProperties().getTitle()) + .isEqualTo("Session Deck"); + assertThat(show.getSlides().get(0).getShapes().stream() + .filter(shape -> "GraphCompose Footer".equals(shape.getShapeName())) + .map(shape -> ((XSLFTextBox) shape).getText())) + .containsExactly("1 of 2"); + assertThat(show.getSlides().get(1).getShapes().stream() + .filter(shape -> "GraphCompose Footer".equals(shape.getShapeName())) + .map(shape -> ((XSLFTextBox) shape).getText())) + .containsExactly("2 of 2"); + } + } + } + + @Test + void writePptxLeavesTheCallerOwnedStreamOpen() throws Exception { + class ProbeStream extends ByteArrayOutputStream { + boolean closed; + + @Override + public void close() { + closed = true; + } + } + try (DocumentSession session = composeWithChrome()) { + ProbeStream output = new ProbeStream(); + session.writePptx(output); + assertThat(output.closed).as("caller-owned stream must stay open").isFalse(); + try (XMLSlideShow show = new XMLSlideShow( + new ByteArrayInputStream(output.toByteArray()))) { + assertThat(show.getSlides()).hasSize(2); + } + } + } + + @Test + void buildPptxWritesTheSuppliedFile(@TempDir Path tempDir) throws Exception { + Path target = tempDir.resolve("session-deck.pptx"); + try (DocumentSession session = composeWithChrome()) { + session.buildPptx(target); + assertThat(Files.size(target)).isPositive(); + try (XMLSlideShow show = new XMLSlideShow(Files.newInputStream(target))) { + assertThat(show.getSlides()).hasSize(2); + } + } + } + + @Test + void buildPptxUsesTheConfiguredDefaultOutputFile(@TempDir Path tempDir) throws Exception { + Path target = tempDir.resolve("default-deck.pptx"); + try (DocumentSession session = GraphCompose.document(target) + .pageSize(400, 300) + .margin(DocumentInsets.of(24)) + .create()) { + session.add(session.dsl().shape().name("Card").size(140, 40) + .fillColor(DocumentColor.ROYAL_BLUE) + .build()); + session.buildPptx(); + try (XMLSlideShow show = new XMLSlideShow(Files.newInputStream(target))) { + assertThat(show.getSlides()).hasSize(1); + } + } + } + + @Test + void buildPptxWithoutADefaultOutputFileFails() { + try (DocumentSession session = composeWithChrome()) { + assertThatThrownBy(session::buildPptx) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("default output file"); + } + } + + @Test + void anEmptySessionRefusesToRender() { + try (DocumentSession session = GraphCompose.document() + .pageSize(300, 200).create()) { + assertThatThrownBy(session::toPptxBytes) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("empty document"); + } + } + + @Test + void nullStreamAndClosedSessionFailWithTypedDiagnostics() throws Exception { + DocumentSession session = composeWithChrome(); + // A null stream is an argument error and passes through unwrapped. + assertThatThrownBy(() -> session.writePptx(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("output"); + session.close(); + assertThatThrownBy(session::toPptxBytes) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already closed"); + } +}