diff --git a/CHANGELOG.md b/CHANGELOG.md index b2920c69a..5d6b10637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,46 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. +## v2.4.2 — Planned + +### Documentation + +- **A card of a preset shows the code that draws that preset.** The catalogue carries one + compiled block per family — the CVs' builds `BoxedSections`, the invoices' `ModernInvoice` — + and every other card of the family was shown it under a caption saying it came from the + documentation. The caption was true and the code under it still drew a different document, + which is what a reader copies. Those cards now carry the smallest block that draws the preset + they are looking at, built from what the card already states: the preset it composes and the + record that preset takes, written the way the preset's own runnable example writes it — the + no-argument `create()`, which carries the preset's theme rather than another preset's. The + family's block stays on the one card it composes, and the link to the page it is published on + stays on all of them. It is not that block with its composing line rewritten, because the + invoices rule that out: `ConsultingInvoice` takes `StructuredInvoiceDocumentSpec` where the + block builds `InvoiceDocumentSpec`, so a rewrite would hand a reader a record the preset does + not accept. + +- **The catalogue comes before the install instructions.** A visitor scrolling the home page met + the Maven coordinates and a format-by-format comparison before seeing anything the library + draws. The gallery now follows the opening block directly: choose the result, then wire the + library up. Every id and anchor is unchanged, so existing links and shared viewer addresses + still land where they did. + +- **The line above the catalogue says what clicking a preview does.** It promised the rendered + PDF; a viewer opens, which pages through the document, moves across the rest of its family and + links the PDF, the source and what reproducing it takes. + +- **The site has a link preview drawn for the shape a link preview is.** `og:image` and + `twitter:image` named a portrait page of one proposal, 893 by 1263, so a large-image card — + which is landscape — published a band cropped out of its middle: a paragraph of a document + nobody had asked about, under a link about the library. `SiteSocialCoverRenderer` composes a + cover at 1200 by 630 with GraphCompose itself, in the site's own palette, carrying the + wordmark, what the library does, and three documents read from the previews the catalogue + publishes. It states no version and no measured figure, so it outlives the release that + published it, and it is published under a name of its own because Slack, X and LinkedIn cache + a preview by URL. `twitter:card` stays `summary_large_image`, and both tags now declare the + size and an alt text. A document's own page keeps its own preview: there the document is the + subject. + ## v2.4.1 — 2026-09-21 ### Performance diff --git a/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java b/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java new file mode 100644 index 000000000..c8cb7e107 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/support/SiteSocialCoverRenderer.java @@ -0,0 +1,286 @@ +package com.demcha.examples.support; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.ImageBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.node.CanvasChild; +import com.demcha.compose.document.node.CanvasLayerNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.svg.SvgIcon; +import com.demcha.compose.font.FontName; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Renders the link-preview cover the showcase site publishes as its {@code og:image}. + * + *

The home page used to hand X and Slack a portrait page of one proposal, 893 by 1263. + * A large-image card is landscape, so the preview showed a crop of whatever fell in the + * middle of that page — a paragraph of a document nobody had asked about, under a link + * about the library. This is a cover drawn for that shape: 1200 by 630, the size every + * platform crops least, with the wordmark, what the library does, and three real documents + * from the catalogue standing in for the rest.

+ * + *

Nothing on it dates: no version, no measured figure, no release name. A cover that + * carries one has to be redrawn on the cut, and a cut that forgets publishes a stale claim + * to every feed that reads the page. What the page states about the release is stated in + * the page, where {@code web-src/data/release.json} already moves it.

+ * + *

The three documents are read from {@code web/showcase/thumbnails/} — the previews the + * catalogue itself publishes, so the cover cannot show a template the site does not have, + * and a rerun after a render change picks the new ones up.

+ * + *

Usage — pass an explicit output path:

