|
3 | 3 | import com.demcha.compose.GraphCompose; |
4 | 4 | import com.demcha.compose.document.api.DocumentPageSize; |
5 | 5 | import com.demcha.compose.document.api.DocumentSession; |
| 6 | +import com.demcha.compose.document.chart.AxisSpec; |
| 7 | +import com.demcha.compose.document.chart.ChartData; |
| 8 | +import com.demcha.compose.document.chart.ChartSize; |
| 9 | +import com.demcha.compose.document.chart.ChartSpec; |
| 10 | +import com.demcha.compose.document.chart.ChartStyle; |
| 11 | +import com.demcha.compose.document.chart.LegendPosition; |
| 12 | +import com.demcha.compose.document.chart.ValueLabelMode; |
6 | 13 | import com.demcha.compose.document.dsl.EllipseBuilder; |
7 | 14 | import com.demcha.compose.document.dsl.ParagraphBuilder; |
8 | 15 | import com.demcha.compose.document.dsl.PathBuilder; |
| 16 | +import com.demcha.compose.document.dsl.RowBuilder; |
| 17 | +import com.demcha.compose.document.dsl.SectionBuilder; |
9 | 18 | import com.demcha.compose.document.dsl.ShapeBuilder; |
10 | 19 | import com.demcha.compose.document.dsl.ShapeContainerBuilder; |
| 20 | +import com.demcha.compose.document.table.DocumentTableColumn; |
| 21 | +import com.demcha.compose.document.table.DocumentTableStyle; |
11 | 22 | import com.demcha.compose.document.node.CanvasChild; |
12 | 23 | import com.demcha.compose.document.node.CanvasLayerNode; |
13 | 24 | import com.demcha.compose.document.node.DocumentNode; |
@@ -65,6 +76,17 @@ public final class MavenBannerPptxExample { |
65 | 76 | private static final DocumentColor ORANGE = DocumentColor.rgb(233, 151, 45); |
66 | 77 | private static final DocumentColor ORANGE_HI = DocumentColor.rgb(242, 168, 68); |
67 | 78 |
|
| 79 | + /** |
| 80 | + * Chart series colours for the two rivals. |
| 81 | + * |
| 82 | + * <p>Three series on one chart need three hues, not three greys. Slate and a |
| 83 | + * near-slate violet were close enough that the iText and JasperReports lines |
| 84 | + * were unreadable where they crossed, so the third series is green — far from |
| 85 | + * both the amber GraphCompose line and the slate one, on a navy field.</p> |
| 86 | + */ |
| 87 | + private static final DocumentColor SERIES_SLATE = DocumentColor.rgb(122, 133, 168); |
| 88 | + private static final DocumentColor SERIES_GREEN = DocumentColor.rgb(93, 200, 143); |
| 89 | + |
68 | 90 | // Text. |
69 | 91 | private static final DocumentColor WHITE_TX = DocumentColor.rgb(247, 248, 252); |
70 | 92 | private static final DocumentColor SUBTLE_TX = DocumentColor.rgb(206, 212, 230); |
@@ -163,10 +185,291 @@ static void compose(DocumentSession document) { |
163 | 185 | .build()); |
164 | 186 | document.pageFlow() |
165 | 187 | .name("MavenBanner") |
| 188 | + .spacing(0) |
166 | 189 | .add(banner()) |
| 190 | + .addPageBreak(b -> b.name("ToPipeline")) |
| 191 | + .addSection("Pipeline", MavenBannerPptxExample::pipelinePage) |
| 192 | + .addPageBreak(b -> b.name("ToMeasured")) |
| 193 | + .addSection("Measured", MavenBannerPptxExample::measuredPage) |
| 194 | + .addPageBreak(b -> b.name("ToScaling")) |
| 195 | + .addSection("Scaling", MavenBannerPptxExample::scalingPage) |
167 | 196 | .build(); |
168 | 197 | } |
169 | 198 |
|
| 199 | + // --------------------------------------------------------------------- |
| 200 | + // Pages 2–4. |
| 201 | + // |
| 202 | + // Page 1 is a canvas: every element is placed by hand because the banner is |
| 203 | + // a poster. These are not. They carry a table and four charts, which want a |
| 204 | + // flow that measures them — so they are ordinary sections on the same night |
| 205 | + // field the page background already paints, styled from the same tokens. |
| 206 | + // --------------------------------------------------------------------- |
| 207 | + |
| 208 | + /** Page 2 — the authoring pipeline, four steps and what each one guarantees. */ |
| 209 | + private static void pipelinePage(SectionBuilder page) { |
| 210 | + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); |
| 211 | + kicker(page, "HOW IT WORKS", "From one Java file to a finished document"); |
| 212 | + lede(page, "You describe the document semantically — sections, rows, tables, charts, " |
| 213 | + + "shapes, layers — and the engine resolves the rest. No manual coordinates, " |
| 214 | + + "no XML templates, no per-format authoring."); |
| 215 | + |
| 216 | + page.addRow("Steps", row -> { |
| 217 | + row.spacing(14).evenWeights().margin(DocumentInsets.top(AFTER_LEDE)); |
| 218 | + stepCard(row, "1", "AUTHOR", "A fluent DSL describes intent, not geometry."); |
| 219 | + stepCard(row, "2", "MEASURE", "Every node measured twice, deterministically."); |
| 220 | + stepCard(row, "3", "PAGINATE", "The flow splits across pages row by row."); |
| 221 | + stepCard(row, "4", "RENDER", "A backend writes the bytes — PDF or PPTX."); |
| 222 | + }); |
| 223 | + |
| 224 | + page.addRow("Guarantees", row -> { |
| 225 | + // Row to row, not lede to row: the card above brings its own bottom |
| 226 | + // padding, so this reads as the same gap at a smaller number. |
| 227 | + row.spacing(14).evenWeights().margin(DocumentInsets.top(22)); |
| 228 | + proofCard(row, "Deterministic", |
| 229 | + "The same input renders the same bytes on every machine. Layout is " |
| 230 | + + "reproducible, not best-effort."); |
| 231 | + proofCard(row, "Regression-tested", |
| 232 | + "layoutSnapshot() captures the resolved geometry, so a layout change " |
| 233 | + + "fails a test instead of surfacing in a release."); |
| 234 | + proofCard(row, "One model, two formats", |
| 235 | + "The same composition emits this PDF and an editable PPTX. Nothing is " |
| 236 | + + "authored twice, so the two cannot drift."); |
| 237 | + }); |
| 238 | + } |
| 239 | + |
| 240 | + /** Page 3 — the measured comparison, drawn from the committed benchmark file. */ |
| 241 | + private static void measuredPage(SectionBuilder page) { |
| 242 | + EngineDeckData.BenchRun bench = EngineDeckData.loadBench(); |
| 243 | + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); |
| 244 | + kicker(page, "MEASURED, NOT CLAIMED", "GraphCompose against the field"); |
| 245 | + lede(page, "The same documents through three engines — render time and peak heap, " |
| 246 | + + "measured by this repository's own harness. Every figure on this page and the " |
| 247 | + + "next is read from the result file at render time, not typed in."); |
| 248 | + |
| 249 | + page.addTable(table -> { |
| 250 | + table.name("Comparison") |
| 251 | + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), |
| 252 | + DocumentTableColumn.auto(), DocumentTableColumn.auto()) |
| 253 | + .headerStyle(cellStyle(sansBold(12, WHITE_TX), CARD_2)) |
| 254 | + .defaultCellStyle(cellStyle(sans(12, SUBTLE_TX), CARD)) |
| 255 | + .headerRow("Report size", "GraphCompose", "iText 9", "JasperReports") |
| 256 | + .row("1 page · 3 lines", |
| 257 | + cell(bench.timeMs("GraphCompose Canonical"), bench.heapMb("GraphCompose Canonical")), |
| 258 | + cell(bench.timeMs("iText 9"), bench.heapMb("iText 9")), |
| 259 | + cell(bench.timeMs("JasperReports"), bench.heapMb("JasperReports"))); |
| 260 | + for (int size : new int[]{40, 200, 1000}) { |
| 261 | + table.row(size + " rows", |
| 262 | + cell(bench.timeMs("GraphCompose", size), bench.heapMb("GraphCompose", size)), |
| 263 | + cell(bench.timeMs("iText 9", size), bench.heapMb("iText 9", size)), |
| 264 | + cell(bench.timeMs("JasperReports", size), bench.heapMb("JasperReports", size))); |
| 265 | + } |
| 266 | + table.margin(DocumentInsets.top(AFTER_LEDE)); |
| 267 | + }); |
| 268 | + |
| 269 | + page.addRow("Headline", row -> { |
| 270 | + row.spacing(14).evenWeights().margin(DocumentInsets.top(20)); |
| 271 | + ratioCard(row, times(bench.timeMs("iText 9", 1000) / bench.timeMs("GraphCompose", 1000)), |
| 272 | + "faster than iText 9 at 1000 rows"); |
| 273 | + ratioCard(row, times(bench.heapMb("iText 9", 1000) / bench.heapMb("GraphCompose", 1000)), |
| 274 | + "lighter than iText 9"); |
| 275 | + ratioCard(row, times(bench.heapMb("JasperReports", 1000) / bench.heapMb("GraphCompose", 1000)), |
| 276 | + "lighter than JasperReports"); |
| 277 | + }); |
| 278 | + |
| 279 | + page.addParagraph(p -> p |
| 280 | + .text("Measured " + bench.timestamp() + " · " + bench.warmup() + " warmup / " |
| 281 | + + bench.measure() + " measurement iterations on one machine. Read the " |
| 282 | + + "ratios rather than the milliseconds: all three engines are measured " |
| 283 | + + "in the same run, so their proportions survive a change of hardware " |
| 284 | + + "while the timings do not.") |
| 285 | + .textStyle(sans(9.5, FAINT_TX)) |
| 286 | + .lineSpacing(3) |
| 287 | + .margin(DocumentInsets.top(18))); |
| 288 | + } |
| 289 | + |
| 290 | + /** Page 4 — how the three engines behave as the report grows. */ |
| 291 | + private static void scalingPage(SectionBuilder page) { |
| 292 | + EngineDeckData.BenchRun bench = EngineDeckData.loadBench(); |
| 293 | + page.padding(DocumentInsets.symmetric(38, 52)).spacing(0); |
| 294 | + kicker(page, "SCALING", "What changes as the report grows"); |
| 295 | + lede(page, "From 40 to 1000 rows the time lead over iText widens; JasperReports closes " |
| 296 | + + "to roughly the same render time at the top size, while GraphCompose stays " |
| 297 | + + "markedly lighter on memory than both throughout. Every series reads from the " |
| 298 | + + "same file as the table on the previous page."); |
| 299 | + |
| 300 | + page.addRow("ScalingCharts", row -> { |
| 301 | + row.spacing(20).evenWeights().margin(DocumentInsets.top(AFTER_LEDE)); |
| 302 | + chartCard(row, "Render time vs. report size — ms", "lower is faster", |
| 303 | + seriesOf(bench, true)); |
| 304 | + chartCard(row, "Peak heap vs. report size — MB", "lower is lighter", |
| 305 | + seriesOf(bench, false)); |
| 306 | + }); |
| 307 | + |
| 308 | + page.addParagraph(p -> p |
| 309 | + .text("Both charts are native vector output: the engine compiles them to the same " |
| 310 | + + "shapes, lines and text frames as everything else on the page — no " |
| 311 | + + "image is embedded, and the numbers come from the file, not the caption.") |
| 312 | + .textStyle(sans(9.5, FAINT_TX)) |
| 313 | + .lineSpacing(3) |
| 314 | + .margin(DocumentInsets.top(20))); |
| 315 | + } |
| 316 | + |
| 317 | + // --------------------------------------------------------------------- |
| 318 | + // Page furniture for 2–4. |
| 319 | + // --------------------------------------------------------------------- |
| 320 | + |
| 321 | + /** |
| 322 | + * Inner width of a card in a four-across / three-across row. |
| 323 | + * |
| 324 | + * <p>A section measures to its longest line, so a row of cards whose text |
| 325 | + * happens to be shorter renders with ragged edges. A zero-height spacer of |
| 326 | + * this width sets the floor and the row lines up.</p> |
| 327 | + */ |
| 328 | + private static final double CONTENT_W = PAGE_W - 104; |
| 329 | + private static final double STEP_INNER = (CONTENT_W - 3 * 14) / 4 - 32; |
| 330 | + private static final double TRIPLE_INNER = (CONTENT_W - 2 * 14) / 3 - 32; |
| 331 | + |
| 332 | + /** |
| 333 | + * Space between the lede and the panels under it. |
| 334 | + * |
| 335 | + * <p>Larger than the gap between two rows of cards, and deliberately so. A card |
| 336 | + * carries its own bottom padding, so between two rows the eye sees that padding |
| 337 | + * plus the margin; between the lede and the first row it sees the margin alone. |
| 338 | + * Equal margins therefore read as unequal, and the text looked stuck to the |
| 339 | + * cards. This is the row gap plus a card's padding, so both gaps land the same.</p> |
| 340 | + */ |
| 341 | + private static final double AFTER_LEDE = 37; |
| 342 | + |
| 343 | + private static void kicker(SectionBuilder page, String eyebrow, String headline) { |
| 344 | + page.addParagraph(p -> p.text(eyebrow).textStyle(monoBold(11, ORANGE))); |
| 345 | + page.addParagraph(p -> p |
| 346 | + .text(headline) |
| 347 | + .textStyle(sansBold(30, WHITE_TX)) |
| 348 | + .margin(DocumentInsets.top(6))); |
| 349 | + page.addShape(sh -> sh.size(96, 4).fillColor(ORANGE).margin(DocumentInsets.top(12))); |
| 350 | + } |
| 351 | + |
| 352 | + private static void lede(SectionBuilder page, String text) { |
| 353 | + page.addParagraph(p -> p |
| 354 | + .text(text) |
| 355 | + .textStyle(sans(13, SUBTLE_TX)) |
| 356 | + .lineSpacing(4) |
| 357 | + .margin(DocumentInsets.top(16))); |
| 358 | + } |
| 359 | + |
| 360 | + private static void stepCard(RowBuilder row, String number, String title, String desc) { |
| 361 | + row.addSection("Step" + number, s -> s |
| 362 | + .softPanel(CARD, 10, 16) |
| 363 | + .stroke(DocumentStroke.of(BORDER, 1)) |
| 364 | + .spacing(0) |
| 365 | + .addSpacer(sp -> sp.size(STEP_INNER, 0)) |
| 366 | + .addParagraph(p -> p.text(number).textStyle(monoBold(20, ORANGE))) |
| 367 | + .addParagraph(p -> p.text(title) |
| 368 | + .textStyle(sansBold(13, WHITE_TX)) |
| 369 | + .margin(DocumentInsets.top(8))) |
| 370 | + .addParagraph(p -> p.text(desc) |
| 371 | + .textStyle(sans(11, MUTED_TX)) |
| 372 | + .lineSpacing(3) |
| 373 | + .margin(DocumentInsets.top(6)))); |
| 374 | + } |
| 375 | + |
| 376 | + private static void proofCard(RowBuilder row, String title, String desc) { |
| 377 | + row.addSection("Proof" + title, s -> s |
| 378 | + .softPanel(CARD_2, 10, 16) |
| 379 | + .accentTop(ORANGE, 3) |
| 380 | + .spacing(0) |
| 381 | + .addSpacer(sp -> sp.size(TRIPLE_INNER, 0)) |
| 382 | + .addParagraph(p -> p.text(title).textStyle(sansBold(13, WHITE_TX))) |
| 383 | + .addParagraph(p -> p.text(desc) |
| 384 | + .textStyle(sans(11, MUTED_TX)) |
| 385 | + .lineSpacing(3) |
| 386 | + .margin(DocumentInsets.top(7)))); |
| 387 | + } |
| 388 | + |
| 389 | + private static void ratioCard(RowBuilder row, String figure, String label) { |
| 390 | + row.addSection("Ratio" + label, s -> s |
| 391 | + .softPanel(CARD, 10, 16) |
| 392 | + .stroke(DocumentStroke.of(BORDER, 1)) |
| 393 | + .spacing(0) |
| 394 | + .addSpacer(sp -> sp.size(TRIPLE_INNER, 0)) |
| 395 | + .addParagraph(p -> p.text(figure).textStyle(sansBold(28, ORANGE))) |
| 396 | + .addParagraph(p -> p.text(label) |
| 397 | + .textStyle(sans(11, MUTED_TX)) |
| 398 | + .lineSpacing(3) |
| 399 | + .margin(DocumentInsets.top(4)))); |
| 400 | + } |
| 401 | + |
| 402 | + private static void chartCard(RowBuilder row, String title, String note, ChartSpec spec) { |
| 403 | + row.addSection("Chart" + title, s -> s |
| 404 | + .softPanel(CARD, 10, 16) |
| 405 | + .stroke(DocumentStroke.of(BORDER, 1)) |
| 406 | + .spacing(0) |
| 407 | + .addParagraph(p -> p.text(title).textStyle(sansBold(12, WHITE_TX))) |
| 408 | + .addParagraph(p -> p.text(note) |
| 409 | + .textStyle(sans(10, FAINT_TX)) |
| 410 | + .margin(DocumentInsets.top(3))) |
| 411 | + .chart(spec, chartStyle())); |
| 412 | + } |
| 413 | + |
| 414 | + /** Time or heap for all three engines across the row-count sweep. */ |
| 415 | + private static ChartSpec seriesOf(EngineDeckData.BenchRun bench, boolean time) { |
| 416 | + ChartData.Builder data = ChartData.builder().categories("40", "200", "1000"); |
| 417 | + for (String lib : new String[]{"GraphCompose", "iText 9", "JasperReports"}) { |
| 418 | + data.series(lib, |
| 419 | + time ? bench.timeMs(lib, 40) : bench.heapMb(lib, 40), |
| 420 | + time ? bench.timeMs(lib, 200) : bench.heapMb(lib, 200), |
| 421 | + time ? bench.timeMs(lib, 1000) : bench.heapMb(lib, 1000)); |
| 422 | + } |
| 423 | + return ChartSpec.line() |
| 424 | + .data(data.build()) |
| 425 | + .legend(LegendPosition.BOTTOM) |
| 426 | + .size(ChartSize.aspectRatio(16, 7)) |
| 427 | + .build(); |
| 428 | + } |
| 429 | + |
| 430 | + /** |
| 431 | + * The chart palette, matched to the banner: GraphCompose in amber, the two |
| 432 | + * rivals in the muted greys the cards already use, so the reader's eye lands |
| 433 | + * on the series the document is about without the chart shouting. |
| 434 | + */ |
| 435 | + private static ChartStyle chartStyle() { |
| 436 | + return ChartStyle.builder() |
| 437 | + .seriesPaint(0, DocumentPaint.solid(ORANGE)) |
| 438 | + .seriesPaint(1, DocumentPaint.solid(SERIES_SLATE)) |
| 439 | + .seriesPaint(2, DocumentPaint.solid(SERIES_GREEN)) |
| 440 | + .barCornerRadius(DocumentCornerRadius.top(3)) |
| 441 | + .barWidthRatio(0.5) |
| 442 | + .lineWidth(2.0) |
| 443 | + .axisTextStyle(mono(8.5, MUTED_TX)) |
| 444 | + .legendTextStyle(sans(9, MUTED_TX)) |
| 445 | + .valueLabelTextStyle(monoBold(10, ORANGE)) |
| 446 | + // The labels sit over the card, not a white page: match the halo to |
| 447 | + // the card fill so a gridline stops behind the digits instead of a |
| 448 | + // white chip appearing on the navy. |
| 449 | + .valueLabelHalo(DocumentPaint.solid(CARD)) |
| 450 | + .grid(ChartStyle.GridStyle.horizontal(DocumentStroke.of(BORDER, 0.6))) |
| 451 | + .build(); |
| 452 | + } |
| 453 | + |
| 454 | + /** A table cell on the night field: the card fill behind the given text style. */ |
| 455 | + private static DocumentTableStyle cellStyle(DocumentTextStyle text, DocumentColor fill) { |
| 456 | + return DocumentTableStyle.builder() |
| 457 | + .textStyle(text) |
| 458 | + .fillColor(fill) |
| 459 | + .stroke(DocumentStroke.of(BORDER, 0.8)) |
| 460 | + .padding(DocumentInsets.symmetric(5, 10)) |
| 461 | + .build(); |
| 462 | + } |
| 463 | + |
| 464 | + /** {@code 40.0 ms · 19.3 MB} — one cell carrying both measurements. */ |
| 465 | + private static String cell(double ms, double mb) { |
| 466 | + return String.format(java.util.Locale.ROOT, "%.1f ms · %.1f MB", ms, mb); |
| 467 | + } |
| 468 | + |
| 469 | + private static String times(double ratio) { |
| 470 | + return String.format(java.util.Locale.ROOT, "%.1fx", ratio); |
| 471 | + } |
| 472 | + |
170 | 473 | // --------------------------------------------------------------------- |
171 | 474 | // Full-bleed banner scene. |
172 | 475 | // --------------------------------------------------------------------- |
|
0 commit comments