import { escapeHtml } from "./html.ts"; const OG_CARD_WIDTH = 1200; const OG_CARD_HEIGHT = 630; // Always the light palette, so previews look identical across feeds regardless of device theme. const PALETTE = { bg: "#fafaf9", surface: "#ffffff", border: "#e7e5e4", text: "#1c1917", textSecondary: "#44403c", textMuted: "#78716c", accent: "#0f766e", accentSoft: "#f0fdfa", }; // This SVG is only ever rasterized server-side by sharp/librsvg, never served to a // browser, so the stack must name families installed in the image (see Containerfile). // A stack of system-ui/-apple-system resolves to nothing there and every glyph renders // as tofu. const FONT_STACK = "'DejaVu Sans','Noto Sans',sans-serif"; // Geometry copied verbatim from public/logo.svg — keep in sync with favicon/nav logo. const LICHEN_LOGO_SVG = (size: number, color: string): string => ``; // Layout is measured in "width units": one unit is roughly one Latin character, or // 0.62em. Full-width characters (CJK, Kana, Hangul) advance about 1.6 units, so a plain // character count over-fills every line in those scripts and runs the text off the card. const FULL_WIDTH = /[\u1100-\u115f\u2e80-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/u; function widthUnits(text: string): number { let units = 0; for (const char of text) units += FULL_WIDTH.test(char) ? 1.6 : 1; return units; } function truncate(text: string, maxUnits: number): string { if (widthUnits(text) <= maxUnits) return text; let kept = ""; let units = 0; for (const char of text) { const next = units + (FULL_WIDTH.test(char) ? 1.6 : 1); if (next > maxUnits - 1) break; kept += char; units = next; } return `${kept}…`; } // CJK text carries no spaces, so it offers no break opportunity — a whole sentence // arrives as one "word" and has to be hard-split to stay inside the card. function splitToWidth(word: string, maxUnits: number): string[] { if (widthUnits(word) <= maxUnits) return [word]; const chunks: string[] = []; let current = ""; let units = 0; for (const char of word) { const width = FULL_WIDTH.test(char) ? 1.6 : 1; if (units + width > maxUnits) { chunks.push(current); current = ""; units = 0; } current += char; units += width; } if (current) chunks.push(current); return chunks; } // Greedy word-wrap — approximate, but fine for sans-serif at these sizes. function wrapText(text: string, maxUnits: number, maxLines: number): string[] { const words = text .split(/\s+/) .filter(Boolean) .flatMap((word) => splitToWidth(word, maxUnits)); const lines: string[] = []; let current = ""; let dropped = false; for (const word of words) { const candidate = current ? `${current} ${word}` : word; if (widthUnits(candidate) <= maxUnits) { current = candidate; continue; } if (lines.length + 1 === maxLines) { dropped = true; break; } if (current) lines.push(current); current = word; } if (current) lines.push(current); const last = lines.length - 1; if (dropped && last >= 0) { lines[last] = truncate(`${lines[last]}…`, maxUnits); } return lines; } // The name has 952px between the card's inner padding — 13.2em at 72px. Shrink before // truncating so long names stay readable. function titleLayout(name: string): { text: string; fontSize: number } { const units = widthUnits(name); if (units <= 21) return { text: name, fontSize: 72 }; if (units <= 30) return { text: name, fontSize: 50 }; return { text: truncate(name, 42), fontSize: 36 }; } interface BrandingArgs { x: number; y: number; } function brandingMark({ x, y }: BrandingArgs): string { const logoSize = 36; return ` ${LICHEN_LOGO_SVG(logoSize, PALETTE.accent)} Lichen `; } interface WikiCardData { name: string; description: string; language: string | null; noteCount: number; ownerHandle: string | null; } export function buildWikiCardSvg(data: WikiCardData): string { const PAD = 60; const INNER_PAD = 64; const innerX = PAD; const innerY = PAD; const innerW = OG_CARD_WIDTH - PAD * 2; const innerH = OG_CARD_HEIGHT - PAD * 2; const contentX = innerX + INNER_PAD; const contentRight = innerX + innerW - INNER_PAD; const title = titleLayout(data.name); const descLines = wrapText(data.description ?? "", 50, 3); const langBadge = data.language ? ` ${escapeHtml(data.language.toUpperCase())} ` : ""; const descY = data.language ? 320 : 280; const descSvg = descLines .map( (line, i) => `${escapeHtml(line)}`, ) .join(""); const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; const noteLabel = `${data.noteCount} ${data.noteCount === 1 ? "note" : "notes"}`; const ownerBlock = data.ownerHandle ? `${escapeHtml(data.ownerHandle)} ` : ""; return ` ${brandingMark({ x: contentX, y: innerY + 32 })} ${escapeHtml(title.text)} ${langBadge} ${descSvg} ${escapeHtml(noteLabel)} ${ownerBlock} `; } // Returns null on any failure; the card still renders with the placeholder circle drawn underneath. async function fetchAvatarCircle( url: string, diameter: number, ): Promise { try { const { default: sharp } = await import("sharp"); const res = await fetch(url, { signal: AbortSignal.timeout(3000) }); if (!res.ok) return null; const bytes = Buffer.from(await res.arrayBuffer()); const resized = await sharp(bytes) .resize(diameter, diameter, { fit: "cover" }) .png() .toBuffer(); const mask = Buffer.from( ``, ); return await sharp(resized) .composite([{ input: mask, blend: "dest-in" }]) .png() .toBuffer(); } catch { return null; } } export async function renderCardPng( svg: string, avatar?: { url: string; diameter: number; left: number; top: number } | null, ): Promise { const { default: sharp } = await import("sharp"); const base = sharp(Buffer.from(svg)).png(); if (!avatar) return await base.toBuffer(); const avatarPng = await fetchAvatarCircle(avatar.url, avatar.diameter); if (!avatarPng) return await base.toBuffer(); return await base .composite([{ input: avatarPng, top: avatar.top, left: avatar.left }]) .toBuffer(); } // Must match the placeholder circle drawn in buildWikiCardSvg (translate + cx/cy/r). export function wikiCardAvatarSlot(): { diameter: number; left: number; top: number; } { const PAD = 60; const INNER_PAD = 64; const contentRight = OG_CARD_WIDTH - PAD - INNER_PAD; const footerY = OG_CARD_HEIGHT - PAD - INNER_PAD - 18; return { diameter: 60, left: contentRight - 66, top: footerY - 42, }; }