diff --git a/CHANGELOG.md b/CHANGELOG.md index f858988bf..0f557c38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,6 +305,18 @@ PDF `GoTo` actions. External links are unchanged. — a paginated catalogue of the entire bundled emoji set (every indexed glyph, drawn inline). +### Build + +- **The README hero banner is now version-stamped and re-rendered on release.** + `EngineDeckExample` reads its version and codename from a filtered + `banner.properties` (`@project.version@`) instead of hardcoded constants, and + the new `ReadmeBannerRenderer` writes + `assets/readme/repository_showcase_render.png` straight from the engine via + `DocumentSession.toImage(...)` — no PDF-rasterize round-trip. + `cut-release.ps1` re-renders and stages the hero on every tag, and + `VersionConsistencyGuardTest` fails the build if the banner version is ever + hardcoded again. + ### Tests - `InternalLinkAnchorTest` (PDFBox assertions): forward and backward references diff --git a/examples/pom.xml b/examples/pom.xml index 7433b7fe9..6f5f74f3d 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -98,6 +98,28 @@ + + + + src/main/resources + true + + banner.properties + + + + src/main/resources + false + + banner.properties + + + org.apache.maven.plugins diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java index 48346b2a5..c73a92547 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java @@ -48,11 +48,14 @@ import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; +import java.awt.image.BufferedImage; +import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.List; import java.util.Objects; +import java.util.Properties; /** * Flagship "what is GraphCompose" capability deck — a multi-page landscape @@ -100,8 +103,35 @@ */ public final class EngineDeckExample { - private static final String VERSION = "1.8.0"; - private static final String CODENAME = "illustrative"; + /** + * Release version + codename shown on the banner. Sourced from the filtered + * {@code banner.properties} ({@code version} = Maven {@code @project.version@}, + * {@code codename} = the per-minor release name), so the hero is always + * current with the release the build was cut from rather than a hand-bumped + * literal. Falls back to {@code "dev"} when run without Maven resource + * filtering (e.g. straight from an IDE), so the banner never prints the raw + * {@code @…@} token. + */ + private static final String VERSION; + private static final String CODENAME; + + static { + Properties banner = new Properties(); + try (InputStream in = EngineDeckExample.class.getResourceAsStream("/banner.properties")) { + if (in != null) { + banner.load(in); + } + } catch (IOException ignored) { + // Missing/unreadable metadata falls through to the defaults below. + } + VERSION = resolved(banner.getProperty("version"), "dev"); + CODENAME = resolved(banner.getProperty("codename"), ""); + } + + /** Returns {@code value} unless it is blank or an unfiltered {@code @…@} token. */ + private static String resolved(String value, String fallback) { + return value == null || value.isBlank() || value.startsWith("@") ? fallback : value.trim(); + } private EngineDeckExample() { } @@ -125,22 +155,24 @@ public static Path generate() throws Exception { } /** - * Renders page 1's banner as a standalone, full-bleed hero. The dark - * violet field is painted as the canonical {@code pageBackground}, so it - * fills the whole landscape page — margins and corners included — and the - * rasterised image carries no white frame, only the banner itself. This is - * the source of the repository README hero - * ({@code assets/readme/repository_showcase_render.png}, produced by - * {@link com.demcha.examples.support.PdfPageRasterizer}); re-render it after a - * version bump — the banner reads {@link #VERSION} / {@link #CODENAME}, so - * the hero stays current with one rebuild. + * Renders the standalone hero banner to a raster image straight from the + * engine via {@link DocumentSession#toImage(int, int)} ({@code @since 1.9.0}) + * — no intermediate PDF and no external rasterizer. This is the source of the + * repository README hero ({@code assets/readme/repository_showcase_render.png}, + * written by {@link com.demcha.examples.support.ReadmeBannerRenderer}). The + * dark violet field is the canonical {@code pageBackground} on a page cropped + * to wrap the content, so the image is all banner and no white frame. The + * version pill reads {@link #VERSION} / {@link #CODENAME} from the filtered + * {@code banner.properties}, so the hero stays current with the release the + * build was cut from; {@code cut-release.ps1} re-renders it on every tag. * - * @return the generated single-page banner PDF path + * @param dpi raster resolution in dots per inch; 200 matches the committed asset + * @return the rendered banner image * @throws Exception when rendering or icon IO fails + * @since 1.9.0 */ - public static Path generateBanner() throws Exception { - Path outputFile = ExampleOutputPaths.prepare("flagships", "engine-banner.pdf"); - try (DocumentSession document = GraphCompose.document(outputFile) + public static BufferedImage renderBannerImage(int dpi) throws Exception { + try (DocumentSession document = GraphCompose.document() // The banner content is a top-anchored stack; the default // A4-landscape page left a thick dead dark border around it. // Crop the page to wrap the content tightly — width fits the @@ -150,21 +182,46 @@ public static Path generateBanner() throws Exception { .pageBackground(HERO_BG) .margin(8, 8, 8, 8) .create()) { - document.metadata(DocumentMetadata.builder() - .title("GraphCompose v" + VERSION + " — " + CODENAME) - .author("GraphCompose") - .subject("GraphCompose banner — the engine's own brand hero") - .producer("GraphCompose (PDFBox 3.0)") - .build()); - document.pageFlow() - .name("EngineBanner") - .addSection("Banner", EngineDeckExample::banner) - .build(); + composeBannerInto(document); + return document.toImage(0, dpi); + } + } + + /** + * Renders the same standalone banner to a PDF — kept for a vector/print copy + * of the hero and as the layout the snapshot test guards. The committed + * README image comes from {@link #renderBannerImage(int)}, not this file. + * + * @return the generated single-page banner PDF path + * @throws Exception when rendering or icon IO fails + */ + public static Path generateBanner() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("flagships", "engine-banner.pdf"); + try (DocumentSession document = GraphCompose.document(outputFile) + .pageSize(801, 525) + .pageBackground(HERO_BG) + .margin(8, 8, 8, 8) + .create()) { + composeBannerInto(document); document.buildPdf(); } return outputFile; } + /** Banner metadata + the single banner section — shared by the image and PDF renders. */ + private static void composeBannerInto(DocumentSession document) { + document.metadata(DocumentMetadata.builder() + .title("GraphCompose v" + VERSION + " — " + CODENAME) + .author("GraphCompose") + .subject("GraphCompose banner — the engine's own brand hero") + .producer("GraphCompose (PDFBox 3.0)") + .build()); + document.pageFlow() + .name("EngineBanner") + .addSection("Banner", EngineDeckExample::banner) + .build(); + } + /** * Composes the four deck pages onto a session — shared by {@link #generate()} * and the layout snapshot test, so the test guards the very layout we ship. diff --git a/examples/src/main/java/com/demcha/examples/support/ReadmeBannerRenderer.java b/examples/src/main/java/com/demcha/examples/support/ReadmeBannerRenderer.java new file mode 100644 index 000000000..f95d11c53 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/support/ReadmeBannerRenderer.java @@ -0,0 +1,75 @@ +package com.demcha.examples.support; + +import com.demcha.examples.flagships.EngineDeckExample; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * Renders the README hero banner straight to its committed PNG via the engine's + * {@code DocumentSession.toImage(...)} path ({@code @since 1.9.0}) — the + * replacement for the old render-to-PDF-then-{@link PdfPageRasterizer} + * round-trip. The banner's version pill is sourced from the filtered + * {@code banner.properties}, so running this after a version bump stamps the + * hero with the current release. {@code cut-release.ps1} invokes it during the + * release commit so {@code assets/readme/repository_showcase_render.png} ships + * in lockstep with every tag, instead of drifting until someone re-renders by + * hand. + * + *

