/** * Contrast analysis — decides the ink colour and whether a scrim is needed, BEFORE * Typst runs. Keeping this in TypeScript keeps the templates pure functions of the spec. * * The rule that matters: text must be legible against what is ACTUALLY behind it. A mean * luminance check alone is not enough — white text over busy artwork can have a perfectly * comfortable mean and still be unreadable, so variance forces a scrim independently. */ import sharp from "sharp"; import type { TemplateName } from "../design/spec.ts"; /** WCAG AA for large text. Everything a poster sets is large, so this is the honest bar. */ export const MIN_CONTRAST = 3.0; /** Above this per-channel stdev the region is "busy" and needs a scrim regardless of mean. */ export const BUSY_STDEV = 48; export interface Region { /** All fractions of the image, 0..1. */ left: number; top: number; width: number; height: number; } export interface RegionAnalysis { luminance: number; stdev: number; busy: boolean; ink: string; contrast: number; needsScrim: boolean; } /** sRGB channel (0..255) -> linear 0..1. */ function linearise(v: number): number { const u = v / 255; return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4); } export function relativeLuminance(r: number, g: number, b: number): number { return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b); } export function contrastRatio(l1: number, l2: number): number { const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1]; return (hi + 0.05) / (lo + 0.05); } export function hexLuminance(hex: string): number { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return 0; const n = parseInt(m[1], 16); return relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255); } /** * Where each template puts its type, as a fraction of the artwork. * * These mirror the layouts in templates/*.typ. `framed` and `split` are deliberately * absent: neither places text over the picture at all (split says so in its own source), * so there is nothing to measure and no scrim to draw. */ export const TEXT_REGIONS: Partial> = { "hero-bottom": { left: 0, top: 0.55, width: 1, height: 0.45 }, banded: { left: 0, top: 0.34, width: 1, height: 0.32 }, "centred-stack": { left: 0.06, top: 0.22, width: 0.88, height: 0.56 }, }; /** True when this template sets type over the artwork. */ export function typeSitsOnArt(template: TemplateName): boolean { return template in TEXT_REGIONS; } /** * Measure the region of `imagePath` that will sit behind the text. * * Returns the ink to use over the ARTWORK plus whether a scrim is required. Never throws * on a missing or unreadable image: a poster that renders with a safe default beats a * crash, so the fallback is white ink with a scrim. */ export async function analyseRegion(imagePath: string, region: Region): Promise { try { const img = sharp(imagePath); const meta = await img.metadata(); const W = meta.width ?? 0; const H = meta.height ?? 0; if (!W || !H) return fallback(); // Clamp: a region partly outside the image must still yield a valid extract box. const left = Math.max(0, Math.min(W - 1, Math.round(region.left * W))); const top = Math.max(0, Math.min(H - 1, Math.round(region.top * H))); const width = Math.max(1, Math.min(W - left, Math.round(region.width * W))); const height = Math.max(1, Math.min(H - top, Math.round(region.height * H))); // Flatten onto mid-grey first: an alpha channel would otherwise skew the means, and // .stats() reports premultiplied values that misrepresent what a viewer sees. const stats = await sharp(imagePath) .extract({ left, top, width, height }) .flatten({ background: { r: 128, g: 128, b: 128 } }) .toColourspace("srgb") .stats(); const ch = stats.channels.slice(0, 3); if (ch.length < 3) return fallback(); const luminance = relativeLuminance(ch[0].mean, ch[1].mean, ch[2].mean); const stdev = Math.max(...ch.map((c) => c.stdev)); const busy = stdev > BUSY_STDEV; const white = 1.0; const black = 0.0; const rWhite = contrastRatio(white, luminance); const rBlack = contrastRatio(black, luminance); const useWhite = rWhite >= rBlack; return { luminance, stdev, busy, ink: useWhite ? "#ffffff" : "#111111", contrast: Math.max(rWhite, rBlack), // A scrim goes on when contrast is short OR the region is busy. The busy case is // the one a mean-only check silently gets wrong. needsScrim: Math.max(rWhite, rBlack) < MIN_CONTRAST || busy, }; } catch { return fallback(); } } function fallback(): RegionAnalysis { return { luminance: 0.5, stdev: 0, busy: false, ink: "#ffffff", contrast: 1, needsScrim: true, }; } /** * Crop `input` to exactly `size` using sharp's attention strategy, which keeps the most * salient part of the picture. (`smartcrop.js` is five years unmaintained — not used.) * * Returns the size actually produced, read back from the written file rather than echoed * from the request, so a caller is never told a size the image does not have. */ export async function smartCrop( input: string, out: string, size: { width: number; height: number }, opts: { signal?: AbortSignal; onProgress?: (message: string) => void } = {}, ): Promise<{ path: string; width: number; height: number }> { opts.signal?.throwIfAborted(); opts.onProgress?.("Ritaglio l'immagine…"); const info = await sharp(input) .resize({ width: Math.max(1, Math.round(size.width)), height: Math.max(1, Math.round(size.height)), fit: "cover", position: sharp.strategy.attention, }) .toFile(out); return { path: out, width: info.width, height: info.height }; } /** Pixel dimensions of an image, or null when it cannot be read. */ export async function imageSize(path: string): Promise<{ width: number; height: number } | null> { try { const m = await sharp(path).metadata(); return m.width && m.height ? { width: m.width, height: m.height } : null; } catch { return null; } }