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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9578-share-cards-speak-the-home-page-s-type-a → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +144
−35
src/web/og/card.ts +15 −28
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | |
| 22 | 22 | import { THEMES } from "../../design/themes.ts"; |
| 23 | 23 | import { graphSvg, type GraphFigure } from "./graph.ts"; |
| 24 | import { clean, escapeXml, fit } from "./xml.ts"; | |
| 24 | import { advance, clean, escapeXml, fit } from "./xml.ts"; | |
| 25 | 25 | import { scoreSvg, type ScoreFigure } from "./score.ts"; |
| 26 | 26 | |
| 27 | 27 | export const CARD_WIDTH = 1200; |
| @@ -73,37 +73,22 @@ const TITLE_LINES = 2; | ||
| 73 | 73 | const EM_BOLD = 0.68; |
| 74 | 74 | const EM_MONO = 0.62; |
| 75 | 75 | |
| 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. */ | |
| 77 | 77 | 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); | |
| 79 | 79 | const words = clean(text).split(" ").filter(Boolean); |
| 80 | 80 | const rows: string[] = []; |
| 81 | let cur = ""; | |
| 82 | 81 | 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; | |
| 89 | 86 | } |
| 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)); | |
| 98 | 90 | } |
| 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; | |
| 107 | 92 | } |
| 108 | 93 | |
| 109 | 94 | function text( |
| @@ -206,14 +191,16 @@ export function renderCard(input: CardInput): string { | ||
| 206 | 191 | y += lsize + 14; |
| 207 | 192 | } |
| 208 | 193 | |
| 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. | |
| 210 | 196 | out.push( |
| 211 | 197 | `<rect x="${PAD}" y="${CARD_HEIGHT - 110}" width="${INNER}" height="2" fill="${T.border}"/>`, |
| 212 | 198 | ); |
| 213 | 199 | const stat = clean(input?.stat); |
| 214 | 200 | if (stat) { |
| 201 | const ssize = Math.max(20, Math.min(32, Math.floor(column / (advance(stat) * EM_MONO)))); | |
| 215 | 202 | 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"`), | |
| 217 | 204 | ); |
| 218 | 205 | } |
| 219 | 206 | out.push(`</svg>`); |
src/web/og/xml.ts +33 −6
| @@ -24,10 +24,37 @@ export function escapeXml(s: string): string { | ||
| 24 | 24 | .replace(/'/g, "'"); |
| 25 | 25 | } |
| 26 | 26 | |
| 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 { | |
| 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, "") + "…"; | |
| 33 | 60 | } |
test/og-cards.test.ts +1 −1
| @@ -132,7 +132,7 @@ describe("social cards: public refs, private refs, hostile titles", () => { | ||
| 132 | 132 | const res = await get("/og/issue/alice~open~1.svg"); |
| 133 | 133 | const svg = await res.text(); |
| 134 | 134 | expect(svg).not.toContain("<script"); |
| 135 | expect(svg).toContain("<script>alert(1)</scrip"); | |
| 135 | expect(svg).toContain("<script>alert(1)</scr"); | |
| 136 | 136 | expect(svg).toContain("& "q""); |
| 137 | 137 | }); |
| 138 | 138 | }); |
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 |
||
| 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 | }); |
The trailing-trim set lacks
·(and|,/,•), so site stat renders…sh ·…. Add them and test it.