diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9176750..84bf9f78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,6 +79,9 @@ jobs: - '**/pom.xml' - '**/src/main/resources/**' - '**/src/test/resources/**' + # The examples module renders figures from the committed perf + # baseline, so a baseline-only refresh changes generated output. + - 'baselines/**' - '.mvn/**' - 'mvnw' - 'mvnw.cmd' diff --git a/assets/readme/examples/master-showcase.pdf b/assets/readme/examples/master-showcase.pdf index 536bc433..0ca75ac5 100644 Binary files a/assets/readme/examples/master-showcase.pdf and b/assets/readme/examples/master-showcase.pdf differ diff --git a/assets/readme/examples/master-showcase.pptx b/assets/readme/examples/master-showcase.pptx index 6d78b8bd..56a8347a 100644 Binary files a/assets/readme/examples/master-showcase.pptx and b/assets/readme/examples/master-showcase.pptx differ diff --git a/examples/pom.xml b/examples/pom.xml index 9c01df16..532f66fe 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -146,6 +146,20 @@ banner.properties + + + ${project.basedir}/../baselines + baselines + false + + current-speed-full.json + + diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java index e384ad02..26d454be 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java @@ -37,9 +37,9 @@ import com.demcha.compose.document.svg.SvgIcon; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; +import com.demcha.examples.support.ExampleVersion; import java.awt.image.BufferedImage; -import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Path; @@ -47,9 +47,6 @@ import java.util.List; import java.util.Locale; import java.util.Objects; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; /** * Parallel 2.0 release concept for the GraphCompose README hero and engine @@ -116,7 +113,7 @@ public final class EngineDeckV2Example { private static final DocumentColor PALE_BLUE = DocumentColor.rgb(232, 243, 255); private static final DocumentColor PALE_MINT = DocumentColor.rgb(230, 249, 242); - private static final String VERSION = loadVersion(); + private static final String VERSION = ExampleVersion.current(); /** * The {@code major.minor} of {@link #VERSION}, for the banner's prose labels. @@ -125,7 +122,7 @@ public final class EngineDeckV2Example { * so a literal would keep announcing whichever line it was typed on — the hero * still read "2.0" while the pill beside it read v2.1.0.

