diff --git a/CHANGELOG.md b/CHANGELOG.md index c316e5d9..e265feab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -241,6 +241,17 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **The Maven Central banner became a deck.** It was one 16:9 slide: wordmark, + coordinate, capability tags and a code → layout → document diagram. It now carries + three more pages in the same amber-on-navy language — the authoring pipeline and what + each step guarantees, the measured comparison against iText 9 and JasperReports, and + how all three behave as the report grows from 40 to 1000 rows. Every figure on the + last two pages is read from the committed benchmark file at render time rather than + typed into the layout. The `.pptx` gains the same three slides; the PNG preview stays + the banner alone, since that is what it is for. `MavenBannerNativeShapeTest` now walks + all four slides and holds the whole deck to one rasterised element — the SVG + checkmark — so the table and both charts have to arrive as native shapes rather than + as an embedded image, which is the claim the last page makes. - **The PowerShell commands run as written.** Two pages handed Windows readers a command that fails. PowerShell splits a `-D` flag whose property name contains a dot, passing the native command `-Dexec` and `.mainClass=…` as separate arguments, and diff --git a/assets/readme/examples/maven-banner.pdf b/assets/readme/examples/maven-banner.pdf index 97d307fc..119c40d1 100644 Binary files a/assets/readme/examples/maven-banner.pdf and b/assets/readme/examples/maven-banner.pdf differ diff --git a/assets/readme/examples/maven-banner.pptx b/assets/readme/examples/maven-banner.pptx index 61e8386c..19d63c1a 100644 Binary files a/assets/readme/examples/maven-banner.pptx and b/assets/readme/examples/maven-banner.pptx differ diff --git a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java index fbb3b806..ea1fa979 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java @@ -3,11 +3,22 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.chart.AxisSpec; +import com.demcha.compose.document.chart.ChartData; +import com.demcha.compose.document.chart.ChartSize; +import com.demcha.compose.document.chart.ChartSpec; +import com.demcha.compose.document.chart.ChartStyle; +import com.demcha.compose.document.chart.LegendPosition; +import com.demcha.compose.document.chart.ValueLabelMode; import com.demcha.compose.document.dsl.EllipseBuilder; import com.demcha.compose.document.dsl.ParagraphBuilder; import com.demcha.compose.document.dsl.PathBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; import com.demcha.compose.document.dsl.ShapeBuilder; import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; import com.demcha.compose.document.node.CanvasChild; import com.demcha.compose.document.node.CanvasLayerNode; import com.demcha.compose.document.node.DocumentNode; @@ -65,6 +76,29 @@ public final class MavenBannerPptxExample { private static final DocumentColor ORANGE = DocumentColor.rgb(233, 151, 45); private static final DocumentColor ORANGE_HI = DocumentColor.rgb(242, 168, 68); + /** + * Chart series colours for the two rivals. + * + *
Three series on one chart need three hues, not three greys. Slate and a + * near-slate violet were close enough that the iText and JasperReports lines + * were unreadable where they crossed, so the third series is green — far from + * both the amber GraphCompose line and the slate one, on a navy field.
+ */ + private static final DocumentColor SERIES_SLATE = DocumentColor.rgb(122, 133, 168); + private static final DocumentColor SERIES_GREEN = DocumentColor.rgb(93, 200, 143); + + /** + * Table banding. + * + *Reusing the two card fills gave a stripe five units apart per channel — + * applied, measurable, and invisible. On a near-black field the eye needs a + * wider step than it does on paper, so these are set for the contrast rather + * than borrowed from the cards. The header sits above both.
+ */ + private static final DocumentColor HEADER_BG = DocumentColor.rgb(42, 53, 88); + private static final DocumentColor ROW_DARK = DocumentColor.rgb(19, 25, 45); + private static final DocumentColor ROW_LIGHT = DocumentColor.rgb(29, 38, 66); + // Text. private static final DocumentColor WHITE_TX = DocumentColor.rgb(247, 248, 252); private static final DocumentColor SUBTLE_TX = DocumentColor.rgb(206, 212, 230); @@ -163,10 +197,299 @@ static void compose(DocumentSession document) { .build()); document.pageFlow() .name("MavenBanner") + .spacing(0) .add(banner()) + .addPageBreak(b -> b.name("ToPipeline")) + .addSection("Pipeline", MavenBannerPptxExample::pipelinePage) + .addPageBreak(b -> b.name("ToMeasured")) + .addSection("Measured", MavenBannerPptxExample::measuredPage) + .addPageBreak(b -> b.name("ToScaling")) + .addSection("Scaling", MavenBannerPptxExample::scalingPage) .build(); } + // --------------------------------------------------------------------- + // Pages 2–4. + // + // Page 1 is a canvas: every element is placed by hand because the banner is + // a poster. These are not. They carry a table and four charts, which want a + // flow that measures them — so they are ordinary sections on the same night + // field the page background already paints, styled from the same tokens. + // --------------------------------------------------------------------- + + /** Page 2 — the authoring pipeline, four steps and what each one guarantees. */ + private static void pipelinePage(SectionBuilder page) { + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); + kicker(page, "HOW IT WORKS", "From one Java file to a finished document"); + lede(page, "You describe the document semantically — sections, rows, tables, charts, " + + "shapes, layers — and the engine resolves the rest. No manual coordinates, " + + "no XML templates, no per-format authoring."); + + page.addRow("Steps", row -> { + row.spacing(14).evenWeights().margin(DocumentInsets.top(AFTER_LEDE)); + stepCard(row, "1", "AUTHOR", "A fluent DSL describes intent, not geometry."); + stepCard(row, "2", "MEASURE", "Every node measured twice, deterministically."); + stepCard(row, "3", "PAGINATE", "The flow splits across pages row by row."); + stepCard(row, "4", "RENDER", "A backend writes the bytes — PDF or PPTX."); + }); + + page.addRow("Guarantees", row -> { + // Row to row, not lede to row: the card above brings its own bottom + // padding, so this reads as the same gap at a smaller number. + row.spacing(14).evenWeights().margin(DocumentInsets.top(22)); + proofCard(row, "Deterministic", + "The same input renders the same bytes on every machine. Layout is " + + "reproducible, not best-effort."); + proofCard(row, "Regression-tested", + "layoutSnapshot() captures the resolved geometry, so a layout change " + + "fails a test instead of surfacing in a release."); + proofCard(row, "One model, two formats", + "The same composition emits this PDF and an editable PPTX. Nothing is " + + "authored twice, so the two cannot drift."); + }); + } + + /** Page 3 — the measured comparison, drawn from the committed benchmark file. */ + private static void measuredPage(SectionBuilder page) { + EngineDeckData.BenchRun bench = EngineDeckData.loadBench(); + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); + kicker(page, "MEASURED, NOT CLAIMED", "GraphCompose against the field"); + lede(page, "The same documents through three engines — render time and peak heap, " + + "measured by this repository's own harness. Every figure on this page and the " + + "next is read from the result file at render time, not typed in."); + + page.addTable(table -> { + table.name("Comparison") + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), + DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .headerStyle(cellStyle(sansBold(12, WHITE_TX), HEADER_BG)) + .defaultCellStyle(cellStyle(sans(12, SUBTLE_TX), CARD)) + // Full styles rather than the fill-only overload: a zebra style is + // registered as the row's style, so anything it leaves out is + // anything the row loses — including the text colour, which on this + // field is the difference between readable and invisible. + .zebra(cellStyle(sans(12, SUBTLE_TX), ROW_DARK), + cellStyle(sans(12, SUBTLE_TX), ROW_LIGHT)) + .headerRow("Report size", "GraphCompose", "iText 9", "JasperReports") + .row("1 page · 3 lines", + cell(bench.timeMs("GraphCompose Canonical"), bench.heapMb("GraphCompose Canonical")), + cell(bench.timeMs("iText 9"), bench.heapMb("iText 9")), + cell(bench.timeMs("JasperReports"), bench.heapMb("JasperReports"))); + for (int size : new int[]{40, 200, 1000}) { + table.row(size + " rows", + cell(bench.timeMs("GraphCompose", size), bench.heapMb("GraphCompose", size)), + cell(bench.timeMs("iText 9", size), bench.heapMb("iText 9", size)), + cell(bench.timeMs("JasperReports", size), bench.heapMb("JasperReports", size))); + } + table.margin(DocumentInsets.top(AFTER_LEDE)); + }); + + page.addRow("Headline", row -> { + row.spacing(14).evenWeights().margin(DocumentInsets.top(20)); + ratioCard(row, times(bench.timeMs("iText 9", 1000) / bench.timeMs("GraphCompose", 1000)), + "faster than iText 9 at 1000 rows", ORANGE); + ratioCard(row, times(bench.heapMb("iText 9", 1000) / bench.heapMb("GraphCompose", 1000)), + "lighter than iText 9", SERIES_SLATE); + ratioCard(row, times(bench.heapMb("JasperReports", 1000) / bench.heapMb("GraphCompose", 1000)), + "lighter than JasperReports", SERIES_GREEN); + }); + + page.addParagraph(p -> p + .text("Measured " + bench.timestamp() + " · " + bench.warmup() + " warmup / " + + bench.measure() + " measurement iterations on one machine. Read the " + + "ratios rather than the milliseconds: all three engines are measured " + + "in the same run, so their proportions survive a change of hardware " + + "while the timings do not.") + .textStyle(sans(9.5, FAINT_TX)) + .lineSpacing(3) + .margin(DocumentInsets.top(18))); + } + + /** Page 4 — how the three engines behave as the report grows. */ + private static void scalingPage(SectionBuilder page) { + EngineDeckData.BenchRun bench = EngineDeckData.loadBench(); + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); + kicker(page, "SCALING", "What changes as the report grows"); + lede(page, "From 40 to 1000 rows the time lead over iText widens; JasperReports closes " + + "to roughly the same render time at the top size, while GraphCompose stays " + + "markedly lighter on memory than both throughout. Every series reads from the " + + "same file as the table on the previous page."); + + page.addRow("ScalingCharts", row -> { + row.spacing(20).evenWeights().margin(DocumentInsets.top(AFTER_LEDE)); + chartCard(row, "Render time vs. report size — ms", "lower is faster", + seriesOf(bench, true)); + chartCard(row, "Peak heap vs. report size — MB", "lower is lighter", + seriesOf(bench, false)); + }); + + page.addParagraph(p -> p + .text("Both charts are native vector output: the engine compiles them to the same " + + "shapes, lines and text frames as everything else on the page — no " + + "image is embedded, and the numbers come from the file, not the caption.") + .textStyle(sans(9.5, FAINT_TX)) + .lineSpacing(3) + .margin(DocumentInsets.top(20))); + } + + // --------------------------------------------------------------------- + // Page furniture for 2–4. + // --------------------------------------------------------------------- + + /** + * Inner width of a card in a four-across / three-across row. + * + *A section measures to its longest line, so a row of cards whose text + * happens to be shorter renders with ragged edges. A zero-height spacer of + * this width sets the floor and the row lines up.
+ */ + private static final double CONTENT_W = PAGE_W - 104; + private static final double STEP_INNER = (CONTENT_W - 3 * 14) / 4 - 32; + private static final double TRIPLE_INNER = (CONTENT_W - 2 * 14) / 3 - 32; + + /** + * Space between the lede and the panels under it. + * + *Larger than the gap between two rows of cards, and deliberately so. A card + * carries its own bottom padding, so between two rows the eye sees that padding + * plus the margin; between the lede and the first row it sees the margin alone. + * Equal margins therefore read as unequal, and the text looked stuck to the + * cards. This is the row gap plus a card's padding, so both gaps land the same.
+ */ + private static final double AFTER_LEDE = 37; + + private static void kicker(SectionBuilder page, String eyebrow, String headline) { + page.addParagraph(p -> p.text(eyebrow).textStyle(monoBold(11, ORANGE))); + page.addParagraph(p -> p + .text(headline) + .textStyle(sansBold(30, WHITE_TX)) + .margin(DocumentInsets.top(6))); + page.addShape(sh -> sh.size(96, 4).fillColor(ORANGE).margin(DocumentInsets.top(12))); + } + + private static void lede(SectionBuilder page, String text) { + page.addParagraph(p -> p + .text(text) + .textStyle(sans(13, SUBTLE_TX)) + .lineSpacing(4) + .margin(DocumentInsets.top(16))); + } + + private static void stepCard(RowBuilder row, String number, String title, String desc) { + row.addSection("Step" + number, s -> s + .softPanel(CARD, 10, 16) + .stroke(DocumentStroke.of(BORDER, 1)) + .spacing(0) + .addSpacer(sp -> sp.size(STEP_INNER, 0)) + .addParagraph(p -> p.text(number).textStyle(monoBold(20, ORANGE))) + .addParagraph(p -> p.text(title) + .textStyle(sansBold(13, WHITE_TX)) + .margin(DocumentInsets.top(8))) + .addParagraph(p -> p.text(desc) + .textStyle(sans(11, MUTED_TX)) + .lineSpacing(3) + .margin(DocumentInsets.top(6)))); + } + + private static void proofCard(RowBuilder row, String title, String desc) { + row.addSection("Proof" + title, s -> s + .softPanel(CARD_2, 10, 16) + .accentTop(ORANGE, 3) + .spacing(0) + .addSpacer(sp -> sp.size(TRIPLE_INNER, 0)) + .addParagraph(p -> p.text(title).textStyle(sansBold(13, WHITE_TX))) + .addParagraph(p -> p.text(desc) + .textStyle(sans(11, MUTED_TX)) + .lineSpacing(3) + .margin(DocumentInsets.top(7)))); + } + + private static void ratioCard(RowBuilder row, String figure, String label, + DocumentColor accent) { + row.addSection("Ratio" + label, s -> s + .softPanel(CARD, 10, 16) + .stroke(DocumentStroke.of(BORDER, 1)) + .spacing(0) + .addSpacer(sp -> sp.size(TRIPLE_INNER, 0)) + .accentTop(accent, 3) + .addParagraph(p -> p.text(figure).textStyle(sansBold(28, accent))) + .addParagraph(p -> p.text(label) + .textStyle(sans(11, MUTED_TX)) + .lineSpacing(3) + .margin(DocumentInsets.top(4)))); + } + + private static void chartCard(RowBuilder row, String title, String note, ChartSpec spec) { + row.addSection("Chart" + title, s -> s + .softPanel(CARD, 10, 16) + .stroke(DocumentStroke.of(BORDER, 1)) + .spacing(0) + .addParagraph(p -> p.text(title).textStyle(sansBold(12, WHITE_TX))) + .addParagraph(p -> p.text(note) + .textStyle(sans(10, FAINT_TX)) + .margin(DocumentInsets.top(3))) + .chart(spec, chartStyle())); + } + + /** Time or heap for all three engines across the row-count sweep. */ + private static ChartSpec seriesOf(EngineDeckData.BenchRun bench, boolean time) { + ChartData.Builder data = ChartData.builder().categories("40", "200", "1000"); + for (String lib : new String[]{"GraphCompose", "iText 9", "JasperReports"}) { + data.series(lib, + time ? bench.timeMs(lib, 40) : bench.heapMb(lib, 40), + time ? bench.timeMs(lib, 200) : bench.heapMb(lib, 200), + time ? bench.timeMs(lib, 1000) : bench.heapMb(lib, 1000)); + } + return ChartSpec.line() + .data(data.build()) + .legend(LegendPosition.BOTTOM) + .size(ChartSize.aspectRatio(16, 7)) + .build(); + } + + /** + * The chart palette, matched to the banner: GraphCompose in amber, the two + * rivals in the muted greys the cards already use, so the reader's eye lands + * on the series the document is about without the chart shouting. + */ + private static ChartStyle chartStyle() { + return ChartStyle.builder() + .seriesPaint(0, DocumentPaint.solid(ORANGE)) + .seriesPaint(1, DocumentPaint.solid(SERIES_SLATE)) + .seriesPaint(2, DocumentPaint.solid(SERIES_GREEN)) + .barCornerRadius(DocumentCornerRadius.top(3)) + .barWidthRatio(0.5) + .lineWidth(2.0) + .axisTextStyle(mono(8.5, MUTED_TX)) + .legendTextStyle(sans(9, MUTED_TX)) + .valueLabelTextStyle(monoBold(10, ORANGE)) + // The labels sit over the card, not a white page: match the halo to + // the card fill so a gridline stops behind the digits instead of a + // white chip appearing on the navy. + .valueLabelHalo(DocumentPaint.solid(CARD)) + .grid(ChartStyle.GridStyle.horizontal(DocumentStroke.of(BORDER, 0.6))) + .build(); + } + + /** A table cell on the night field: the card fill behind the given text style. */ + private static DocumentTableStyle cellStyle(DocumentTextStyle text, DocumentColor fill) { + return DocumentTableStyle.builder() + .textStyle(text) + .fillColor(fill) + .stroke(DocumentStroke.of(BORDER, 0.8)) + .padding(DocumentInsets.symmetric(5, 10)) + .build(); + } + + /** {@code 40.0 ms · 19.3 MB} — one cell carrying both measurements. */ + private static String cell(double ms, double mb) { + return String.format(java.util.Locale.ROOT, "%.1f ms · %.1f MB", ms, mb); + } + + private static String times(double ratio) { + return String.format(java.util.Locale.ROOT, "%.1fx", ratio); + } + // --------------------------------------------------------------------- // Full-bleed banner scene. // --------------------------------------------------------------------- diff --git a/examples/src/test/java/com/demcha/examples/flagships/MavenBannerNativeShapeTest.java b/examples/src/test/java/com/demcha/examples/flagships/MavenBannerNativeShapeTest.java index 9c6e3800..c728cf4e 100644 --- a/examples/src/test/java/com/demcha/examples/flagships/MavenBannerNativeShapeTest.java +++ b/examples/src/test/java/com/demcha/examples/flagships/MavenBannerNativeShapeTest.java @@ -16,16 +16,24 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Guards the Maven-banner slide's editability promise at the PPTX structural - * level: the panels, tags, code, diagram and connectors land as native shapes - * and text, and the only rasterised element is the SVG checkmark in the "Maven - * Central" badge. A clip-safety or SVG-handling regression that flattened the - * panels into pictures would silently falsify that promise — this catches it. + * Guards the Maven deck's editability promise at the PPTX structural level: the + * panels, tags, code, diagram, connectors, table and charts land as native shapes + * and text, and the only rasterised element in the whole deck is the SVG checkmark + * in the "Maven Central" badge on slide 1. A clip-safety or SVG-handling regression + * that flattened any of it into pictures would silently falsify that promise. + * + *The charts matter most here. They are compiled to the same primitives as + * everything else, so a deck claiming "native vector output" has to survive the + * round trip as shapes rather than as an embedded image — which is exactly what a + * chart-rendering shortcut would break, and nothing else would notice.
*/ class MavenBannerNativeShapeTest { + /** Banner, pipeline, measured, scaling. */ + private static final int SLIDES = 4; + @Test - void theSlideStaysNativeApartFromTheCheckIcon() throws Exception { + void everySlideStaysNativeApartFromTheCheckIcon() throws Exception { try (DocumentSession document = GraphCompose.document() .pageSize(DocumentPageSize.SLIDE_16_9) .pageBackground(DocumentColor.rgb(13, 17, 33)) @@ -34,14 +42,25 @@ void theSlideStaysNativeApartFromTheCheckIcon() throws Exception { MavenBannerPptxExample.compose(document); byte[] deck = document.toPptxBytes(); try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(deck))) { - assertThat(show.getSlides()).hasSize(1); - List