Public: anyone can read this; the crew can change it.

Share cards speak the home page's type and restraint, and each is checked as an image #9628

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9578-share-cards-speak-the-home-page-s-type-a → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9578-share-cards-speak-the-home-page-s-type-a was merged into main as e0e1214.

Review

Sign in to review this change.
4 files changed +144 −35
src/web/og/card.ts +15 −28
@@ -21,7 +21,7 @@
2121
2222 import { THEMES } from "../../design/themes.ts";
2323 import { graphSvg, type GraphFigure } from "./graph.ts";
24 import { clean, escapeXml, fit } from "./xml.ts";
24 import { advance, clean, escapeXml, fit } from "./xml.ts";
2525 import { scoreSvg, type ScoreFigure } from "./score.ts";
2626
2727 export const CARD_WIDTH = 1200;
@@ -73,37 +73,22 @@ const TITLE_LINES = 2;
7373 const EM_BOLD = 0.68;
7474 const EM_MONO = 0.62;
7575
76 /** Break a title into at most `n` lines of `max` chars; the last is clamped. */
76 /** Break a title into at most `n` rows that fit `width`; what does not fit ends the last row in an ellipsis. */
7777 function wrap(text: string, size: number, n: number, em: number, width = INNER): string[] {
78 const max = Math.max(1, Math.floor(width / (size * em)));
78 const limit = width / (size * em);
7979 const words = clean(text).split(" ").filter(Boolean);
8080 const rows: string[] = [];
81 let cur = "";
8281 let i = 0;
83 for (; i < words.length && rows.length < n; i++) {
84 const w = words[i]!;
85 const next = cur ? `${cur} ${w}` : w;
86 if (Array.from(next).length <= max) {
87 cur = next;
88 continue;
82 while (i < words.length && rows.length < n) {
83 if (rows.length === n - 1) {
84 rows.push(fit(words.slice(i).join(" "), size, width, em));
85 break;
8986 }
90 if (cur) {
91 rows.push(cur);
92 cur = "";
93 i--;
94 continue;
95 }
96 rows.push(Array.from(w).slice(0, max).join("")); // one word wider than a row
97 if (rows.length === n) break;
87 let row = words[i++]!;
88 while (i < words.length && advance(`${row} ${words[i]}`) <= limit) row += ` ${words[i++]}`;
89 rows.push(fit(row, size, width, em));
9890 }
99 if (cur && rows.length < n) rows.push(cur);
100 const rest = i < words.length || rows.join(" ").length < clean(text).length;
101 if (rest && rows.length) {
102 const last = rows.length - 1;
103 rows[last] = fit(rows[last]! + "…", size, width, em);
104 if (!rows[last]!.endsWith("…")) rows[last] += "…";
105 }
106 return rows.map((r) => fit(r, size, width, em));
91 return rows;
10792 }
10893
10994 function text(
@@ -206,14 +191,16 @@ export function renderCard(input: CardInput): string {
206191 y += lsize + 14;
207192 }
208193
209 // Footer rule and stat.
194 // Footer rule and stat; the stat stays in the words' column beside a figure or
195 // picture, set smaller (down to 20px) before anything is cut.
210196 out.push(
211197 `<rect x="${PAD}" y="${CARD_HEIGHT - 110}" width="${INNER}" height="2" fill="${T.border}"/>`,
212198 );
213199 const stat = clean(input?.stat);
214200 if (stat) {
201 const ssize = Math.max(20, Math.min(32, Math.floor(column / (advance(stat) * EM_MONO))));
215202 out.push(
216 text(PAD, CARD_HEIGHT - 52, 32, T["muted-fg"], fit(stat, 32, INNER, EM_MONO), ` font-family="${MONO}" font-weight="600"`),
203 text(PAD, CARD_HEIGHT - 52, ssize, T["muted-fg"], fit(stat, ssize, column, EM_MONO), ` font-family="${MONO}" font-weight="600"`),
217204 );
218205 }
219206 out.push(`</svg>`);
src/web/og/xml.ts +33 −6
@@ -24,10 +24,37 @@ export function escapeXml(s: string): string {
2424 .replace(/'/g, "&#39;");
2525 }
2626
27 /** Clamp to `width` px at `size` px type; exported for the tests. */
28 export function fit(text: string, size: number, width: number, em = EM): string {
29 const max = Math.max(1, Math.floor(width / (size * em)));
30 const chars = Array.from(clean(text));
31 if (chars.length <= max) return chars.join("");
32 return chars.slice(0, max - 1).join("").trimEnd() + "…";
27 /**
28 * How wide a string is, in units of one average glyph (`EM`): capitals are
29 * wider than that and W, M, m, w, @ and % much wider, so a run of them is not
30 * judged as if it were lowercase.
31 */
32 export function advance(text: string): number {
33 let units = 0;
34 for (const ch of text) units += /[WMmw@%]/.test(ch) ? 1.6 : /[A-Z]/.test(ch) ? 1.25 : 1;
35 return units;
36 }
37
38 /**
39 * Clamp to `width` px at `size` px type; exported for the tests. A cut falls
40 * at a word and ends in an ellipsis, after dropping any separator left at the
41 * cut (`·`, `|`, `/`, `•`, punctuation); only a single word wider than the
42 * whole width is cut inside the word, and it too ends in one.
43 */
44 export function fit(text: string, size: number, width: number, em = EM): string {
outdated

The trailing-trim set lacks · (and |, /, •), so site stat renders …sh ·…. Add them and test it.

45 const limit = width / (size * em);
46 const flat = clean(text);
47 if (advance(flat) <= limit) return flat;
48 const chars = Array.from(flat);
49 let end = 0;
50 let used = 1; // the ellipsis
51 while (end < chars.length && used + advance(chars[end]!) <= limit) used += advance(chars[end++]!);
52 const atWord = chars[end] === " " || chars[end - 1] === " ";
53 let kept = chars.slice(0, end).join("");
54 if (!atWord) {
55 const space = kept.lastIndexOf(" ");
56 // Back to the word before, unless that leaves nothing worth showing.
57 if (space > 0 && /[\p{L}\p{N}]/u.test(kept.slice(0, space))) kept = kept.slice(0, space);
58 }
59 return kept.replace(/[\s\p{P}\p{S}]+$/u, "") + "…";
3360 }
test/og-cards.test.ts +1 −1
@@ -132,7 +132,7 @@ describe("social cards: public refs, private refs, hostile titles", () => {
132132 const res = await get("/og/issue/alice~open~1.svg");
133133 const svg = await res.text();
134134 expect(svg).not.toContain("<script");
135 expect(svg).toContain("&lt;script&gt;alert(1)&lt;/scrip");
135 expect(svg).toContain("&lt;script&gt;alert(1)&lt;/scr");
136136 expect(svg).toContain("&amp; &quot;q&quot;");
137137 });
138138 });
test/og-image.test.ts added +95
@@ -0,0 +1,95 @@
1 import { describe, expect, test } from "bun:test";
2 import { join } from "node:path";
3 import { CARD_HEIGHT, CARD_WIDTH, CARD_KINDS, renderCard, type CardInput } from "../src/web/og/card.ts";
4 import { fit } from "../src/web/og/xml.ts";
5 import { rasterizer } from "../src/web/og/raster.ts";
6 import { Resvg } from "../src/web/og/vendor/resvg.mjs";
7
8 const LONG =
9 "A very long sentence about what this thing does and why anybody would ever want to read all of it on a share card";
10 const SRC = "data:image/png;base64,iVBORw0KGgo=";
11 const VENDOR = join(import.meta.dir, "../src/web/og/vendor");
12 const PAD = 80;
13 const COLUMN_RIGHT = PAD + 620;
14
15 const fonts = await Promise.all(
16 ["DejaVuSans.ttf", "DejaVuSans-Bold.ttf", "DejaVuSansMono.ttf", "DejaVuSansMono-Bold.ttf"].map(
17 async (f) => new Uint8Array(await Bun.file(join(VENDOR, f)).arrayBuffer()),
18 ),
19 );
20
21 function inputs(kind: (typeof CARD_KINDS)[number]): { input: CardInput; right: number }[] {
22 const shapes: CardInput[] = [
23 { kind, title: "Fast forge", lines: ["one", "two"], stat: "12 open" },
24 { kind, title: LONG + " " + LONG, tagline: LONG, lines: [LONG, LONG, LONG], stat: LONG },
25 { kind, title: "x".repeat(300), lines: ["y".repeat(300)], stat: "z".repeat(300) },
26 // Wide glyphs, where an average-width estimate is tightest.
27 { kind, title: "WMWM ".repeat(30), tagline: "MMMM WWWW 0123456789".repeat(4), lines: ["W".repeat(80), "0123456789 ".repeat(20), "@%mw".repeat(30)], stat: "WWWW MMMM 0000 ".repeat(8) },
28 ];
29 return [
30 ...shapes.map((input) => ({ input, right: CARD_WIDTH - PAD })),
31 ...shapes.map((input) => ({ input: { ...input, images: [{ id: "a", src: SRC }] }, right: COLUMN_RIGHT })),
32 ];
33 }
34
35 function pngSize(png: Uint8Array): [number, number] {
36 const v = new DataView(png.buffer, png.byteOffset);
37 return [v.getUint32(16), v.getUint32(20)];
38 }
39
40 describe("og cards as images", () => {
41 for (const kind of CARD_KINDS) {
42 test(`${kind}: rendered 1200×630, no text box leaves its margins`, async () => {
43 const raster = await rasterizer();
44 for (const { input, right } of inputs(kind)) {
45 const svg = renderCard(input);
46 const png = raster(svg);
47 expect(Array.from(png.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
48 expect(pngSize(png)).toEqual([CARD_WIDTH, CARD_HEIGHT]);
49 const head = svg.slice(0, svg.indexOf(">") + 1);
50 let boxes = 0;
51 for (const m of svg.matchAll(/<text [^>]*>[^<]*<\/text>/g)) {
outdated

The footer carve-out lets the stat bypass the column check; clamp the stat in card.ts instead of excusing it here.

52 const r = new Resvg(`${head}${m[0]}</svg>`, {
53 font: {
54 fontBuffers: fonts,
55 loadSystemFonts: false,
56 defaultFontFamily: "DejaVu Sans",
57 sansSerifFamily: "DejaVu Sans",
58 monospaceFamily: "DejaVu Sans Mono",
59 },
60 });
61 const box = r.innerBBox();
62 r.free();
63 if (!box) continue;
64 boxes++;
65 const x = Number(m[0].match(/ x="(\d+)"/)![1]);
66 const tolerance = x === PAD ? 4 : 0; // the logo word sits right of the mark
67 expect(box.x).toBeGreaterThanOrEqual(x - tolerance);
68 expect(box.x + box.width).toBeLessThanOrEqual(x === PAD ? right : CARD_WIDTH - PAD);
69 expect(box.y).toBeGreaterThanOrEqual(0);
70 expect(box.y + box.height).toBeLessThanOrEqual(CARD_HEIGHT);
71 }
72 expect(boxes).toBeGreaterThan(2);
73 }
74 });
75 }
76 });
77
78 describe("fit cuts at a word", () => {
79 test("no separator is left dangling before the ellipsis", () => {
80 for (const sep of ["·", "|", "/", "•", "-", ","]) {
81 const out = fit(`lab--9628--forge.example.sh ${sep} open source and more`, 32, 560, 0.62);
82 expect(out.endsWith("…")).toBe(true);
83 expect(out.slice(0, -1)).toMatch(/[A-Za-z0-9]$/);
84 }
85 expect(fit("host.example.sh · open source", 32, 32 * 0.62 * 19)).toBe("host.example.sh…");
86 });
87 test("a long sentence ends in an ellipsis after a whole word", () => {
88 const out = fit(LONG, 30, 400);
89 expect(out.endsWith("…")).toBe(true);
90 for (const w of out.slice(0, -1).split(" ")) expect(LONG.split(" ")).toContain(w);
91 });
92 test("one word wider than the box is cut with an ellipsis", () => {
93 expect(fit("x".repeat(100), 30, 300).endsWith("…")).toBe(true);
94 });
95 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs