shots: app titles get the weights the templates use (variable axis, used range or static weights; font-weight warning), palette.bg as one colour or a CSS gradient string (validated; contrast guard in shots check), photo backgrounds (palette.image, shots.feature.background) with overlay and cover crop

This commit is contained in:
CRTkafa 2026-10-20 13:37:37 +07:00
parent 2ba8278ab7
commit 3a11682a37
21 changed files with 713 additions and 44 deletions

View file

@ -66,12 +66,19 @@ export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR',
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
/** One downloaded face: `face` is the @font-face family name the templates use. axis: css2 axis spec ("wght@800",
* "wght@100..900") or null for the font's default instance. */
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo }
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo;
/** Weights the templates use for this face's role (title / body): the getter fetches them (weights.ts fetchFaces). */
weights?: number[] }
/** What a getter returns: the covered code points, and (several weights downloaded) the files with their weight. */
export type FaceResult = Set<number> | { cps: Set<number>; files: FaceFile[] };
export interface FaceFile { file: string; weight: string }
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
export interface FontManifest {
v: 1;
/** local: a local font file (shots.fonts entry with a path), read in place, never copied; file = its served path. */
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo }>;
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean; local?: LocalFontInfo;
/** Downloaded files per weight ("700", "200 800"); none = one file for every weight (older manifests). */
files?: FaceFile[] }>;
langs: Record<string, LangFonts>;
}
@ -80,6 +87,8 @@ export interface PlanInput {
title: string; body: string;
/** css2 axis for the title / body family (null = default instance). */
titleAxis: string | null; bodyAxis: string | null;
/** Weights the templates use per role (weights.ts styleWeights); set = fetch these instead of titleAxis / bodyAxis. */
titleWeights?: number[]; bodyWeights?: number[];
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
fallbackTitleAxis: string | null;
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
@ -102,7 +111,14 @@ export class GlyphError extends Error {
/** Faces for every language's copy: title + body, one Noto face per non-Latin script (whole lines, as before), and an
* automatic Noto fallback for each character the stack still lacks. `get` downloads a face (only the listed chars)
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise<Set<number>>): Promise<FontManifest> {
export async function planFonts(input: PlanInput, get0: (job: FaceJob) => Promise<FaceResult>): Promise<FontManifest> {
const files = new Map<string, FaceFile[]>();
const get = async (job: FaceJob): Promise<Set<number>> => {
const r = await get0(job);
if (r instanceof Set) return r;
files.set(job.face, r.files);
return r.cps;
};
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
const local = input.local ?? {};
const extra = [...new Set(input.chosen ?? [])].filter(f => !FALLBACK_ORDER.some(n => n.toLowerCase() === f.toLowerCase()));
@ -130,13 +146,15 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
cps.set(job.face, await get(job));
if (job.kind === 'fallback' && ![...job.chars].some(ch => cps.get(job.face)!.has(ch.codePointAt(0)!))) return; // covers none: not a face
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}) };
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}), ...(files.has(job.face) ? { files: files.get(job.face) } : {}) };
};
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title) }, true);
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body) }, true);
await add({ face: input.title, family: input.title, file: fileOf(input.title, 'title.woff2'), kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title), ...loc(input.title), ...(input.titleWeights ? { weights: input.titleWeights } : {}) }, true);
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: fileOf(input.body, 'body.woff2'), kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body), ...loc(input.body), ...(input.bodyWeights ? { weights: input.bodyWeights } : {}) }, true);
else { // same family: one download (title axis) with both roles' characters
const t = faces[input.title]!;
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title) }));
cps.set(input.title, await get({ face: input.title, family: input.title, file: t.file, kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted([...roleChars.title, ...roleChars.body]), ...loc(input.title),
...(input.titleWeights ? { weights: [...new Set([...input.titleWeights, ...(input.bodyWeights ?? [])])].sort((a, b) => a - b) } : {}) }));
if (files.has(input.title)) t.files = files.get(input.title);
}
for (const [s, v] of scripts) await add({ face: `Store ${s}`, family: v.family, file: `${s}.woff2`, kind: 'script', axis: 'wght@100..900', chars: sorted(v.chars) }, true);
@ -202,6 +220,10 @@ export function fontsCss(m: FontManifest): string {
css += `@font-face { font-family: '${face}'; font-weight: ${l.weight ?? '100 900'}; font-style: ${l.style ?? 'normal'}; font-synthesis: none; src: url(${f.file}?v=${l.hash}) format('${l.format}'); }\n`;
continue;
}
if (f.files?.length) { // one file per downloaded weight: a weight the family lacks is drawn at the nearest one (never faked)
for (const x of f.files) css += `@font-face { font-family: '${face}'; font-weight: ${x.weight}; font-synthesis: none; src: url(${x.file}) format('woff2'); }\n`;
continue;
}
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
}
return css;