Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,28 @@
</dependencies>

<build>
<resources>
<!--
Only banner.properties is filtered (it carries @project.version@
so the README hero is stamped with the reactor version, never a
hand-bumped literal). Every other resource is copied verbatim so
binary assets (icons, fonts, sample data) stay byte-intact.
-->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>banner.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>banner.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
}
Expand All @@ -125,22 +155,24 @@ public static Path generate() throws Exception {
}

/**
* Renders page&nbsp;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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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:</p>
* <pre>
* ./mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java \
* -Dexec.mainClass=com.demcha.examples.support.ReadmeBannerRenderer \
* -Dexec.args="&lt;outputPng&gt; [dpi=200]"
* </pre>
*
* @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 <outputPng> [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;
}
}
10 changes: 10 additions & 0 deletions examples/src/main/resources/banner.properties
Original file line number Diff line number Diff line change
@@ -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 <resources>), 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
Original file line number Diff line number Diff line change
@@ -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.*");
}
}
Loading
Loading