*/ - private static final String VERSION_LINE = majorMinor(VERSION); + private static final String VERSION_LINE = ExampleVersion.majorMinor(VERSION); private EngineDeckV2Example() { } @@ -912,31 +909,6 @@ private static String snapshotDate(EngineDeckData.BenchRun bench) { return timestamp.substring(0, Math.min(10, timestamp.length())); } - /** - * Reduces a version to its {@code major.minor} line for display. - * - * @param version the resolved build version, possibly a qualifier or {@code "dev"} - * @return {@code "2.1"} for {@code "2.1.0"} / {@code "2.1.0-SNAPSHOT"}; the input - * unchanged when it carries no dotted numeric prefix - */ - private static String majorMinor(String version) { - Matcher matcher = Pattern.compile("^(\\d+)\\.(\\d+)").matcher(version); - return matcher.find() ? matcher.group(1) + "." + matcher.group(2) : version; - } - - private static String loadVersion() { - Properties banner = new Properties(); - try (InputStream in = EngineDeckV2Example.class.getResourceAsStream("/banner.properties")) { - if (in != null) { - banner.load(in); - } - } catch (IOException ignored) { - // Fall through to the development label below. - } - String value = banner.getProperty("version"); - return value == null || value.isBlank() || value.startsWith("@") ? "dev" : value.trim(); - } - private static SvgIcon logo() { return icon("logo"); } diff --git a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java index 10d2aef9..73b50cc7 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java @@ -23,8 +23,11 @@ import com.demcha.examples.support.theme.BusinessTheme; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; +import com.demcha.examples.support.ExampleVersion; +import com.demcha.examples.support.PerfBaseline; import java.nio.file.Path; +import java.util.Locale; /** * Kitchen-sink showcase combining the canonical surface end-to-end — @@ -34,10 +37,17 @@ * QR + Code 128 barcodes, document metadata, and header / footer chrome. * *

The rendered PDF reads like a fictional "GraphCompose Q2 Sample - * Report" — three pages of designed content meant to look at a glance + * Report" — designed content meant to look at a glance * like the kind of business document GraphCompose was built to * generate, not a feature checklist. Use it as a reference when * composing your own multi-page documents.

+ * + *

The narrative is deliberately undated. This file is regenerated and + * re-committed on every release, so anything that names a version, a date or a + * measurement would go stale between one release and the next — as it did. What + * survives is either sample copy that cannot age or a figure read at render time + * from {@link com.demcha.examples.support.PerfBaseline} and + * {@link com.demcha.examples.support.ExampleVersion}.

*/ public final class MasterShowcaseExample { private static final BusinessTheme THEME = BusinessTheme.modern(); @@ -90,7 +100,7 @@ static void compose(DocumentSession document) { document.metadata(DocumentMetadata.builder() .title("GraphCompose master showcase") .author("Jordan Rivera") - .subject("Comprehensive end-to-end demo of the v1.5 canonical surface") + .subject("Comprehensive end-to-end demo of the canonical document surface") .keywords("graphcompose, showcase, business, theme, rich-text, table, shape, transform, barcode") .creator("GraphCompose Examples") .producer("GraphCompose") @@ -109,7 +119,7 @@ static void compose(DocumentSession document) { document.footer(DocumentHeaderFooter.builder() .zone(DocumentHeaderFooterZone.FOOTER) - .leftText("v1.5 — \"intuitive\" release") + .leftText("GraphCompose " + ExampleVersion.currentLine() + " — sample report") .rightText("Page {page} of {pages}") .fontSize(9f) .textColor(MUTED) @@ -165,16 +175,17 @@ static void compose(DocumentSession document) { .plain("Status: ") .bold("On track") .plain(" — ") - .accent("675 / 675 tests green", BRAND) - .plain(" — Q2 release window confirmed for ") - .underline("June 2026") + .accent("every gate green", BRAND) + .plain(" — release window confirmed for the ") + .underline("current quarter") .plain(".")) .addRich(rich -> rich .plain("Performance: ") .color("invoice-template", BRAND_DEEP) - .plain(" 13.4 ms avg, 75 docs/sec; ") + .plain(" " + speed("invoice-template") + "; ") .color("feature-rich", BRAND_DEEP) - .plain(" 36.8 ms avg, 27 docs/sec."))) + .plain(" " + speed("feature-rich") + ". One machine, " + + PerfBaseline.get().capturedOn() + "."))) .addSection("Seal", section -> section .padding(DocumentInsets.of(2)) .addCircle(118, BRAND, circle -> circle @@ -187,7 +198,7 @@ static void compose(DocumentSession document) { style(FontName.HELVETICA_BOLD, 13, DocumentTextDecoration.BOLD, DocumentColor.WHITE))) - .position(label("Q2 / 2026", + .position(label("SAMPLE", style(FontName.HELVETICA_BOLD, 7.5, DocumentTextDecoration.BOLD, SOFT_GOLD)), @@ -222,7 +233,7 @@ static void compose(DocumentSession document) { .textStyle(THEME.text().h2()) .margin(DocumentInsets.zero())) .addRich(rich -> rich - .plain("v1.5 lands the cinematic features that turn GraphCompose from \"tidy PDF layouter\" into a designed-document engine: ") + .plain("The features below are what turn GraphCompose from a \"tidy PDF layouter\" into a designed-document engine: ") .bold("shape-as-container with clip path") .plain(", ") .bold("rotate / scale + per-layer z-index") @@ -314,7 +325,7 @@ static void compose(DocumentSession document) { .addSection("Card3", section -> highlightCard(section, "Transformable mixin", "rotate / scale on every shape", - "v1.5 extends Transformable to every leaf builder so any shape rotates around its placement centre."))) + "Transformable reaches every leaf builder, so any shape rotates around its placement centre."))) // ───── Action items + status legend ───── .addSection("ActionItems", section -> section @@ -328,9 +339,9 @@ static void compose(DocumentSession document) { .textStyle(THEME.text().h2()) .margin(DocumentInsets.zero())) .addRich(rich -> rich - .plain("• Tag ") - .bold("v1.5.0") - .plain(" on main once develop is merged through PR. ") + .plain("• Publish the release notes once the branch ") + .bold("merges through review") + .plain(". ") .accent("Owner: maintainer", BRAND) .plain(".")) .addRich(rich -> rich @@ -360,7 +371,7 @@ static void compose(DocumentSession document) { .padding(DocumentInsets.zero()) .addBarcode(barcode -> barcode .code128() - .data("GC-MASTER-Q2-2026") + .data("GC-MASTER-SAMPLE") .foreground(BRAND) .size(180, 36)))) .build(); @@ -403,6 +414,23 @@ private static DocumentNode label(String text, DocumentTextStyle style) { .build(); } + /** + * One scenario's measured speed, phrased for the report. + * + *

Restates the committed baseline rather than a literal, so the figure + * moves when the measurement does instead of when somebody remembers.

+ * + * @param scenario benchmark scenario name + * @return e.g. {@code "6.6 ms avg, 152 docs/sec"}, or a plain note when the + * baseline does not carry the scenario + */ + private static String speed(String scenario) { + return PerfBaseline.get().scenario(scenario) + .map(measured -> String.format(Locale.ROOT, "%.1f ms avg, %.0f docs/sec", + measured.avgMillis(), measured.docsPerSecond())) + .orElse("not measured"); + } + private static DocumentTextStyle label() { return DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) diff --git a/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java b/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java new file mode 100644 index 00000000..76003677 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java @@ -0,0 +1,71 @@ +package com.demcha.examples.support; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The reactor version, for examples that print it. + * + *

Read from the filtered {@code banner.properties} rather than written out, + * because example documents are regenerated on every release: a literal keeps + * announcing whichever line it was typed on. Shared so the value cannot drift + * between the documents that show it.

+ */ +public final class ExampleVersion { + + private static final Pattern MAJOR_MINOR = Pattern.compile("^(\\d+)\\.(\\d+)"); + + private static final String CURRENT = load(); + + private ExampleVersion() { + } + + /** + * The full reactor version, or {@code "dev"} when the resource is absent or + * unfiltered (running straight from sources). + * + * @return version string + */ + public static String current() { + return CURRENT; + } + + /** + * The {@code major.minor} of {@link #current()}, for prose that names a line + * rather than a patch. + * + * @return the leading {@code major.minor}, or the version unchanged when it + * carries no dotted numeric prefix + */ + public static String currentLine() { + return majorMinor(CURRENT); + } + + /** + * Reduces a version to its {@code major.minor} prefix. + * + * @param version version string to reduce + * @return the leading {@code major.minor}, or {@code version} unchanged when + * it carries no dotted numeric prefix + */ + public static String majorMinor(String version) { + Matcher matcher = MAJOR_MINOR.matcher(version); + return matcher.find() ? matcher.group(1) + "." + matcher.group(2) : version; + } + + private static String load() { + Properties banner = new Properties(); + try (InputStream in = ExampleVersion.class.getResourceAsStream("/banner.properties")) { + if (in != null) { + banner.load(in); + } + } catch (IOException ignored) { + // Fall through to the development label below. + } + String value = banner.getProperty("version"); + return value == null || value.isBlank() || value.startsWith("@") ? "dev" : value.trim(); + } +} diff --git a/examples/src/main/java/com/demcha/examples/support/PerfBaseline.java b/examples/src/main/java/com/demcha/examples/support/PerfBaseline.java new file mode 100644 index 00000000..3e1da248 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/support/PerfBaseline.java @@ -0,0 +1,117 @@ +package com.demcha.examples.support; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * The committed current-speed baseline, for examples that quote engine speed. + * + *

Reads {@code baselines/current-speed-full.json} — the same file the local + * perf gate scores against, put on the classpath by the examples module rather + * than copied, so there is one number and not two that drift. An example that + * quotes a figure therefore restates measured data instead of asserting a + * literal somebody typed once.

+ * + *

The figures are one machine's median over several runs. Absolute + * milliseconds do not travel between machines, so anything rendering them + * should show {@link #capturedOn()} beside them and let the reader judge.

+ * + *

Loading never throws: a resource that is missing, unreadable or malformed + * yields an empty baseline, and callers render a plain "not measured" rather + * than a number nobody measured. {@code PerfBaselineTest} keeps that fallback + * from becoming the normal case by asserting the resource really is on the + * classpath with the scenarios the examples quote.

+ */ +public final class PerfBaseline { + + private static final String RESOURCE = "/baselines/current-speed-full.json"; + + private static final PerfBaseline EMPTY = new PerfBaseline("undated", Map.of()); + + private static final PerfBaseline INSTANCE = load(); + + private final String capturedOn; + private final Map scenarios; + + private PerfBaseline(String capturedOn, Map scenarios) { + this.capturedOn = capturedOn; + this.scenarios = scenarios; + } + + /** + * One scenario's measured speed. + * + * @param avgMillis average render latency in milliseconds + * @param docsPerSecond documents rendered per second + */ + public record Scenario(double avgMillis, double docsPerSecond) { + } + + /** + * The bundled baseline. + * + * @return the loaded baseline + */ + public static PerfBaseline get() { + return INSTANCE; + } + + /** + * The date the baseline was measured, as {@code yyyy-MM-dd}. + * + * @return capture date, or {@code "undated"} when the file carries no timestamp + */ + public String capturedOn() { + return capturedOn; + } + + /** + * Looks up one scenario by its benchmark name. + * + * @param scenario scenario name, e.g. {@code invoice-template} + * @return the scenario's figures, empty when the baseline does not carry it + */ + public Optional scenario(String scenario) { + return Optional.ofNullable(scenarios.get(scenario)); + } + + private static PerfBaseline load() { + // Never throws. This runs in a static initializer, so a malformed file + // would otherwise fail class-load and take down every example in the + // module, not just the one quoting a figure. Absent and unreadable + // degrade the same way, and the caller renders the honest fallback. + try (InputStream in = PerfBaseline.class.getResourceAsStream(RESOURCE)) { + if (in == null) { + return EMPTY; + } + JsonNode root = new ObjectMapper().readTree(in); + Map scenarios = new HashMap<>(); + for (JsonNode row : root.path("latency")) { + String name = row.path("scenario").asText(""); + // Require both figures to be present and numeric: a renamed or + // dropped field would otherwise read as 0.0 and render a + // plausible "0.0 ms avg, 0 docs/sec" instead of admitting the + // measurement is missing. + if (name.isEmpty() + || !row.path("avgMillis").isNumber() + || !row.path("docsPerSecond").isNumber()) { + continue; + } + scenarios.put(name, new Scenario(row.path("avgMillis").asDouble(), + row.path("docsPerSecond").asDouble())); + } + String timestamp = root.path("timestamp").asText(""); + return new PerfBaseline( + timestamp.length() >= 10 ? timestamp.substring(0, 10) : "undated", + Map.copyOf(scenarios)); + } catch (IOException | RuntimeException e) { + return EMPTY; + } + } +} diff --git a/examples/src/test/java/com/demcha/examples/support/PerfBaselineTest.java b/examples/src/test/java/com/demcha/examples/support/PerfBaselineTest.java new file mode 100644 index 00000000..7f1164bc --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/support/PerfBaselineTest.java @@ -0,0 +1,48 @@ +package com.demcha.examples.support; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps the honest fallback from quietly becoming the normal case. + * + *

{@link PerfBaseline} degrades to "not measured" when its resource is + * absent, which is the right behaviour at render time and the wrong thing to + * discover in a published PDF. The baseline reaches the classpath through a + * resource directory in {@code examples/pom.xml} that points outside the + * module; rename the file, move the directory, or drop that pom entry and every + * figure in the master showcase silently turns into a placeholder while the + * build stays green. This is the test that would go red instead.

+ */ +class PerfBaselineTest { + + @Test + void theCommittedBaselineIsOnTheClasspath() { + assertThat(PerfBaseline.get().capturedOn()) + .describedAs("baseline resource missing from the examples classpath - " + + "check the baselines resource entry in examples/pom.xml") + .matches("\\d{4}-\\d{2}-\\d{2}"); + } + + @Test + void itCarriesTheScenariosTheShowcaseQuotes() { + for (String scenario : new String[] {"invoice-template", "feature-rich"}) { + assertThat(PerfBaseline.get().scenario(scenario)) + .describedAs("MasterShowcaseExample renders '%s', so the baseline must carry it", + scenario) + .hasValueSatisfying(measured -> { + assertThat(measured.avgMillis()).isPositive(); + assertThat(measured.docsPerSecond()).isPositive(); + }); + } + } + + @Test + void anUnknownScenarioIsAbsentRatherThanZero() { + assertThat(PerfBaseline.get().scenario("no-such-scenario")) + .describedAs("an unknown scenario must be empty so the caller can say " + + "'not measured' instead of rendering 0.0 ms") + .isEmpty(); + } +} diff --git a/web/showcase/pdf/flagships/default/master-showcase.pdf b/web/showcase/pdf/flagships/default/master-showcase.pdf index 0c1fbbeb..0ca75ac5 100644 Binary files a/web/showcase/pdf/flagships/default/master-showcase.pdf and b/web/showcase/pdf/flagships/default/master-showcase.pdf differ diff --git a/web/showcase/pptx/flagships/default/master-showcase.pptx b/web/showcase/pptx/flagships/default/master-showcase.pptx index 0be32967..56a8347a 100644 Binary files a/web/showcase/pptx/flagships/default/master-showcase.pptx and b/web/showcase/pptx/flagships/default/master-showcase.pptx differ diff --git a/web/showcase/screenshots/flagships/default/master-showcase.png b/web/showcase/screenshots/flagships/default/master-showcase.png index cb73361f..cef27d63 100644 Binary files a/web/showcase/screenshots/flagships/default/master-showcase.png and b/web/showcase/screenshots/flagships/default/master-showcase.png differ