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:
parent
2ba8278ab7
commit
3a11682a37
21 changed files with 713 additions and 44 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue