From ed9d9eb68a03fe2912e6ea07e979f741cee218d1 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 27 Jul 2026 10:35:06 +0100 Subject: [PATCH 1/3] feat(examples): a five-slide carousel sized for a LinkedIn document post Portrait 1080x1350, which is the tallest frame LinkedIn shows without cropping, so the type can be sized for a phone rather than a desktop preview. Five slides: the one-composition premise, what the PPTX backend actually is, the comparative numbers, the guarantees behind them, and how to depend on it. Figures are read at render time - the version line from the filtered banner.properties, the comparative timings from the committed benchmark snapshot - so the file does not need remembering on the next release. The coordinate slide deliberately does not print the reactor version: this renders from a -SNAPSHOT tree, and publishing that string would advertise a coordinate that is not on Central. Two engine details the layout needed. A section measures to its longest line, so a stack of cards renders with ragged right edges; a zero-height spacer sets the width floor and they line up. Leading whitespace is trimmed during inline layout, so the code sample indents with non-breaking spaces, which occupy the same advance in a monospaced face. The same composition also writes a PPTX, which is what keeps slide two's claim about identical geometry honest whenever this file changes. --- .../demcha/examples/GenerateAllExamples.java | 3 + .../flagships/LinkedInCarouselExample.java | 498 ++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index fa370db7..447da85c 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -52,6 +52,7 @@ import com.demcha.examples.flagships.FinancialReportPptxExample; import com.demcha.examples.flagships.MasterShowcaseExample; import com.demcha.examples.flagships.MasterShowcasePptxExample; +import com.demcha.examples.flagships.LinkedInCarouselExample; import com.demcha.examples.flagships.SocialCardExample; import com.demcha.examples.flagships.MavenBannerPptxExample; import com.demcha.examples.flagships.ModuleFirstFileExample; @@ -225,6 +226,8 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + MavenBannerPptxExample.generate()); System.out.println("Generated: " + SocialCardExample.generate()); System.out.println("Generated: " + SocialCardExample.generatePptx()); + System.out.println("Generated: " + LinkedInCarouselExample.generate()); + System.out.println("Generated: " + LinkedInCarouselExample.generatePptx()); System.out.println("Generated: " + TwinOutputExample.generate()); System.out.println("Generated: " + FinancialReportExample.generate()); System.out.println("Generated: " + FinancialReportPptxExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java new file mode 100644 index 00000000..2a17ef80 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java @@ -0,0 +1,498 @@ +package com.demcha.examples.flagships; + +import com.demcha.compose.GraphCompose; +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.ParagraphBuilder; +import com.demcha.compose.document.dsl.RowBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.output.DocumentMetadata; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPaint; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import com.demcha.examples.support.ExampleOutputPaths; +import com.demcha.examples.support.ExampleVersion; + +import java.nio.file.Path; +import java.util.Locale; + +/** + * A five-slide carousel sized for a LinkedIn document post, composed through the + * canonical DSL and rendered by the engine it describes. + * + *

Portrait 1080×1350 (4:5), which is the tallest frame LinkedIn shows + * without cropping and therefore the most readable on a phone. Type is sized for + * that phone, not for a desktop preview: the smallest text on a slide is 18pt at + * a 1080-wide page, so it survives the reduction.

+ * + *

Every figure is read at render time — the version from the filtered + * {@code banner.properties}, the comparative timings from the committed + * benchmark snapshot through {@link EngineDeckData}. Nothing here is a literal + * that has to be remembered on the next release, which is the failure mode the + * older flagship assets kept hitting.