Usage — pass an explicit output path (exec:java runs with the examples + * module as its working directory, so a relative default would land in the + * wrong place); DPI defaults to 200, matching the committed asset:

+ *
+ * ./mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java \
+ *   -Dexec.mainClass=com.demcha.examples.support.ReadmeBannerRenderer \
+ *   -Dexec.args="<outputPng> [dpi=200]"
+ * 
+ * + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public final class ReadmeBannerRenderer { + + /** Resolution of the committed hero asset; high enough for a crisp README image. */ + private static final int DEFAULT_DPI = 200; + + private ReadmeBannerRenderer() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 1 || args[0].isBlank()) { + System.err.println("Usage: ReadmeBannerRenderer [dpi=200]"); + System.exit(2); + } + Path outputPng = Paths.get(args[0]).toAbsolutePath().normalize(); + int dpi = args.length >= 2 ? Integer.parseInt(args[1]) : DEFAULT_DPI; + + Path written = render(outputPng, dpi); + System.out.println("Rendered README banner -> " + written + " (" + dpi + " DPI)"); + } + + /** + * Renders the hero banner and writes it as a PNG, creating parent + * directories as needed. + * + * @param outputPng destination file + * @param dpi raster resolution in dots per inch + * @return the written path + * @throws Exception if rendering fails + * @throws IllegalStateException if the PNG encoder rejects the image + */ + public static Path render(Path outputPng, int dpi) throws Exception { + BufferedImage image = EngineDeckExample.renderBannerImage(dpi); + Path parent = outputPng.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + if (!ImageIO.write(image, "png", outputPng.toFile())) { + throw new IllegalStateException("No PNG ImageIO writer accepted the banner: " + outputPng); + } + return outputPng; + } +} diff --git a/examples/src/main/resources/banner.properties b/examples/src/main/resources/banner.properties new file mode 100644 index 000000000..d3161d75e --- /dev/null +++ b/examples/src/main/resources/banner.properties @@ -0,0 +1,10 @@ +# README hero banner metadata. +# +# `version` is filled by Maven resource filtering from the reactor +# ${project.version} at build time (see examples/pom.xml ), so the +# rendered hero is never a hand-bumped literal and can never drift from the +# release it was cut from. `codename` is the per-minor release name, set during +# release prep. cut-release.ps1 re-renders the banner on every tag, stamping the +# committed assets/readme/repository_showcase_render.png with both values. +version=@project.version@ +codename=navigable diff --git a/examples/src/test/java/com/demcha/examples/support/ReadmeBannerRendererTest.java b/examples/src/test/java/com/demcha/examples/support/ReadmeBannerRendererTest.java new file mode 100644 index 000000000..3fd64800e --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/support/ReadmeBannerRendererTest.java @@ -0,0 +1,59 @@ +package com.demcha.examples.support; + +import com.demcha.examples.flagships.EngineDeckExample; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the README hero renders straight to an image through the engine's + * {@code toImage} path — no intermediate PDF, no external rasterizer — and that + * the writer lands a non-trivial PNG. + */ +class ReadmeBannerRendererTest { + + @Test + void rendersANonEmptyBannerImageWithoutAPdfRoundTrip() throws Exception { + BufferedImage image = EngineDeckExample.renderBannerImage(96); + + // The banner page is 801x525pt; at 96 DPI that is ~1068x700px. Assert + // generous lower bounds so the test guards "rendered something real" + // without pinning exact pixels (DPI/metric drift would make that brittle). + assertThat(image.getWidth()).isGreaterThan(600); + assertThat(image.getHeight()).isGreaterThan(300); + } + + @Test + void writesThePngToTheGivenPath(@TempDir Path tmp) throws Exception { + Path png = tmp.resolve("nested").resolve("banner.png"); + + Path written = ReadmeBannerRenderer.render(png, 72); + + assertThat(written).isEqualTo(png); + assertThat(png).exists(); + assertThat(Files.size(png)).isGreaterThan(2_000L); + } + + @Test + void bannerPropertiesVersionIsFilledByMavenResourceFiltering() throws Exception { + // The on-classpath copy is the build's filtered output: if examples/pom.xml + // ever loses the filtering, version stays the raw @project.version@ token + // and the hero silently stops carrying the real release version. + Properties props = new Properties(); + try (InputStream in = getClass().getResourceAsStream("/banner.properties")) { + assertThat(in).describedAs("banner.properties must be on the classpath").isNotNull(); + props.load(in); + } + assertThat(props.getProperty("version")) + .describedAs("banner version must be filtered to the project version, not the raw @project.version@ token") + .doesNotContain("@") + .matches("\\d.*"); + } +} diff --git a/scripts/cut-release.ps1 b/scripts/cut-release.ps1 index c08f22e8c..fd25aff01 100644 --- a/scripts/cut-release.ps1 +++ b/scripts/cut-release.ps1 @@ -340,6 +340,36 @@ function Run-ShowcaseSync { } } +function Render-ReadmeBanner { + # Re-renders assets/readme/repository_showcase_render.png straight from the + # engine (DocumentSession.toImage, @since 1.9.0) so the hero's version pill + # carries the just-bumped ${project.version} (read from the filtered + # banner.properties). Runs after Run-ShowcaseSync, which already installed the + # root artifact into the local m2 cache so the examples module resolves it. + Write-Host " > Re-render the version-stamped README hero banner" -ForegroundColor Cyan + $banner = Join-Path $repoRoot 'assets/readme/repository_showcase_render.png' + $execProp = '"-Dexec.mainClass=com.demcha.examples.support.ReadmeBannerRenderer"' + $execArgs = "`"-Dexec.args=$banner`"" + if ($DryRun) { + Write-Host " [DRY RUN] $mvnw -f examples/pom.xml exec:java $execProp $execArgs" -ForegroundColor Yellow + return + } + Push-Location $repoRoot + try { + & $mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java $execProp $execArgs 2>&1 | ForEach-Object { + if ($_ -match 'Rendered README banner|BUILD SUCCESS|BUILD FAILURE|ERROR') { + Write-Host " $_" -ForegroundColor DarkGray + } + } + if ($LASTEXITCODE -ne 0) { + throw "README banner render failed (exit $LASTEXITCODE)" + } + } finally { + Pop-Location + } + Note "banner: assets/readme/repository_showcase_render.png re-rendered" +} + # ============================================================ # Mode: -PostReleaseOnly # ============================================================ @@ -483,8 +513,9 @@ try { Step 4 "Regenerate web/examples.json with $tag links" Run-ShowcaseSync + Render-ReadmeBanner } else { - Step 3 "Skipped showcase GH_BASE flip + regen (-SkipShowcase)" + Step 3 "Skipped showcase GH_BASE flip + regen + banner (-SkipShowcase)" } if (-not $SkipVerify) { @@ -523,7 +554,8 @@ try { $commitFiles += @( 'examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java', 'web/examples.json', - 'web/showcase' + 'web/showcase', + 'assets/readme/repository_showcase_render.png' ) } if ($DryRun) { diff --git a/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index 0d2648020..5a4fd50d1 100644 --- a/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -106,6 +106,28 @@ void benchmarksDependencyDerivesVersionAndIsNotHardcoded() throws IOException { .isFalse(); } + /** + * The README hero banner reads its version pill from the filtered examples + * {@code banner.properties}; its {@code version} line must be the Maven + * {@code @project.version@} token so the rendered hero always carries the + * release version instead of a hand-bumped literal (the drift that left the + * banner reading v1.8.0 while the repo was on v1.9.0). {@code cut-release.ps1} + * re-renders the banner on release, and this fails the verify gate the moment + * the source is hardcoded again. + */ + @Test + void readmeBannerVersionDerivesFromProjectVersion() throws Exception { + String props = Files.readString( + PROJECT_ROOT.resolve("examples/src/main/resources/banner.properties")); + + assertThat(Pattern.compile("(?m)^\\s*version\\s*=\\s*@project\\.version@\\s*$").matcher(props).find()) + .describedAs("examples/src/main/resources/banner.properties must source the hero version from @project.version@, not a literal") + .isTrue(); + assertThat(Pattern.compile("(?m)^\\s*version\\s*=\\s*v?\\d").matcher(props).find()) + .describedAs("banner.properties must not hardcode a numeric version — that reintroduces the stale-hero drift") + .isFalse(); + } + @Test void readmeInstallSnippetsMatchTheProjectVersion() throws Exception { Set targets = acceptableTargets();