Skip to content

Commit b60bde1

Browse files
authored
feat(examples): auto-render the version-stamped README hero banner on release (#260)
The README hero (assets/readme/repository_showcase_render.png) carried a hardcoded version constant and was rasterised by hand, so it drifted — it still read v1.8.0 while the repo was on 1.9.0. Now it is single-sourced and rendered straight from the engine: - EngineDeckExample reads its version + codename from a Maven-filtered banner.properties (version=@project.version@, codename=navigable) instead of hardcoded constants, so the hero always carries the reactor version. - The new ReadmeBannerRenderer writes the PNG straight from the engine via DocumentSession.toImage(0, dpi) — no render-to-PDF-then-PdfPageRasterizer round-trip. EngineDeckExample.renderBannerImage shares the banner composition with the (kept) PDF generateBanner via composeBannerInto. - cut-release.ps1 re-renders the banner after the version bump and stages it in the release commit (gated under -not -SkipShowcase), so the committed hero ships in lockstep with every tag. - VersionConsistencyGuardTest fails the build if banner.properties stops sourcing the version from @project.version@; ReadmeBannerRendererTest covers the render, the PNG write, and that filtering actually resolved. The hero keeps its tight pageSize crop: the whole page is the banner via pageBackground, which bleed() does not replace.
1 parent 89af092 commit b60bde1

8 files changed

Lines changed: 316 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,18 @@ PDF `GoTo` actions. External links are unchanged.
305305
— a paginated catalogue of the entire bundled emoji set (every indexed glyph,
306306
drawn inline).
307307

308+
### Build
309+
310+
- **The README hero banner is now version-stamped and re-rendered on release.**
311+
`EngineDeckExample` reads its version and codename from a filtered
312+
`banner.properties` (`@project.version@`) instead of hardcoded constants, and
313+
the new `ReadmeBannerRenderer` writes
314+
`assets/readme/repository_showcase_render.png` straight from the engine via
315+
`DocumentSession.toImage(...)` — no PDF-rasterize round-trip.
316+
`cut-release.ps1` re-renders and stages the hero on every tag, and
317+
`VersionConsistencyGuardTest` fails the build if the banner version is ever
318+
hardcoded again.
319+
308320
### Tests
309321

310322
- `InternalLinkAnchorTest` (PDFBox assertions): forward and backward references

examples/pom.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@
9898
</dependencies>
9999

100100
<build>
101+
<resources>
102+
<!--
103+
Only banner.properties is filtered (it carries @project.version@
104+
so the README hero is stamped with the reactor version, never a
105+
hand-bumped literal). Every other resource is copied verbatim so
106+
binary assets (icons, fonts, sample data) stay byte-intact.
107+
-->
108+
<resource>
109+
<directory>src/main/resources</directory>
110+
<filtering>true</filtering>
111+
<includes>
112+
<include>banner.properties</include>
113+
</includes>
114+
</resource>
115+
<resource>
116+
<directory>src/main/resources</directory>
117+
<filtering>false</filtering>
118+
<excludes>
119+
<exclude>banner.properties</exclude>
120+
</excludes>
121+
</resource>
122+
</resources>
101123
<plugins>
102124
<plugin>
103125
<groupId>org.apache.maven.plugins</groupId>

examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java

Lines changed: 82 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@
4848
import com.demcha.compose.font.FontName;
4949
import com.demcha.examples.support.ExampleOutputPaths;
5050

51+
import java.awt.image.BufferedImage;
52+
import java.io.IOException;
5153
import java.io.InputStream;
5254
import java.nio.charset.StandardCharsets;
5355
import java.nio.file.Path;
5456
import java.util.List;
5557
import java.util.Objects;
58+
import java.util.Properties;
5659

5760
/**
5861
* Flagship "what is GraphCompose" capability deck — a multi-page landscape
@@ -100,8 +103,35 @@
100103
*/
101104
public final class EngineDeckExample {
102105

103-
private static final String VERSION = "1.8.0";
104-
private static final String CODENAME = "illustrative";
106+
/**
107+
* Release version + codename shown on the banner. Sourced from the filtered
108+
* {@code banner.properties} ({@code version} = Maven {@code @project.version@},
109+
* {@code codename} = the per-minor release name), so the hero is always
110+
* current with the release the build was cut from rather than a hand-bumped
111+
* literal. Falls back to {@code "dev"} when run without Maven resource
112+
* filtering (e.g. straight from an IDE), so the banner never prints the raw
113+
* {@code @…@} token.
114+
*/
115+
private static final String VERSION;
116+
private static final String CODENAME;
117+
118+
static {
119+
Properties banner = new Properties();
120+
try (InputStream in = EngineDeckExample.class.getResourceAsStream("/banner.properties")) {
121+
if (in != null) {
122+
banner.load(in);
123+
}
124+
} catch (IOException ignored) {
125+
// Missing/unreadable metadata falls through to the defaults below.
126+
}
127+
VERSION = resolved(banner.getProperty("version"), "dev");
128+
CODENAME = resolved(banner.getProperty("codename"), "");
129+
}
130+
131+
/** Returns {@code value} unless it is blank or an unfiltered {@code @…@} token. */
132+
private static String resolved(String value, String fallback) {
133+
return value == null || value.isBlank() || value.startsWith("@") ? fallback : value.trim();
134+
}
105135

106136
private EngineDeckExample() {
107137
}
@@ -125,22 +155,24 @@ public static Path generate() throws Exception {
125155
}
126156

127157
/**
128-
* Renders page&nbsp;1's banner as a standalone, full-bleed hero. The dark
129-
* violet field is painted as the canonical {@code pageBackground}, so it
130-
* fills the whole landscape page — margins and corners included — and the
131-
* rasterised image carries no white frame, only the banner itself. This is
132-
* the source of the repository README hero
133-
* ({@code assets/readme/repository_showcase_render.png}, produced by
134-
* {@link com.demcha.examples.support.PdfPageRasterizer}); re-render it after a
135-
* version bump — the banner reads {@link #VERSION} / {@link #CODENAME}, so
136-
* the hero stays current with one rebuild.
158+
* Renders the standalone hero banner to a raster image straight from the
159+
* engine via {@link DocumentSession#toImage(int, int)} ({@code @since 1.9.0})
160+
* — no intermediate PDF and no external rasterizer. This is the source of the
161+
* repository README hero ({@code assets/readme/repository_showcase_render.png},
162+
* written by {@link com.demcha.examples.support.ReadmeBannerRenderer}). The
163+
* dark violet field is the canonical {@code pageBackground} on a page cropped
164+
* to wrap the content, so the image is all banner and no white frame. The
165+
* version pill reads {@link #VERSION} / {@link #CODENAME} from the filtered
166+
* {@code banner.properties}, so the hero stays current with the release the
167+
* build was cut from; {@code cut-release.ps1} re-renders it on every tag.
137168
*
138-
* @return the generated single-page banner PDF path
169+
* @param dpi raster resolution in dots per inch; 200 matches the committed asset
170+
* @return the rendered banner image
139171
* @throws Exception when rendering or icon IO fails
172+
* @since 1.9.0
140173
*/
141-
public static Path generateBanner() throws Exception {
142-
Path outputFile = ExampleOutputPaths.prepare("flagships", "engine-banner.pdf");
143-
try (DocumentSession document = GraphCompose.document(outputFile)
174+
public static BufferedImage renderBannerImage(int dpi) throws Exception {
175+
try (DocumentSession document = GraphCompose.document()
144176
// The banner content is a top-anchored stack; the default
145177
// A4-landscape page left a thick dead dark border around it.
146178
// Crop the page to wrap the content tightly — width fits the
@@ -150,21 +182,46 @@ public static Path generateBanner() throws Exception {
150182
.pageBackground(HERO_BG)
151183
.margin(8, 8, 8, 8)
152184
.create()) {
153-
document.metadata(DocumentMetadata.builder()
154-
.title("GraphCompose v" + VERSION + " — " + CODENAME)
155-
.author("GraphCompose")
156-
.subject("GraphCompose banner — the engine's own brand hero")
157-
.producer("GraphCompose (PDFBox 3.0)")
158-
.build());
159-
document.pageFlow()
160-
.name("EngineBanner")
161-
.addSection("Banner", EngineDeckExample::banner)
162-
.build();
185+
composeBannerInto(document);
186+
return document.toImage(0, dpi);
187+
}
188+
}
189+
190+
/**
191+
* Renders the same standalone banner to a PDF — kept for a vector/print copy
192+
* of the hero and as the layout the snapshot test guards. The committed
193+
* README image comes from {@link #renderBannerImage(int)}, not this file.
194+
*
195+
* @return the generated single-page banner PDF path
196+
* @throws Exception when rendering or icon IO fails
197+
*/
198+
public static Path generateBanner() throws Exception {
199+
Path outputFile = ExampleOutputPaths.prepare("flagships", "engine-banner.pdf");
200+
try (DocumentSession document = GraphCompose.document(outputFile)
201+
.pageSize(801, 525)
202+
.pageBackground(HERO_BG)
203+
.margin(8, 8, 8, 8)
204+
.create()) {
205+
composeBannerInto(document);
163206
document.buildPdf();
164207
}
165208
return outputFile;
166209
}
167210

211+
/** Banner metadata + the single banner section — shared by the image and PDF renders. */
212+
private static void composeBannerInto(DocumentSession document) {
213+
document.metadata(DocumentMetadata.builder()
214+
.title("GraphCompose v" + VERSION + " — " + CODENAME)
215+
.author("GraphCompose")
216+
.subject("GraphCompose banner — the engine's own brand hero")
217+
.producer("GraphCompose (PDFBox 3.0)")
218+
.build());
219+
document.pageFlow()
220+
.name("EngineBanner")
221+
.addSection("Banner", EngineDeckExample::banner)
222+
.build();
223+
}
224+
168225
/**
169226
* Composes the four deck pages onto a session — shared by {@link #generate()}
170227
* and the layout snapshot test, so the test guards the very layout we ship.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.demcha.examples.support;
2+
3+
import com.demcha.examples.flagships.EngineDeckExample;
4+
5+
import javax.imageio.ImageIO;
6+
import java.awt.image.BufferedImage;
7+
import java.nio.file.Files;
8+
import java.nio.file.Path;
9+
import java.nio.file.Paths;
10+
11+
/**
12+
* Renders the README hero banner straight to its committed PNG via the engine's
13+
* {@code DocumentSession.toImage(...)} path ({@code @since 1.9.0}) — the
14+
* replacement for the old render-to-PDF-then-{@link PdfPageRasterizer}
15+
* round-trip. The banner's version pill is sourced from the filtered
16+
* {@code banner.properties}, so running this after a version bump stamps the
17+
* hero with the current release. {@code cut-release.ps1} invokes it during the
18+
* release commit so {@code assets/readme/repository_showcase_render.png} ships
19+
* in lockstep with every tag, instead of drifting until someone re-renders by
20+
* hand.
21+
*
22+
* <p>Usage — pass an explicit output path (exec:java runs with the examples
23+
* module as its working directory, so a relative default would land in the
24+
* wrong place); DPI defaults to 200, matching the committed asset:</p>
25+
* <pre>
26+
* ./mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java \
27+
* -Dexec.mainClass=com.demcha.examples.support.ReadmeBannerRenderer \
28+
* -Dexec.args="&lt;outputPng&gt; [dpi=200]"
29+
* </pre>
30+
*
31+
* @author Artem Demchyshyn
32+
* @since 1.9.0
33+
*/
34+
public final class ReadmeBannerRenderer {
35+
36+
/** Resolution of the committed hero asset; high enough for a crisp README image. */
37+
private static final int DEFAULT_DPI = 200;
38+
39+
private ReadmeBannerRenderer() {
40+
}
41+
42+
public static void main(String[] args) throws Exception {
43+
if (args.length < 1 || args[0].isBlank()) {
44+
System.err.println("Usage: ReadmeBannerRenderer <outputPng> [dpi=200]");
45+
System.exit(2);
46+
}
47+
Path outputPng = Paths.get(args[0]).toAbsolutePath().normalize();
48+
int dpi = args.length >= 2 ? Integer.parseInt(args[1]) : DEFAULT_DPI;
49+
50+
Path written = render(outputPng, dpi);
51+
System.out.println("Rendered README banner -> " + written + " (" + dpi + " DPI)");
52+
}
53+
54+
/**
55+
* Renders the hero banner and writes it as a PNG, creating parent
56+
* directories as needed.
57+
*
58+
* @param outputPng destination file
59+
* @param dpi raster resolution in dots per inch
60+
* @return the written path
61+
* @throws Exception if rendering fails
62+
* @throws IllegalStateException if the PNG encoder rejects the image
63+
*/
64+
public static Path render(Path outputPng, int dpi) throws Exception {
65+
BufferedImage image = EngineDeckExample.renderBannerImage(dpi);
66+
Path parent = outputPng.toAbsolutePath().getParent();
67+
if (parent != null) {
68+
Files.createDirectories(parent);
69+
}
70+
if (!ImageIO.write(image, "png", outputPng.toFile())) {
71+
throw new IllegalStateException("No PNG ImageIO writer accepted the banner: " + outputPng);
72+
}
73+
return outputPng;
74+
}
75+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# README hero banner metadata.
2+
#
3+
# `version` is filled by Maven resource filtering from the reactor
4+
# ${project.version} at build time (see examples/pom.xml <resources>), so the
5+
# rendered hero is never a hand-bumped literal and can never drift from the
6+
# release it was cut from. `codename` is the per-minor release name, set during
7+
# release prep. cut-release.ps1 re-renders the banner on every tag, stamping the
8+
# committed assets/readme/repository_showcase_render.png with both values.
9+
version=@project.version@
10+
codename=navigable
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.demcha.examples.support;
2+
3+
import com.demcha.examples.flagships.EngineDeckExample;
4+
import org.junit.jupiter.api.Test;
5+
import org.junit.jupiter.api.io.TempDir;
6+
7+
import java.awt.image.BufferedImage;
8+
import java.io.InputStream;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.util.Properties;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
/**
16+
* Verifies the README hero renders straight to an image through the engine's
17+
* {@code toImage} path — no intermediate PDF, no external rasterizer — and that
18+
* the writer lands a non-trivial PNG.
19+
*/
20+
class ReadmeBannerRendererTest {
21+
22+
@Test
23+
void rendersANonEmptyBannerImageWithoutAPdfRoundTrip() throws Exception {
24+
BufferedImage image = EngineDeckExample.renderBannerImage(96);
25+
26+
// The banner page is 801x525pt; at 96 DPI that is ~1068x700px. Assert
27+
// generous lower bounds so the test guards "rendered something real"
28+
// without pinning exact pixels (DPI/metric drift would make that brittle).
29+
assertThat(image.getWidth()).isGreaterThan(600);
30+
assertThat(image.getHeight()).isGreaterThan(300);
31+
}
32+
33+
@Test
34+
void writesThePngToTheGivenPath(@TempDir Path tmp) throws Exception {
35+
Path png = tmp.resolve("nested").resolve("banner.png");
36+
37+
Path written = ReadmeBannerRenderer.render(png, 72);
38+
39+
assertThat(written).isEqualTo(png);
40+
assertThat(png).exists();
41+
assertThat(Files.size(png)).isGreaterThan(2_000L);
42+
}
43+
44+
@Test
45+
void bannerPropertiesVersionIsFilledByMavenResourceFiltering() throws Exception {
46+
// The on-classpath copy is the build's filtered output: if examples/pom.xml
47+
// ever loses the filtering, version stays the raw @project.version@ token
48+
// and the hero silently stops carrying the real release version.
49+
Properties props = new Properties();
50+
try (InputStream in = getClass().getResourceAsStream("/banner.properties")) {
51+
assertThat(in).describedAs("banner.properties must be on the classpath").isNotNull();
52+
props.load(in);
53+
}
54+
assertThat(props.getProperty("version"))
55+
.describedAs("banner version must be filtered to the project version, not the raw @project.version@ token")
56+
.doesNotContain("@")
57+
.matches("\\d.*");
58+
}
59+
}

0 commit comments

Comments
 (0)