+ * + * @since 2.1.1 + */ +public final class LinkedInCarouselExample { + + /** LinkedIn renders document posts in a 4:5 frame; this is that frame. */ + public static final double PAGE_WIDTH = 1080; + /** LinkedIn renders document posts in a 4:5 frame; this is that frame. */ + public static final double PAGE_HEIGHT = 1350; + + private static final double MARGIN = 80; + private static final double CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN; + + private static final DocumentColor NIGHT = DocumentColor.rgb(9, 12, 27); + private static final DocumentColor SURFACE = DocumentColor.rgb(20, 27, 51); + private static final DocumentColor SURFACE_2 = DocumentColor.rgb(27, 34, 64); + private static final DocumentColor LINE = DocumentColor.rgb(48, 56, 96); + private static final DocumentColor ON_DARK = DocumentColor.rgb(245, 247, 252); + private static final DocumentColor ON_DARK_MUTED = DocumentColor.rgb(168, 177, 202); + private static final DocumentColor VIOLET = DocumentColor.rgb(124, 92, 252); + private static final DocumentColor VIOLET_LIGHT = DocumentColor.rgb(181, 159, 255); + private static final DocumentColor MINT = DocumentColor.rgb(55, 214, 161); + private static final DocumentColor AMBER = DocumentColor.rgb(230, 160, 68); + + /** Uniform padding inside every card, as passed to {@code softPanel}. */ + private static final double CARD_PAD = 30; + + /** + * Width available inside a card. + * + *

A section measures to its longest line, so a stack of cards whose text + * happens to be shorter renders with ragged right edges. A zero-height + * spacer of this width sets the floor, and every card lines up.

+ */ + private static final double CARD_INNER = CONTENT_WIDTH - 2 * CARD_PAD; + + /** + * The smallest useful program, shown on the closing slide. + * + *

Indented with non-breaking spaces: leading ordinary whitespace is + * trimmed during inline layout, which would flatten the code to the left + * margin and make it read as broken. Courier is monospaced, so the + * substitute occupies the same advance.

+ */ + private static final String[] SNIPPET = { + "try (DocumentSession doc =", + indent(8) + "GraphCompose.document(out).create()) {", + indent(4) + "doc.pageFlow()", + indent(7) + ".addParagraph(p -> p.text(\"Hello.\"))", + indent(7) + ".build();", + indent(4) + "doc.buildPdf();" + indent(6) + "// or buildPptx(out)", + "}", + }; + + private static String indent(int spaces) { + return " ".repeat(spaces); + } + + /** The scaling tier the comparative benchmark reports at its largest size. */ + private static final int TIER = 1000; + + private LinkedInCarouselExample() { + } + + /** + * Runs the example. + * + * @param args ignored + * @throws Exception when composition or rendering fails + */ + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + System.out.println("Generated: " + generatePptx()); + } + + /** + * Writes the carousel as a PDF — the file LinkedIn accepts as a document post. + * + * @return generated PDF path under {@code target/generated-pdfs/flagships} + * @throws Exception when composition or rendering fails + */ + public static Path generate() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("flagships", "linkedin-carousel.pdf"); + try (DocumentSession document = session(outputFile)) { + compose(document); + document.buildPdf(); + } + return outputFile; + } + + /** + * Writes the same carousel as an editable deck. + * + *

Slide two claims the PPTX backend places the same geometry as the PDF + * one. Emitting this file from the same composition is the cheapest way to + * keep that claim true whenever this example changes.

+ * + * @return generated PPTX path under {@code target/generated-pdfs/flagships} + * @throws Exception when composition or rendering fails + */ + public static Path generatePptx() throws Exception { + Path outputFile = ExampleOutputPaths.prepare("flagships", "linkedin-carousel.pptx"); + try (DocumentSession document = session(outputFile)) { + compose(document); + document.buildPptx(outputFile); + } + return outputFile; + } + + private static DocumentSession session(Path outputFile) { + return GraphCompose.document(outputFile) + .pageSize(PAGE_WIDTH, PAGE_HEIGHT) + .pageBackground(NIGHT) + .margin((float) MARGIN, (float) MARGIN, (float) MARGIN, (float) MARGIN) + .create(); + } + + /** + * Composes all five slides. + * + * @param document the open session to compose into + */ + static void compose(DocumentSession document) { + EngineDeckData.BenchRun bench = EngineDeckData.loadBench(); + + document.metadata(DocumentMetadata.builder() + .title("GraphCompose " + ExampleVersion.currentLine()) + .subject("One composition, rendered to PDF and PowerPoint") + .creator("GraphCompose Examples") + .producer("GraphCompose") + .build()); + + document.pageFlow() + .name("Carousel") + .spacing(0) + .addSection("Cover", LinkedInCarouselExample::cover) + .addPageBreak(b -> b.name("ToBackend")) + .addSection("Backend", LinkedInCarouselExample::backendSlide) + .addPageBreak(b -> b.name("ToNumbers")) + .addSection("Numbers", s -> numbersSlide(s, bench)) + .addPageBreak(b -> b.name("ToGuarantees")) + .addSection("Guarantees", LinkedInCarouselExample::guaranteesSlide) + .addPageBreak(b -> b.name("ToClose")) + .addSection("Close", LinkedInCarouselExample::closeSlide) + .build(); + } + + // ───────────────────────── slides ───────────────────────── + + private static void cover(SectionBuilder slide) { + slide.spacing(56); + eyebrow(slide, "GRAPHCOMPOSE " + ExampleVersion.currentLine(), VIOLET_LIGHT); + slide.addParagraph(p -> p + .text("One composition.\nTwo formats.") + .textStyle(headline(132)) + .lineSpacing(1.03) + .margin(DocumentInsets.zero())); + slide.addShape(shape -> shape.size(160, 7).fillColor(VIOLET) + .margin(DocumentInsets.symmetric(14, 0))); + slide.addParagraph(p -> p + .text("A Java DSL describes the document. The engine resolves layout " + + "and pagination once, then hands the same fragments to every " + + "backend — so PDF and PowerPoint are the same geometry, " + + "not two hand-kept designs.") + .textStyle(body(40, ON_DARK_MUTED)) + .lineSpacing(1.45) + .margin(DocumentInsets.zero())); + slide.addRow("Formats", row -> { + row.spacing(14).evenWeights(); + formatChip(row, "PDF", "fixed layout", "PDFBox", MINT); + formatChip(row, "PPTX", "fixed layout", "Apache POI", VIOLET_LIGHT); + formatChip(row, "DOCX", "semantic", "Apache POI", AMBER); + }); + slide.addParagraph(p -> footnote(p, + "This carousel is a GraphCompose document. So is the deck it links to.")); + } + + private static void backendSlide(SectionBuilder slide) { + slide.spacing(30); + eyebrow(slide, "WHAT " + ExampleVersion.currentLine() + " ADDS", MINT); + slide.addParagraph(p -> p + .text("PowerPoint is a\nfirst-class backend.") + .textStyle(headline(82)) + .lineSpacing(1.06) + .margin(DocumentInsets.zero())); + point(slide, "Fixed layout, not export", MINT, + "PptxFixedLayoutBackend implements the same FixedLayoutRenderer " + + "contract as the PDF backend and is discovered through the same " + + "ServiceLoader SPI. It consumes placed fragments, so it never " + + "re-flows your document."); + point(slide, "Native shapes, not screenshots", VIOLET_LIGHT, + "Text stays text, rectangles stay rectangles, charts stay drawn " + + "geometry. The deck opens editable in PowerPoint instead of " + + "arriving as a picture of one."); + point(slide, "One source, two files", AMBER, + "The same compose(...) call writes the PDF and the PPTX. Nothing " + + "is authored twice, so the two cannot drift apart."); + point(slide, "It tells you what it swapped", VIOLET, + "PowerPoint has no Helvetica or Courier. When a PDF built-in is " + + "substituted the render logs the family, the replacement and how " + + "to get identical glyphs - rather than silently shifting your text."); + } + + private static void numbersSlide(SectionBuilder slide, EngineDeckData.BenchRun bench) { + slide.spacing(40); + eyebrow(slide, "MEASURED, NOT CLAIMED", AMBER); + slide.addParagraph(p -> p + .text("A " + TIER + "-row report") + .textStyle(headline(82)) + .margin(DocumentInsets.zero())); + slide.addParagraph(p -> p + .text("Average render latency, milliseconds — lower is faster.") + .textStyle(body(30, ON_DARK_MUTED)) + .margin(DocumentInsets.bottom(10))); + + slide.addSection("Chart", card -> card + .softPanel(SURFACE, 20, 26) + .stroke(DocumentStroke.of(LINE, 1)) + .chart(latencyChart(bench), latencyStyle())); + + slide.addRow("Ratios", row -> { + row.spacing(14).evenWeights(); + ratio(row, times(bench.timeMs("iText 9", TIER) / bench.timeMs("GraphCompose", TIER)), + "faster than iText 9", VIOLET_LIGHT); + ratio(row, times(bench.heapMb("iText 9", TIER) / bench.heapMb("GraphCompose", TIER)), + "lighter than iText 9", MINT); + ratio(row, times(bench.heapMb("JasperReports", TIER) / bench.heapMb("GraphCompose", TIER)), + "lighter than Jasper", AMBER); + }); + + slide.addParagraph(p -> footnote(p, + "One machine, single run, " + snapshotDate(bench) + ". Read the ratios rather " + + "than the timings: both libraries are measured in the same run, so their " + + "proportions survive a change of machine while the milliseconds do not. " + + "On time, Jasper reaches parity at this size.")); + } + + private static void guaranteesSlide(SectionBuilder slide) { + slide.spacing(30); + eyebrow(slide, "HOW IT STAYS HONEST", VIOLET_LIGHT); + slide.addParagraph(p -> p + .text("Guarantees you\ncan run.") + .textStyle(headline(82)) + .lineSpacing(1.06) + .margin(DocumentInsets.zero())); + point(slide, "Deterministic layout snapshots", VIOLET_LIGHT, + "Every flagship document has its geometry recorded as JSON. A layout " + + "change that moves a fragment fails the build instead of quietly " + + "shipping."); + point(slide, "Measurement counts, not stopwatches", MINT, + "A CI gate asserts the exact number of text measurements the layout " + + "pass costs. Those are integers, identical on every machine, so they " + + "separate a slower engine from a slower laptop — which a millisecond " + + "ceiling cannot."); + point(slide, "A capability matrix, written down", AMBER, + "Where a backend cannot do something, the difference is documented per " + + "payload rather than discovered in your output."); + point(slide, "Binary compatibility, gated", VIOLET, + "japicmp checks every build against the published 2.0 baseline, so an " + + "accidental break in a minor release fails CI instead of your app."); + } + + private static void closeSlide(SectionBuilder slide) { + slide.spacing(30); + eyebrow(slide, "OPEN SOURCE · APACHE-2.0", MINT); + slide.addParagraph(p -> p + .text("Try it.") + .textStyle(headline(132)) + .margin(DocumentInsets.zero())); + slide.addSection("Coords", card -> { + card.softPanel(SURFACE, 20, CARD_PAD) + .stroke(DocumentStroke.of(LINE, 1)) + .spacing(4); + card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); + card.addParagraph(p -> p + .text("io.github.demchaav:graph-compose-bundle") + .textStyle(mono(37, ON_DARK)) + .margin(DocumentInsets.zero())); + // Deliberately not the reactor version: this file renders from a + // -SNAPSHOT tree, and printing that would advertise a coordinate that + // is not on Central. + card.addParagraph(p -> p + .text("latest release on Maven Central") + .textStyle(mono(27, MINT)) + .margin(DocumentInsets.zero())); + }); + slide.addSection("Snippet", card -> { + card.softPanel(SURFACE_2, 20, CARD_PAD) + .accentLeft(VIOLET, 4) + .spacing(2); + card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); + for (String line : SNIPPET) { + card.addParagraph(p -> p + .text(line) + .textStyle(mono(30, line.trim().startsWith("//") + ? DocumentColor.rgb(126, 135, 165) : ON_DARK)) + .margin(DocumentInsets.zero())); + } + }); + slide.addParagraph(p -> p + .text("github.com/DemchaAV/GraphCompose") + .textStyle(body(46, VIOLET_LIGHT)) + .margin(DocumentInsets.top(4))); + slide.addParagraph(p -> footnote(p, + "Java 17+. Every slide in this carousel was composed with the library " + + "and rendered by its own PDF backend - the PPTX beside it comes from " + + "the same composition.")); + } + + // ───────────────────────── pieces ───────────────────────── + + private static void eyebrow(SectionBuilder slide, String text, DocumentColor accent) { + slide.addParagraph(p -> p + .text(text) + .textStyle(mono(26, accent)) + .margin(DocumentInsets.zero())); + } + + private static void point(SectionBuilder slide, String title, DocumentColor accent, String body) { + // Wrapped in a single-child row: a bare section sizes itself to its + // longest line, which leaves a stack of cards with ragged right edges. + // An even-weighted row gives every card the full content width. + slide.addSection("Point", card -> { + card.softPanel(SURFACE, 26, CARD_PAD) + .accentLeft(accent, 4) + .spacing(6); + card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); + card.addParagraph(p -> p + .text(title) + .textStyle(DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(40) + .color(ON_DARK) + .build()) + .margin(DocumentInsets.zero())); + card.addParagraph(p -> p + .text(body) + .textStyle(body(28, ON_DARK_MUTED)) + .lineSpacing(1.4) + .margin(DocumentInsets.zero())); + }); + } + + private static void formatChip(RowBuilder row, String name, String lane, + String engine, DocumentColor accent) { + row.addSection(name, card -> { + card.softPanel(SURFACE_2, 20, 24) + .stroke(DocumentStroke.of(LINE, 1)) + .spacing(2); + card.addParagraph(p -> p + .text(name) + .textStyle(DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(56) + .color(accent) + .build()) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + card.addParagraph(p -> p + .text(lane) + .textStyle(mono(27, ON_DARK_MUTED)) + .align(TextAlign.CENTER) + .margin(DocumentInsets.zero())); + card.addParagraph(p -> p + .text(engine) + .textStyle(mono(23, DocumentColor.rgb(122, 131, 162))) + .align(TextAlign.CENTER) + .margin(DocumentInsets.top(4))); + }); + } + + private static void ratio(RowBuilder row, String value, String label, DocumentColor accent) { + row.addSection("Ratio", card -> { + card.softPanel(SURFACE_2, 14, 24) + .accentTop(accent, 3) + .spacing(2); + card.addParagraph(p -> p + .text(value) + .textStyle(DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(64) + .color(accent) + .build()) + .margin(DocumentInsets.zero())); + card.addParagraph(p -> p + .text(label) + .textStyle(body(24, ON_DARK_MUTED)) + .lineSpacing(1.25) + .margin(DocumentInsets.zero())); + }); + } + + private static ChartSpec latencyChart(EngineDeckData.BenchRun bench) { + return ChartSpec.bar() + .data(ChartData.builder() + .categories("GraphCompose", "iText 9", "JasperReports") + .series("ms", + bench.timeMs("GraphCompose", TIER), + bench.timeMs("iText 9", TIER), + bench.timeMs("JasperReports", TIER)) + .build()) + .valueAxis(AxisSpec.builder().baselineAtZero(true).build()) + .showCategoryLabels(true) + .legend(LegendPosition.NONE) + .valueLabels(ValueLabelMode.OUTSIDE) + .size(ChartSize.fixedHeight(470)) + .build(); + } + + private static ChartStyle latencyStyle() { + return ChartStyle.builder() + .seriesPaint(0, DocumentPaint.solid(VIOLET)) + .barCornerRadius(DocumentCornerRadius.of(4)) + .barWidthRatio(0.5) + .axisTextStyle(mono(22, ON_DARK_MUTED)) + .valueLabelTextStyle(mono(25, ON_DARK)) + .build(); + } + + private static ParagraphBuilder footnote(ParagraphBuilder p, String text) { + return p.text(text) + .textStyle(body(22, DocumentColor.rgb(130, 139, 170))) + .lineSpacing(1.4) + .margin(DocumentInsets.top(6)); + } + + private static String times(double ratio) { + return String.format(Locale.ROOT, "%.1fx", ratio); + } + + private static String snapshotDate(EngineDeckData.BenchRun bench) { + String timestamp = bench.timestamp(); + return timestamp.substring(0, Math.min(10, timestamp.length())); + } + + private static DocumentTextStyle headline(double size) { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA_BOLD) + .size(size) + .color(ON_DARK) + .build(); + } + + private static DocumentTextStyle body(double size, DocumentColor color) { + return DocumentTextStyle.builder().size(size).color(color).build(); + } + + private static DocumentTextStyle mono(double size, DocumentColor color) { + return DocumentTextStyle.builder() + .fontName(FontName.COURIER) + .size(size) + .color(color) + .build(); + } +} From df9886b8769314e910c61e82a880bf8dbc285d3b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 27 Jul 2026 10:51:57 +0100 Subject: [PATCH 2/3] feat(examples): open the carousel with the social card, and correct its licence The carousel now leads with SocialCardExample's artwork rather than restating it, so the post opens on the picture that already explains the premise. That drove the page size. The card is a 1280pt canvas with absolute coordinates, and the DSL has no node-level scale outside images, so scaling it would have meant rebuilding it against a width. Widening the carousel to 1280x1600 keeps the frame at 4:5 and carries the card at native size instead. Everything else was drawn against a 1080-wide page, so the three type helpers multiply by the ratio and the slides keep their proportions. Its own page takes a zero margin so the artwork reaches the edges, and a spacer centres it in the taller frame; both share the night background, so the space above and below reads as letterboxing rather than a gap. The closing slide said APACHE-2.0. The repository is MIT - LICENSE, the README badge and the card's own footer all agree - so the carousel was contradicting the slide it now opens with. The Maven coordinate also broke mid-artifact at that width and is set as groupId over artifactId. --- .../flagships/LinkedInCarouselExample.java | 78 +++++++++++++------ .../examples/flagships/SocialCardExample.java | 11 ++- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java index 2a17ef80..1d82c5b9 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java @@ -2,6 +2,7 @@ import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.api.PageMarginRule; import com.demcha.compose.document.chart.AxisSpec; import com.demcha.compose.document.chart.ChartData; import com.demcha.compose.document.chart.ChartSize; @@ -25,6 +26,7 @@ import com.demcha.examples.support.ExampleVersion; import java.nio.file.Path; +import java.util.List; import java.util.Locale; /** @@ -46,12 +48,23 @@ */ public final class LinkedInCarouselExample { - /** LinkedIn renders document posts in a 4:5 frame; this is that frame. */ - public static final double PAGE_WIDTH = 1080; - /** LinkedIn renders document posts in a 4:5 frame; this is that frame. */ - public static final double PAGE_HEIGHT = 1350; + /** + * LinkedIn renders document posts in a 4:5 frame; this is that frame, sized + * so the opening slide can carry {@link SocialCardExample}'s card at its + * native {@value SocialCardExample#CARD_WIDTH}pt width without scaling. + */ + public static final double PAGE_WIDTH = SocialCardExample.CARD_WIDTH; + /** Four-by-five against {@link #PAGE_WIDTH}. */ + public static final double PAGE_HEIGHT = PAGE_WIDTH * 5 / 4; + + /** + * Everything below was drawn against a 1080-wide page. Rather than restate + * every size, the type helpers scale by this ratio, so the slides keep their + * proportions on the wider frame. + */ + private static final double SCALE = PAGE_WIDTH / 1080.0; - private static final double MARGIN = 80; + private static final double MARGIN = 80 * SCALE; private static final double CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN; private static final DocumentColor NIGHT = DocumentColor.rgb(9, 12, 27); @@ -66,7 +79,7 @@ public final class LinkedInCarouselExample { private static final DocumentColor AMBER = DocumentColor.rgb(230, 160, 68); /** Uniform padding inside every card, as passed to {@code softPanel}. */ - private static final double CARD_PAD = 30; + private static final double CARD_PAD = 30 * SCALE; /** * Width available inside a card. @@ -173,9 +186,17 @@ static void compose(DocumentSession document) { .producer("GraphCompose") .build()); + // The opening slide is the social card at native size. Its own page gets + // no margin so the artwork reaches the edges, and a spacer centres it in + // the taller frame - both share the same night background, so the space above + // and below reads as deliberate letterboxing rather than a gap. + document.pageMargins(List.of(PageMarginRule.page(1, DocumentInsets.zero()))); + document.pageFlow() .name("Carousel") .spacing(0) + .addSection("Card", LinkedInCarouselExample::cardSlide) + .addPageBreak(b -> b.name("ToCover")) .addSection("Cover", LinkedInCarouselExample::cover) .addPageBreak(b -> b.name("ToBackend")) .addSection("Backend", LinkedInCarouselExample::backendSlide) @@ -190,15 +211,22 @@ static void compose(DocumentSession document) { // ───────────────────────── slides ───────────────────────── + private static void cardSlide(SectionBuilder slide) { + slide.spacing(0); + slide.addSpacer(sp -> sp.width(PAGE_WIDTH) + .height((PAGE_HEIGHT - SocialCardExample.CARD_HEIGHT) / 2)); + slide.add(SocialCardExample.scene()); + } + private static void cover(SectionBuilder slide) { - slide.spacing(56); + slide.spacing(56 * SCALE); eyebrow(slide, "GRAPHCOMPOSE " + ExampleVersion.currentLine(), VIOLET_LIGHT); slide.addParagraph(p -> p .text("One composition.\nTwo formats.") .textStyle(headline(132)) .lineSpacing(1.03) .margin(DocumentInsets.zero())); - slide.addShape(shape -> shape.size(160, 7).fillColor(VIOLET) + slide.addShape(shape -> shape.size(160 * SCALE, 7 * SCALE).fillColor(VIOLET) .margin(DocumentInsets.symmetric(14, 0))); slide.addParagraph(p -> p .text("A Java DSL describes the document. The engine resolves layout " @@ -219,7 +247,7 @@ private static void cover(SectionBuilder slide) { } private static void backendSlide(SectionBuilder slide) { - slide.spacing(30); + slide.spacing(30 * SCALE); eyebrow(slide, "WHAT " + ExampleVersion.currentLine() + " ADDS", MINT); slide.addParagraph(p -> p .text("PowerPoint is a\nfirst-class backend.") @@ -245,7 +273,7 @@ private static void backendSlide(SectionBuilder slide) { } private static void numbersSlide(SectionBuilder slide, EngineDeckData.BenchRun bench) { - slide.spacing(40); + slide.spacing(40 * SCALE); eyebrow(slide, "MEASURED, NOT CLAIMED", AMBER); slide.addParagraph(p -> p .text("A " + TIER + "-row report") @@ -279,7 +307,7 @@ private static void numbersSlide(SectionBuilder slide, EngineDeckData.BenchRun b } private static void guaranteesSlide(SectionBuilder slide) { - slide.spacing(30); + slide.spacing(30 * SCALE); eyebrow(slide, "HOW IT STAYS HONEST", VIOLET_LIGHT); slide.addParagraph(p -> p .text("Guarantees you\ncan run.") @@ -304,8 +332,8 @@ private static void guaranteesSlide(SectionBuilder slide) { } private static void closeSlide(SectionBuilder slide) { - slide.spacing(30); - eyebrow(slide, "OPEN SOURCE · APACHE-2.0", MINT); + slide.spacing(30 * SCALE); + eyebrow(slide, "OPEN SOURCE · MIT", MINT); slide.addParagraph(p -> p .text("Try it.") .textStyle(headline(132)) @@ -315,9 +343,15 @@ private static void closeSlide(SectionBuilder slide) { .stroke(DocumentStroke.of(LINE, 1)) .spacing(4); card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); + // Split on the colon rather than set in one line: the coordinate is + // wider than the card at this size and broke mid-artifact. + card.addParagraph(p -> p + .text("io.github.demchaav") + .textStyle(mono(31, ON_DARK_MUTED)) + .margin(DocumentInsets.zero())); card.addParagraph(p -> p - .text("io.github.demchaav:graph-compose-bundle") - .textStyle(mono(37, ON_DARK)) + .text("graph-compose-bundle") + .textStyle(mono(40, ON_DARK)) .margin(DocumentInsets.zero())); // Deliberately not the reactor version: this file renders from a // -SNAPSHOT tree, and printing that would advertise a coordinate that @@ -372,7 +406,7 @@ private static void point(SectionBuilder slide, String title, DocumentColor acce .text(title) .textStyle(DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) - .size(40) + .size(40 * SCALE) .color(ON_DARK) .build()) .margin(DocumentInsets.zero())); @@ -394,7 +428,7 @@ private static void formatChip(RowBuilder row, String name, String lane, .text(name) .textStyle(DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) - .size(56) + .size(56 * SCALE) .color(accent) .build()) .align(TextAlign.CENTER) @@ -421,7 +455,7 @@ private static void ratio(RowBuilder row, String value, String label, DocumentCo .text(value) .textStyle(DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) - .size(64) + .size(64 * SCALE) .color(accent) .build()) .margin(DocumentInsets.zero())); @@ -446,7 +480,7 @@ private static ChartSpec latencyChart(EngineDeckData.BenchRun bench) { .showCategoryLabels(true) .legend(LegendPosition.NONE) .valueLabels(ValueLabelMode.OUTSIDE) - .size(ChartSize.fixedHeight(470)) + .size(ChartSize.fixedHeight(470 * SCALE)) .build(); } @@ -479,19 +513,19 @@ private static String snapshotDate(EngineDeckData.BenchRun bench) { private static DocumentTextStyle headline(double size) { return DocumentTextStyle.builder() .fontName(FontName.HELVETICA_BOLD) - .size(size) + .size(size * SCALE) .color(ON_DARK) .build(); } private static DocumentTextStyle body(double size, DocumentColor color) { - return DocumentTextStyle.builder().size(size).color(color).build(); + return DocumentTextStyle.builder().size(size * SCALE).color(color).build(); } private static DocumentTextStyle mono(double size, DocumentColor color) { return DocumentTextStyle.builder() .fontName(FontName.COURIER) - .size(size) + .size(size * SCALE) .color(color) .build(); } diff --git a/examples/src/main/java/com/demcha/examples/flagships/SocialCardExample.java b/examples/src/main/java/com/demcha/examples/flagships/SocialCardExample.java index 893f34e2..58f3cd51 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/SocialCardExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/SocialCardExample.java @@ -212,7 +212,16 @@ private static void compose(DocumentSession document) { .build(); } - private static DocumentNode scene() { + /** + * The card artwork as a single node. + * + *

