index.ts was written against assumed renderer signatures (render(req)) while typst.ts exposed render(resolved, jobDir, outPath). It type-checked -- only typeof === 'function' was ever asserted -- but would have failed at runtime. typst.ts now exports the request-shaped render()/renderAllFormats() the commands' Renderer interface declares; the low-level entry points are renderOne()/renderFormats(). smartCrop() likewise adopts retouch.ts's richer contract, returning the size read back from the written file. Verified e2e: a3-portrait -> PNG+PDF, ig-story -> PNG, Italian progress.
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
/**
|
|
* 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<Record<TemplateName, Region>> = {
|
|
"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<RegionAnalysis> {
|
|
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;
|
|
}
|
|
}
|