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 ``;
}
// 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,
};
}