+ *
+ * ./mvnw -B -ntp -f examples/pom.xml -DskipTests exec:java \
+ *   -Dexec.mainClass=com.demcha.examples.support.SiteSocialCoverRenderer \
+ *   -Dexec.args="<outputPng>"
+ * 
+ * + * @author Artem Demchyshyn + * @since 2.4.2 + */ +public final class SiteSocialCoverRenderer { + + /** The size a large-image card is composed at; every major platform crops it least. */ + public static final double COVER_WIDTH = 1200; + /** The size a large-image card is composed at; every major platform crops it least. */ + public static final double COVER_HEIGHT = 630; + + /** 72 DPI over a page declared in points writes exactly COVER_WIDTH by COVER_HEIGHT pixels. */ + private static final int POINTS_PER_INCH = 72; + + // The site's own palette, as web/styles.css declares it for the dark theme. + private static final DocumentColor NIGHT = DocumentColor.rgb(11, 16, 32); + private static final DocumentColor SURFACE = DocumentColor.rgb(17, 24, 39); + private static final DocumentColor GRID = DocumentColor.rgb(30, 41, 69); + private static final DocumentColor ON_DARK = DocumentColor.rgb(248, 250, 252); + private static final DocumentColor ON_DARK_MUTED = DocumentColor.rgb(148, 163, 184); + private static final DocumentColor ACCENT = DocumentColor.rgb(99, 102, 241); + private static final DocumentColor ACCENT_TEXT = DocumentColor.rgb(165, 180, 252); + private static final DocumentColor SHEET_EDGE = DocumentColor.rgb(203, 213, 225); + + /** The documents the cover shows, as their paths under the published catalogue. */ + private static final String[] SHOWN = { + "web/showcase/thumbnails/templates/cv/cv-blue-banner-v2.png", + "web/showcase/thumbnails/templates/invoice/invoice-modern-v2.png", + "web/showcase/thumbnails/templates/proposal/proposal-editorial-v2.png" + }; + + /** Each shown document, drawn at this width; the height follows the thumbnails' 320x453. */ + private static final double SHEET_WIDTH = 210; + private static final double SHEET_HEIGHT = SHEET_WIDTH * 453 / 320; + + /** The card around a sheet: the sheet plus 6pt of surface on every side. */ + private static final double SHEET_CARD_WIDTH = SHEET_WIDTH + 12; + private static final double SHEET_CARD_HEIGHT = SHEET_HEIGHT + 12; + + /** + * Where the fan starts and how far each sheet steps. + * + *

The last card has to land inside the page: at {@code LEFT + 2 * STEP + card width} + * it ends at 1158 of 1200, leaving a margin that matches the one on the left. An earlier + * pass stepped 176 from 648 and put the third document 48 points past the right edge, + * which a render showed and no assertion would have.

+ */ + private static final double FAN_LEFT = 636; + private static final double FAN_STEP = 150; + + /** The fan, centred in the page, with the middle sheet lifted to give it a front. */ + private static final double FAN_TOP = (COVER_HEIGHT - SHEET_CARD_HEIGHT) / 2; + private static final double FAN_LIFT = 28; + + private SiteSocialCoverRenderer() { + } + + /** + * Runs the renderer. + * + * @param args one argument — where to write the PNG + * @throws Exception when composition or rendering fails + */ + public static void main(String[] args) throws Exception { + if (args.length < 1 || args[0].isBlank()) { + System.err.println("Usage: SiteSocialCoverRenderer "); + System.exit(2); + } + Path written = render(Paths.get(args[0]).toAbsolutePath().normalize()); + System.out.println("Generated: " + written + " (" + + (int) COVER_WIDTH + "x" + (int) COVER_HEIGHT + ")"); + } + + /** + * Composes the cover and writes it as a PNG, creating parent directories as needed. + * + * @param outputPng destination file + * @return the written path + * @throws Exception when composition or rendering fails + */ + public static Path render(Path outputPng) throws Exception { + BufferedImage image; + try (DocumentSession document = GraphCompose.document() + .pageSize(COVER_WIDTH, COVER_HEIGHT) + .pageBackground(NIGHT) + .margin(DocumentInsets.zero()) + .create()) { + document.pageFlow().name("SocialCover").add(scene()).build(); + image = document.toImage(0, POINTS_PER_INCH); + } + Files.createDirectories(outputPng.toAbsolutePath().getParent()); + ImageIO.write(image, "png", outputPng.toFile()); + return outputPng; + } + + /** The cover as a single node. */ + private static DocumentNode scene() { + List layers = new ArrayList<>(grid()); + + // The wordmark carries the mark and the name together - it is one set of paths, so + // the two cannot drift apart or be scaled against each other by accident. + layers.add(at(icon("logo").node(430), 72, 92)); + layers.add(at(rule(96), 76, 232)); + layers.add(at(text("Tagline", "Document generation for Java", + display(36, ON_DARK), 520), 74, 260)); + layers.add(at(text("Subtitle", + "Compose a document once. Render it to PDF, PowerPoint or Word.", + body(18, ON_DARK_MUTED), 470), 76, 322)); + layers.add(at(text("Formats", "PDF · PPTX · DOCX", + body(15, ACCENT_TEXT), 420), 76, 470)); + + // Three real documents from the catalogue, fanned so each stays readable as a shape + // while the group reads as a stack rather than as three unrelated pictures. + for (int i = 0; i < SHOWN.length; i++) { + boolean middle = i == SHOWN.length / 2; + layers.add(at(sheet(SHOWN[i]), + FAN_LEFT + i * FAN_STEP, + middle ? FAN_TOP - FAN_LIFT : FAN_TOP)); + } + return new CanvasLayerNode("SocialCover", COVER_WIDTH, COVER_HEIGHT, layers, + ClipPolicy.CLIP_BOUNDS, DocumentInsets.zero(), DocumentInsets.zero()); + } + + /** One catalogue document, in a card with the site's own surface and edge. */ + private static DocumentNode sheet(String thumbnail) { + Path file = repoRoot().resolve(thumbnail); + if (!Files.isRegularFile(file)) { + throw new IllegalStateException("the cover names a preview the catalogue does not " + + "publish: " + thumbnail); + } + return new ShapeContainerBuilder() + .name("Sheet") + .roundedRect(SHEET_CARD_WIDTH, SHEET_CARD_HEIGHT, 10) + .fillColor(SURFACE) + .stroke(DocumentStroke.of(SHEET_EDGE.withOpacity(0.28), 1)) + .position(new ImageBuilder() + .name("Preview") + .source(file) + .size(SHEET_WIDTH, SHEET_HEIGHT) + .build(), 6, 6, LayerAlign.TOP_LEFT) + .build(); + } + + /** The faint grid the site's own hero carries, so the cover reads as part of it. */ + private static List grid() { + List lines = new ArrayList<>(); + for (double y = 0; y <= COVER_HEIGHT; y += 42) { + lines.add(at(new ShapeBuilder().size(COVER_WIDTH, 1) + .fillColor(GRID.withOpacity(0.55)).build(), 0, y)); + } + for (double x = 0; x <= COVER_WIDTH; x += 42) { + lines.add(at(new ShapeBuilder().size(1, COVER_HEIGHT) + .fillColor(GRID.withOpacity(0.55)).build(), x, 0)); + } + return lines; + } + + private static DocumentNode rule(double width) { + return new ShapeBuilder().size(width, 4).fillColor(ACCENT).build(); + } + + private static CanvasChild at(DocumentNode node, double x, double y) { + return new CanvasChild(node, x, y); + } + + private static DocumentNode text(String name, String value, DocumentTextStyle style, + double width) { + return new ParagraphBuilder() + .name(name) + .text(value) + .textStyle(style) + .align(TextAlign.LEFT) + .lineSpacing(1.3) + .margin(new DocumentInsets(0, COVER_WIDTH - width, 0, 0)) + .build(); + } + + /** + * Bold display type. The weight comes from the decoration rather than from a + * {@code *_BOLD} font name: the standard-14 style variants are family aliases, so naming + * one selects the family and leaves the face at regular. + */ + private static DocumentTextStyle display(double size, DocumentColor color) { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .decoration(DocumentTextDecoration.BOLD) + .size(size) + .color(color) + .build(); + } + + private static DocumentTextStyle body(double size, DocumentColor color) { + return DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(size) + .color(color) + .build(); + } + + private static SvgIcon icon(String name) { + try (InputStream in = Objects.requireNonNull( + SiteSocialCoverRenderer.class.getResourceAsStream("/showcase/" + name + ".svg"), + "showcase icon missing: " + name)) { + return SvgIcon.parse(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("failed to load showcase icon: " + name, e); + } + } + + /** + * The repository root, found by walking up from the working directory until the published + * catalogue is under it. The renderer is started from the examples module by the site + * tooling and from the repository root by hand, and the previews it reads live at a path + * relative to neither. + * + * @return the directory holding {@code web/showcase} + */ + private static Path repoRoot() { + Path here = Paths.get("").toAbsolutePath().normalize(); + for (Path candidate = here; candidate != null; candidate = candidate.getParent()) { + if (Files.isDirectory(candidate.resolve("web/showcase/thumbnails"))) { + return candidate; + } + } + throw new IllegalStateException("no web/showcase/thumbnails above " + here + + ": run this from the repository or the examples module"); + } +} diff --git a/scripts/site/gallery-viewer.test.mjs b/scripts/site/gallery-viewer.test.mjs index 5e928e982..9032d4edf 100644 --- a/scripts/site/gallery-viewer.test.mjs +++ b/scripts/site/gallery-viewer.test.mjs @@ -599,13 +599,27 @@ check("with no release context the panel names no coordinates rather than guessi check("the panel shows the family's compiled block, as the manifest carries it", () => { const page = viewerHarness(); - page.viewer.open({ category: "templates", group: "cv", id: "cv-blue-banner-v2" }, { history: "push" }); + page.viewer.open({ category: "templates", group: "cv", id: "cv-boxed-sections-v2" }, { history: "push" }); page.click(page.parts.panelToggle); const code = manifest.snippets.cv.code; assert.ok(code && code.length > 0, "the manifest carries a CV snippet for the panel to show"); assert.ok(page.panelText().includes(code), "the block is shown as it stands, not paraphrased"); }); +check("a preset the family's block does not compose is given a block of its own", () => { + const page = viewerHarness(); + page.viewer.open({ category: "templates", group: "cv", id: "cv-blue-banner-v2" }, { history: "push" }); + page.click(page.parts.panelToggle); + const said = page.panelText(); + assert.ok(!said.includes(manifest.snippets.cv.code), + "the family's block composes BoxedSections: shown on Blue Banner it is code that draws a " + + "different CV, and a caption saying so does not stop it being copied"); + assert.ok(said.includes("BlueBanner.create()"), + "the block a reader can copy here has to compose the preset they are looking at"); + assert.ok(!said.includes("BoxedSections"), "and nothing of the preset it does not"); + assert.ok(said.includes("CvDocument"), "named with the record this preset takes"); +}); + check("a family with no compiled block shows no snippet rather than another family's", () => { const page = viewerHarness(); page.viewer.open({ category: "templates", group: "coverletter", id: "cover-letter" }, { history: "push" }); @@ -685,26 +699,39 @@ check("the panel model runs with no DOM at all", () => { assert.ok(model.items.length > 0 && model.links.length > 0); }); -check("the family's block is called the document's own only on the card of the preset it composes", () => { - const labelOf = (id) => { +check("the family's block is shown only on the card of the preset it composes", () => { + const familyBlockOf = (id) => { const home = catalogue.get(id); const block = catalogue.snippets[home.groupId].code; - const listing = gallery.panelModel(home.example, catalogue, RELEASE).items.find((item) => item.code === block); - assert.ok(listing, "fixture: the panel of " + id + " shows its family's block"); - return listing.label; + return gallery.panelModel(home.example, catalogue, RELEASE).items.find((item) => item.code === block); + }; + const presetBlockOf = (id) => { + const home = catalogue.get(id); + return gallery.panelModel(home.example, catalogue, RELEASE).items + .find((item) => item.label === "Compose this preset"); }; assert.match(manifest.snippets.cv.code, /\bBoxedSections\.create\(/, "fixture: the CV block composes BoxedSections"); - assert.equal(labelOf("cv-boxed-sections-v2"), "Compose it"); - assert.equal(labelOf("cv-blue-banner-v2"), "From the docs", - "the CV block builds BoxedSections; labelled 'Compose it' on Blue Banner it promises a document it does not build"); - assert.equal(labelOf("invoice-modern-v2"), "Compose it"); - assert.equal(labelOf("invoice-classic-v2"), "From the docs"); - assert.equal(labelOf("project-proposal-cinematic"), "From the docs", "a card that builds no preset"); - - // Matched as a whole name: a preset called Sections does not compose BoxedSections. + + assert.equal(familyBlockOf("cv-boxed-sections-v2").label, "Compose it"); + assert.equal(familyBlockOf("invoice-modern-v2").label, "Compose it"); + assert.equal(familyBlockOf("project-proposal-cinematic").label, "From the docs", + "a card that builds no preset has no preset block to be given instead"); + + // Every other preset of the family gets its own block rather than the family's. The caption + // was honest before and the code under it still drew another design, which is what a reader + // copies. ConsultingInvoice is why the block is not the family's with one line rewritten: it + // takes StructuredInvoiceDocumentSpec where that block builds InvoiceDocumentSpec. + for (const [id, preset] of [["cv-blue-banner-v2", "BlueBanner"], ["invoice-classic-v2", "ClassicInvoice"]]) { + assert.equal(familyBlockOf(id), undefined, id + " is still shown a block composing another preset"); + assert.match(presetBlockOf(id).code, new RegExp("\\b" + preset + "\\.create\\(\\)")); + } + + // Matched as a whole name: a preset called Sections does not compose BoxedSections, so it is + // given its own block rather than told the family's is its own. const card = { id: "sections", kind: "PRESET", presetClass: "com.example.presets.Sections" }; const fixture = { snippets: { cv: { code: manifest.snippets.cv.code } }, get: () => ({ groupId: "cv", example: card }) }; - assert.equal(gallery.panelModel(card, fixture, RELEASE).items.find((item) => "code" in item).label, "From the docs"); + assert.equal(gallery.panelModel(card, fixture, RELEASE).items.find((item) => "code" in item).label, + "Compose this preset"); }); check("a listing that is not on the family guide's page links the page it is on", () => { diff --git a/web-src/pages/index.html b/web-src/pages/index.html index bb7fab294..62473efcf 100644 --- a/web-src/pages/index.html +++ b/web-src/pages/index.html @@ -22,17 +22,25 @@ - - - - + + + + + + - + + + + + + + - + +