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