Package-private so {@link LinkedInCarouselExample} can open with the + * same picture instead of redrawing it. It is a canvas of {@link #CARD_WIDTH} + * by {@link #CARD_HEIGHT}, so a host page has to be at least that wide.

+ * + * @return the composed card + */ + static DocumentNode scene() { List layers = new ArrayList<>(); layers.addAll(grid()); From b048a979ef66119321f5637ede478dac90e9343b Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 27 Jul 2026 11:10:00 +0100 Subject: [PATCH 3/3] docs(examples): describe the carousel that shipped, and scale the boxes with the type The prose still described the first commit: five slides at 1080x1350, where adding the social card made it six at 1280x1600. Class javadoc and the method comment both said five. The widening scaled the type helpers but left the gaps, corner radii, accent bars and stroke widths at their 1080-era values, so "the slides keep their proportions" was true of the text and not of the boxes around it. They take the same ratio now, and the constant's javadoc says which lengths it applies to rather than leaving the next reader to guess. The opening slide's centring spacer is floored at zero: a card taller than the page would have asked for a negative height instead of simply filling the frame. --- .../flagships/LinkedInCarouselExample.java | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java index 1d82c5b9..219a6c41 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/LinkedInCarouselExample.java @@ -30,13 +30,14 @@ import java.util.Locale; /** - * A five-slide carousel sized for a LinkedIn document post, composed through the + * A six-slide carousel sized for a LinkedIn document post, composed through the * canonical DSL and rendered by the engine it describes. * - *

Portrait 1080×1350 (4:5), which is the tallest frame LinkedIn shows - * without cropping and therefore the most readable on a phone. Type is sized for - * that phone, not for a desktop preview: the smallest text on a slide is 18pt at - * a 1080-wide page, so it survives the reduction.

+ *

Portrait 1280×1600 (4:5), which is the tallest frame LinkedIn shows + * without cropping and therefore the most readable on a phone. The width is set + * by the opening slide: it carries {@link SocialCardExample}'s card at native + * size rather than a scaled copy. Type is sized for that phone, not for a + * desktop preview.

* *

Every figure is read at render time — the version from the filtered * {@code banner.properties}, the comparative timings from the committed @@ -59,8 +60,10 @@ public final class LinkedInCarouselExample { /** * Everything below was drawn against a 1080-wide page. Rather than restate - * every size, the type helpers scale by this ratio, so the slides keep their - * proportions on the wider frame. + * every size, the type helpers and the box metrics scale by this ratio, so + * the slides keep their proportions on the wider frame. Apply it to any + * length that was eyeballed against the narrower page; the only literals + * that stay absolute are ones derived from the page itself. */ private static final double SCALE = PAGE_WIDTH / 1080.0; @@ -172,7 +175,7 @@ private static DocumentSession session(Path outputFile) { } /** - * Composes all five slides. + * Composes all six slides. * * @param document the open session to compose into */ @@ -213,8 +216,10 @@ static void compose(DocumentSession document) { private static void cardSlide(SectionBuilder slide) { slide.spacing(0); - slide.addSpacer(sp -> sp.width(PAGE_WIDTH) - .height((PAGE_HEIGHT - SocialCardExample.CARD_HEIGHT) / 2)); + // Floored: a card taller than the page would otherwise ask for a + // negative spacer rather than simply filling the frame. + double bleed = Math.max(0, (PAGE_HEIGHT - SocialCardExample.CARD_HEIGHT) / 2); + slide.addSpacer(sp -> sp.width(PAGE_WIDTH).height(bleed)); slide.add(SocialCardExample.scene()); } @@ -237,7 +242,7 @@ private static void cover(SectionBuilder slide) { .lineSpacing(1.45) .margin(DocumentInsets.zero())); slide.addRow("Formats", row -> { - row.spacing(14).evenWeights(); + row.spacing(14 * SCALE).evenWeights(); formatChip(row, "PDF", "fixed layout", "PDFBox", MINT); formatChip(row, "PPTX", "fixed layout", "Apache POI", VIOLET_LIGHT); formatChip(row, "DOCX", "semantic", "Apache POI", AMBER); @@ -282,15 +287,15 @@ private static void numbersSlide(SectionBuilder slide, EngineDeckData.BenchRun b slide.addParagraph(p -> p .text("Average render latency, milliseconds — lower is faster.") .textStyle(body(30, ON_DARK_MUTED)) - .margin(DocumentInsets.bottom(10))); + .margin(DocumentInsets.bottom(10 * SCALE))); slide.addSection("Chart", card -> card - .softPanel(SURFACE, 20, 26) - .stroke(DocumentStroke.of(LINE, 1)) + .softPanel(SURFACE, 20 * SCALE, 26 * SCALE) + .stroke(DocumentStroke.of(LINE, 1 * SCALE)) .chart(latencyChart(bench), latencyStyle())); slide.addRow("Ratios", row -> { - row.spacing(14).evenWeights(); + row.spacing(14 * SCALE).evenWeights(); ratio(row, times(bench.timeMs("iText 9", TIER) / bench.timeMs("GraphCompose", TIER)), "faster than iText 9", VIOLET_LIGHT); ratio(row, times(bench.heapMb("iText 9", TIER) / bench.heapMb("GraphCompose", TIER)), @@ -339,9 +344,9 @@ private static void closeSlide(SectionBuilder slide) { .textStyle(headline(132)) .margin(DocumentInsets.zero())); slide.addSection("Coords", card -> { - card.softPanel(SURFACE, 20, CARD_PAD) - .stroke(DocumentStroke.of(LINE, 1)) - .spacing(4); + card.softPanel(SURFACE, 20 * SCALE, CARD_PAD) + .stroke(DocumentStroke.of(LINE, 1 * SCALE)) + .spacing(4 * SCALE); card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); // Split on the colon rather than set in one line: the coordinate is // wider than the card at this size and broke mid-artifact. @@ -362,9 +367,9 @@ private static void closeSlide(SectionBuilder slide) { .margin(DocumentInsets.zero())); }); slide.addSection("Snippet", card -> { - card.softPanel(SURFACE_2, 20, CARD_PAD) - .accentLeft(VIOLET, 4) - .spacing(2); + card.softPanel(SURFACE_2, 20 * SCALE, CARD_PAD) + .accentLeft(VIOLET, 4 * SCALE) + .spacing(2 * SCALE); card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); for (String line : SNIPPET) { card.addParagraph(p -> p @@ -377,7 +382,7 @@ private static void closeSlide(SectionBuilder slide) { slide.addParagraph(p -> p .text("github.com/DemchaAV/GraphCompose") .textStyle(body(46, VIOLET_LIGHT)) - .margin(DocumentInsets.top(4))); + .margin(DocumentInsets.top(4 * SCALE))); slide.addParagraph(p -> footnote(p, "Java 17+. Every slide in this carousel was composed with the library " + "and rendered by its own PDF backend - the PPTX beside it comes from " @@ -398,9 +403,9 @@ private static void point(SectionBuilder slide, String title, DocumentColor acce // longest line, which leaves a stack of cards with ragged right edges. // An even-weighted row gives every card the full content width. slide.addSection("Point", card -> { - card.softPanel(SURFACE, 26, CARD_PAD) - .accentLeft(accent, 4) - .spacing(6); + card.softPanel(SURFACE, 26 * SCALE, CARD_PAD) + .accentLeft(accent, 4 * SCALE) + .spacing(6 * SCALE); card.addSpacer(sp -> sp.width(CARD_INNER).height(0)); card.addParagraph(p -> p .text(title) @@ -422,8 +427,8 @@ private static void formatChip(RowBuilder row, String name, String lane, String engine, DocumentColor accent) { row.addSection(name, card -> { card.softPanel(SURFACE_2, 20, 24) - .stroke(DocumentStroke.of(LINE, 1)) - .spacing(2); + .stroke(DocumentStroke.of(LINE, 1 * SCALE)) + .spacing(2 * SCALE); card.addParagraph(p -> p .text(name) .textStyle(DocumentTextStyle.builder() @@ -442,15 +447,15 @@ private static void formatChip(RowBuilder row, String name, String lane, .text(engine) .textStyle(mono(23, DocumentColor.rgb(122, 131, 162))) .align(TextAlign.CENTER) - .margin(DocumentInsets.top(4))); + .margin(DocumentInsets.top(4 * SCALE))); }); } private static void ratio(RowBuilder row, String value, String label, DocumentColor accent) { row.addSection("Ratio", card -> { - card.softPanel(SURFACE_2, 14, 24) - .accentTop(accent, 3) - .spacing(2); + card.softPanel(SURFACE_2, 14 * SCALE, 24 * SCALE) + .accentTop(accent, 3 * SCALE) + .spacing(2 * SCALE); card.addParagraph(p -> p .text(value) .textStyle(DocumentTextStyle.builder() @@ -487,7 +492,7 @@ private static ChartSpec latencyChart(EngineDeckData.BenchRun bench) { private static ChartStyle latencyStyle() { return ChartStyle.builder() .seriesPaint(0, DocumentPaint.solid(VIOLET)) - .barCornerRadius(DocumentCornerRadius.of(4)) + .barCornerRadius(DocumentCornerRadius.of(4 * SCALE)) .barWidthRatio(0.5) .axisTextStyle(mono(22, ON_DARK_MUTED)) .valueLabelTextStyle(mono(25, ON_DARK)) @@ -498,7 +503,7 @@ private static ParagraphBuilder footnote(ParagraphBuilder p, String text) { return p.text(text) .textStyle(body(22, DocumentColor.rgb(130, 139, 170))) .lineSpacing(1.4) - .margin(DocumentInsets.top(6)); + .margin(DocumentInsets.top(6 * SCALE)); } private static String times(double ratio) {