diff --git a/render-docx/README.md b/render-docx/README.md
index 0cad94ba..d0558f47 100644
--- a/render-docx/README.md
+++ b/render-docx/README.md
@@ -10,6 +10,22 @@ The semantic DOCX export backend for GraphCompose, backed by Apache POI. It carr
Add it (at compile scope) only when you export `.docx`. It is **not** included by
`graph-compose`, `graph-compose-core`, or `graph-compose-bundle` — DOCX is opt-in.
+**It is not sufficient on its own.** Opening a `DocumentSession` resolves a
+`FontMetricsProvider` so text can be measured, and `graph-compose-render-pdf` is the
+only artifact that publishes one. A classpath of `graph-compose-core` +
+`graph-compose-render-docx` fails at `create()` with `MissingBackendException` before
+any export happens. Add the PDF backend alongside it — or depend on `graph-compose`,
+which is core + render-pdf already:
+
+```xml
+
+ io.github.demchaav
+ graph-compose-render-pdf
+ 2.0.0
+ runtime
+
+```
+
## Usage
```java
diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1
index e8aa126d..25a1fe96 100644
--- a/scripts/cut-release.ps1
+++ b/scripts/cut-release.ps1
@@ -404,6 +404,42 @@ function Update-ModuleReadmeInstallVersion($readmePath, $newVersion) {
}
}
+function Update-ReleaseSmokeDefaultVersion($repoRoot, $newVersion) {
+ # The consumer-smoke harness hard-codes the version it tests when none is
+ # passed. Left behind, a post-release run that accepts the prefilled default
+ # re-verifies the PREVIOUS release and reports green — the one failure mode a
+ # release gate must not have. Each pattern is anchored to its own construct so
+ # a bump cannot smear across unrelated version strings in the same file.
+ $targets = @(
+ @{ Path = 'scripts/release-smoke/run.sh'; Pattern = '(?<=^GC_VERSION=")[\w\.\-]+(?=")'; Label = 'run.sh default' },
+ @{ Path = 'scripts/release-smoke/run.sh'; Pattern = '(?<=tests gc\.version=)[\w\.\-]+'; Label = 'run.sh usage' },
+ @{ Path = 'scripts/release-smoke/run.ps1'; Pattern = "(?<=\[string\]\`$Version = ')[\w\.\-]+(?=')"; Label = 'run.ps1 default' },
+ @{ Path = 'scripts/release-smoke/run.ps1'; Pattern = '(?<=# isolated, tests )[\w\.\-]+'; Label = 'run.ps1 usage' },
+ @{ Path = '.github/workflows/release-smoke.yml'; Pattern = "(?<=^\s{8}default: ')[\w\.\-]+(?=')"; Label = 'workflow input default' },
+ @{ Path = 'scripts/release-smoke/README.md'; Pattern = '(?<=defaults to the current published release \(`)[\w\.\-]+(?=`\))'; Label = 'README prose' }
+ )
+
+ foreach ($target in $targets) {
+ $path = Join-Path $repoRoot $target.Path
+ if (-not (Test-Path $path)) {
+ Note "skip (no file): $($target.Path)"
+ continue
+ }
+ $content = Get-Content $path -Raw
+ $updated = [regex]::Replace($content, $target.Pattern, $newVersion, 'Multiline')
+ if ($content -eq $updated) {
+ Note "no change: $($target.Path) [$($target.Label)] (already $newVersion?)"
+ continue
+ }
+ if ($DryRun) {
+ Write-Host " [DRY RUN] Bump $($target.Path) [$($target.Label)] -> $newVersion" -ForegroundColor Yellow
+ } else {
+ [System.IO.File]::WriteAllText($path, $updated)
+ Note "bumped release-smoke $($target.Label): $($target.Path) -> $newVersion"
+ }
+ }
+}
+
function Update-IndexHtmlVersion($indexHtmlPath, $newVersion) {
if (-not (Test-Path $indexHtmlPath)) {
Note "skip (no file): $indexHtmlPath"
@@ -815,6 +851,9 @@ try {
Update-ModuleReadmeInstallVersion (Join-Path $repoRoot $moduleReadme) $Version
}
Update-IndexHtmlVersion (Join-Path $repoRoot 'web/index.html') $Version
+ # The smoke harness's default version must follow the release, or the
+ # post-release run silently re-verifies the previous one.
+ Update-ReleaseSmokeDefaultVersion $repoRoot $Version
} else {
Note "pre-release: skipped README / module-README / web install-snippet bumps (stay on last stable)"
}
@@ -941,7 +980,13 @@ try {
'benchmarks/pom.xml',
'README.md',
'CHANGELOG.md',
- 'web/index.html'
+ 'web/index.html',
+ # Bumped by Update-ReleaseSmokeDefaultVersion so the post-release smoke run
+ # defaults to the version just published, not the previous one.
+ 'scripts/release-smoke/run.sh',
+ 'scripts/release-smoke/run.ps1',
+ 'scripts/release-smoke/README.md',
+ '.github/workflows/release-smoke.yml'
)
# qa + coverage exist only in the 2.0 aggregator layout; add them to the commit
# only when present so the script stays layout-agnostic (the 1.x single-artifact
diff --git a/scripts/release-smoke/README.md b/scripts/release-smoke/README.md
index 5d052a05..6b9f4135 100644
--- a/scripts/release-smoke/README.md
+++ b/scripts/release-smoke/README.md
@@ -19,6 +19,13 @@ build never touches them. Run them explicitly with the harness below.
| `s4-templates` | `graph-compose-templates` | a built-in template composes and renders through the PDF stack |
| `s5-testing` | `graph-compose` + `graph-compose-testing` | the consumer testing helper (`LayoutSnapshotAssertions`) resolves and round-trips a layout snapshot |
| `s6-bundle` | `graph-compose-bundle` | the batteries-included aggregate renders a templated document, exposes the bundled fonts (`DefaultFonts.bundledFontNames()`), and makes the colour-emoji set resolvable (`GraphComposeEmoji.isAvailable()`) |
+| `s7-core-render-pptx` | `graph-compose-core` + `graph-compose-render-pptx` | the PPTX backend is discovered by format, and `toPptxBytes()` produces a real OPC package — one slide part, 16:9 slide dimensions in EMU, editable text runs, and **no** full-slide picture in vector mode. **Requires 2.1.0+**: the fixed-layout backend and `DocumentPageSize.SLIDE_16_9` do not exist in 2.0.0, so this scenario cannot pass against an earlier version |
+| `s8-core-render-docx` | `graph-compose-core` + `graph-compose-render-docx` + `graph-compose-render-pdf` | the semantic Word exporter is on the consumer's compile classpath (it is named directly, not discovered through the ServiceLoader) and `export(new DocxSemanticBackend())` produces a `word/document.xml` carrying the text. The PDF backend is in the set because it is **required**: opening a session resolves a `FontMetricsProvider` and render-pdf is the only artifact that publishes one, while render-docx declares it at test scope only — so core + render-docx alone cannot construct a session |
+
+Both new scenarios inspect the emitted OPC package with `java.util.zip` rather than
+Apache POI. The point is to prove the *published* artifacts work for a consumer who
+installed nothing else, so a scenario must not pull a parsing library of its own to make
+its assertions pass.
The "must pull core + render-pdf" (wrapper) and "must pull the documented
aggregate" (bundle) assertions are proven positively: `s1` / `s6` can only
diff --git a/scripts/release-smoke/run.ps1 b/scripts/release-smoke/run.ps1
index 07cb51fc..57eb6268 100644
--- a/scripts/release-smoke/run.ps1
+++ b/scripts/release-smoke/run.ps1
@@ -29,7 +29,7 @@ $repoRoot = (Resolve-Path (Join-Path $here '..\..')).Path
$mvnw = Join-Path $repoRoot 'mvnw.cmd'
$settings = Join-Path $here 'settings.xml'
-$scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-templates', 's5-testing', 's6-bundle')
+$scenarios = @('s1-graph-compose', 's2-core-only', 's3-core-render-pdf', 's4-templates', 's5-testing', 's6-bundle', 's7-core-render-pptx', 's8-core-render-docx')
$repo = Join-Path $repoRoot 'target\release-smoke-m2\repo'
New-Item -ItemType Directory -Force -Path $repo | Out-Null
diff --git a/scripts/release-smoke/run.sh b/scripts/release-smoke/run.sh
index 96f472a8..a28693c9 100755
--- a/scripts/release-smoke/run.sh
+++ b/scripts/release-smoke/run.sh
@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
MVNW="$REPO_ROOT/mvnw"
SETTINGS="$HERE/settings.xml"
-SCENARIOS=(s1-graph-compose s2-core-only s3-core-render-pdf s4-templates s5-testing s6-bundle)
+SCENARIOS=(s1-graph-compose s2-core-only s3-core-render-pdf s4-templates s5-testing s6-bundle s7-core-render-pptx s8-core-render-docx)
REPO="$REPO_ROOT/target/release-smoke-m2/repo"
# Default version under test: the currently published release. Release smoke must
diff --git a/scripts/release-smoke/s7-core-render-pptx/pom.xml b/scripts/release-smoke/s7-core-render-pptx/pom.xml
new file mode 100644
index 00000000..d8d33245
--- /dev/null
+++ b/scripts/release-smoke/s7-core-render-pptx/pom.xml
@@ -0,0 +1,64 @@
+
+
+ 4.0.0
+
+
+ com.demcha.smoke
+ graph-compose-smoke-core-render-pptx
+ 1.0.0
+
+
+ UTF-8
+ 17
+ 2.0.0
+ 5.11.4
+ 3.27.3
+ 3.5.2
+
+
+
+
+ io.github.demchaav
+ graph-compose-core
+ ${gc.version}
+
+
+
+ io.github.demchaav
+ graph-compose-render-pptx
+ ${gc.version}
+ runtime
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${surefire.version}
+
+
+
+
diff --git a/scripts/release-smoke/s7-core-render-pptx/src/test/java/com/demcha/smoke/CoreRenderPptxTest.java b/scripts/release-smoke/s7-core-render-pptx/src/test/java/com/demcha/smoke/CoreRenderPptxTest.java
new file mode 100644
index 00000000..0ba73a92
--- /dev/null
+++ b/scripts/release-smoke/s7-core-render-pptx/src/test/java/com/demcha/smoke/CoreRenderPptxTest.java
@@ -0,0 +1,118 @@
+package com.demcha.smoke;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.fixed.BackendProviders;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Scenario 7 — {@code graph-compose-core} + {@code graph-compose-render-pptx}
+ * resolved from Maven Central produces a real, editable PowerPoint deck.
+ *
+ *
The deck is inspected with {@link ZipInputStream} rather than Apache POI:
+ * the point is to prove the published artifacts work for a consumer who
+ * installed nothing else, so the scenario must not pull a parsing library of its
+ * own to make its assertions pass.
+ *
+ * An empty file with a valid ZIP header would satisfy a naive smoke test, so
+ * the assertions go further: the slide part must exist, carry real text runs, and
+ * consist of native shapes rather than one full-slide picture — the failure mode
+ * that would mean the backend silently fell back to rasterising everything.
+ */
+class CoreRenderPptxTest {
+
+ /** EMU per PostScript point, the unit OOXML stores slide dimensions in. */
+ private static final int EMU_PER_POINT = 12700;
+
+ @Test
+ void coreWithRenderPptxProducesAnEditableDeck() throws Exception {
+ byte[] deck;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.SLIDE_16_9)
+ .margin(48f, 48f, 48f, 48f)
+ .create()) {
+ document.add(document.dsl().paragraph()
+ .text("graph-compose-core + graph-compose-render-pptx renders via the SPI.")
+ .build());
+ deck = document.toPptxBytes();
+ }
+
+ assertThat(deck).isNotEmpty();
+ assertThat(new String(deck, 0, 2, StandardCharsets.US_ASCII))
+ .describedAs("a .pptx is an OPC package, so it must start with the ZIP signature")
+ .isEqualTo("PK");
+
+ Map parts = unzip(deck);
+ assertThat(parts).containsKey("[Content_Types].xml");
+ assertThat(parts)
+ .describedAs("one resolved page must become one slide part")
+ .containsKey("ppt/slides/slide1.xml");
+ assertThat(parts.keySet().stream().filter(name -> name.startsWith("ppt/slides/slide")))
+ .hasSize(1);
+
+ String presentation = text(parts.get("ppt/presentation.xml"));
+ assertThat(presentation)
+ .describedAs("SLIDE_16_9 is 960x540 pt, which OOXML stores in EMU")
+ .contains("cx=\"" + (960 * EMU_PER_POINT) + "\"")
+ .contains("cy=\"" + (540 * EMU_PER_POINT) + "\"");
+
+ String slide = text(parts.get("ppt/slides/slide1.xml"));
+ assertThat(slide)
+ .describedAs("the paragraph must land as an editable text run, not as pixels")
+ .contains("");
+ assertThat(countOccurrences(slide, ""))
+ .describedAs("the slide must be built from native shapes")
+ .isPositive();
+ // POI writes both elements without attributes, verified against a generated
+ // deck that does contain a picture — so neither assertion is vacuous, and
+ // "" cannot accidentally match "".
+ assertThat(countOccurrences(slide, ""))
+ .describedAs("a text-only document must produce no pictures at all in vector mode")
+ .isZero();
+ }
+
+ @Test
+ void thePptxProviderIsDiscoveredByFormat() {
+ assertThat(BackendProviders.fixedLayout("pptx").format()).isEqualTo("pptx");
+ }
+
+ private static Map unzip(byte[] archive) throws Exception {
+ Map parts = new HashMap<>();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive))) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ if (entry.isDirectory()) {
+ continue;
+ }
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ zip.transferTo(bytes);
+ parts.put(entry.getName(), bytes.toByteArray());
+ }
+ }
+ return parts;
+ }
+
+ private static String text(byte[] part) {
+ assertThat(part).isNotNull();
+ return new String(part, StandardCharsets.UTF_8);
+ }
+
+ private static int countOccurrences(String haystack, String needle) {
+ int count = 0;
+ for (int at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length())) {
+ count++;
+ }
+ return count;
+ }
+}
diff --git a/scripts/release-smoke/s8-core-render-docx/pom.xml b/scripts/release-smoke/s8-core-render-docx/pom.xml
new file mode 100644
index 00000000..1d738345
--- /dev/null
+++ b/scripts/release-smoke/s8-core-render-docx/pom.xml
@@ -0,0 +1,72 @@
+
+
+ 4.0.0
+
+
+ com.demcha.smoke
+ graph-compose-smoke-core-render-docx
+ 1.0.0
+
+
+ UTF-8
+ 17
+ 2.0.0
+ 5.11.4
+ 3.27.3
+ 3.5.2
+
+
+
+
+ io.github.demchaav
+ graph-compose-core
+ ${gc.version}
+
+
+
+ io.github.demchaav
+ graph-compose-render-docx
+ ${gc.version}
+
+
+
+ io.github.demchaav
+ graph-compose-render-pdf
+ ${gc.version}
+ runtime
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.version}
+ test
+
+
+ org.assertj
+ assertj-core
+ ${assertj.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ ${surefire.version}
+
+
+
+
diff --git a/scripts/release-smoke/s8-core-render-docx/src/test/java/com/demcha/smoke/CoreRenderDocxTest.java b/scripts/release-smoke/s8-core-render-docx/src/test/java/com/demcha/smoke/CoreRenderDocxTest.java
new file mode 100644
index 00000000..7a02ded8
--- /dev/null
+++ b/scripts/release-smoke/s8-core-render-docx/src/test/java/com/demcha/smoke/CoreRenderDocxTest.java
@@ -0,0 +1,79 @@
+package com.demcha.smoke;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.backend.semantic.docx.DocxSemanticBackend;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Scenario 8 — {@code graph-compose-core} + {@code graph-compose-render-docx}
+ * resolved from Maven Central produces a real Word document.
+ *
+ * Unlike the fixed-layout backends, the semantic exporter is named by the
+ * caller rather than discovered through the ServiceLoader, so this scenario also
+ * proves the type is on the compile classpath of a consumer who installed only
+ * these two coordinates.
+ *
+ * Inspected with {@link ZipInputStream} rather than Apache POI, so the
+ * scenario asserts against the published artifacts alone.
+ */
+class CoreRenderDocxTest {
+
+ @Test
+ void coreWithRenderDocxProducesAWordDocument() throws Exception {
+ byte[] docx;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4)
+ .margin(36f, 36f, 36f, 36f)
+ .create()) {
+ document.add(document.dsl().paragraph()
+ .text("graph-compose-core + graph-compose-render-docx exports semantic Word content.")
+ .build());
+ docx = document.export(new DocxSemanticBackend());
+ }
+
+ assertThat(docx).isNotEmpty();
+ assertThat(new String(docx, 0, 2, StandardCharsets.US_ASCII))
+ .describedAs("a .docx is an OPC package, so it must start with the ZIP signature")
+ .isEqualTo("PK");
+
+ Map parts = unzip(docx);
+ assertThat(parts).containsKey("[Content_Types].xml");
+ assertThat(parts)
+ .describedAs("the main document part must be present")
+ .containsKey("word/document.xml");
+
+ String body = new String(parts.get("word/document.xml"), StandardCharsets.UTF_8);
+ assertThat(body)
+ .describedAs("the paragraph must land as a Word text run")
+ .contains(" unzip(byte[] archive) throws Exception {
+ Map parts = new HashMap<>();
+ try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(archive))) {
+ ZipEntry entry;
+ while ((entry = zip.getNextEntry()) != null) {
+ if (entry.isDirectory()) {
+ continue;
+ }
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ zip.transferTo(bytes);
+ parts.put(entry.getName(), bytes.toByteArray());
+ }
+ }
+ return parts;
+ }
+}