storebound/cli/shots/weights.ts

113 lines
7.4 KiB
TypeScript

// Font weights: which weights the templates ask for, how to get them from Google Fonts, and whether a rendered title
// got the weight it asked for. The @font-face rules use font-synthesis: none, so a face that lacks a weight is drawn
// at the nearest weight it has (a 750 headline from a 400-only face renders thin), never faked bolder.
// shots fonts: per role (title / body) the weights of the style's templates (templateWeights); a Google family is
// fetched as the full wght axis when it is variable, else the range the templates use, else those weights one by
// one (static families), else the default instance (fetchFaces).
// shots render: the title's computed weight vs. the weights the loaded faces serve (report.json `weights`);
// shots check warns when the browser fell back to another weight (weightIssues).
/** Weights a page's CSS asks for, per role: rules that use var(--tf) are title text, the rest body text (the body
* default 400 included). Literal font-weight values plus the numbers set on --tw by the page script. Pure. */
export function templateWeights(html: string): { title: number[]; body: number[] } {
const tw = [...html.matchAll(/setProperty\('--tw',([^;]+)\)/g)].flatMap(m => [...m[1]!.matchAll(/\b([1-9]00|[1-9][0-9]0)\b/g)].map(x => +x[1]!));
const title = new Set<number>(), body = new Set<number>([400]);
const style = /<style>([\s\S]*?)<\/style>/i.exec(html)?.[1] ?? '';
for (const m of style.matchAll(/[^{}]+\{([^{}]*)\}/g)) {
const decl = m[1]!;
const fw = /font-weight:\s*([^;}]+)/.exec(decl)?.[1]?.trim();
if (!fw) continue;
const vals = fw.includes('var(--tw)') ? tw : /^\d{3}$/.test(fw) ? [+fw] : fw === 'bold' ? [700] : [];
for (const v of vals) (decl.includes('var(--tf)') ? title : body).add(v);
}
const sort = (s: Set<number>): number[] => [...s].sort((a, b) => a - b);
return { title: sort(title.size ? title : new Set([400])), body: sort(body) };
}
/** Weights per role over several pages (a style's screenshot template and feature graphic). */
export function styleWeights(pages: string[]): { title: number[]; body: number[] } {
const t = new Set<number>(), b = new Set<number>();
for (const p of pages) { const w = templateWeights(p); w.title.forEach(x => t.add(x)); w.body.forEach(x => b.add(x)); }
return { title: [...t].sort((x, y) => x - y), body: [...b].sort((x, y) => x - y) };
}
/** css2 axis requests to try in order: the full variable axis, the range the templates use, then the weights the
* templates use rounded to static instances (750 -> 700 and 800). Pure. */
export function axisCandidates(weights: number[]): string[] {
const out = ['wght@100..900'];
const lo = Math.min(...weights), hi = Math.max(...weights);
if (weights.length && lo < hi) out.push(`wght@${lo}..${hi}`);
const statics = [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
if (statics.length) out.push(`wght@${statics.join(';')}`);
return [...new Set(out)];
}
/** Static weights to try one by one when no combined request works. Pure. */
export function staticWeights(weights: number[]): number[] {
return [...new Set(weights.flatMap(w => (w % 100 ? [Math.floor(w / 100) * 100, Math.ceil(w / 100) * 100] : [w])))].sort((a, b) => a - b);
}
/** @font-face blocks of a css2 response: weight descriptor ("400", "200 800") and the font URL. Pure. */
export function parseFaces(css: string): { weight: string; url: string }[] {
return [...css.matchAll(/@font-face\s*\{([^}]*)\}/g)].flatMap(m => {
const url = /url\((https:[^)]+)\)/.exec(m[1]!)?.[1];
const weight = /font-weight:\s*([\d ]+)/.exec(m[1]!)?.[1]?.trim() ?? '400';
return url ? [{ weight, url }] : [];
});
}
/** Whether a weight descriptor ("700", "200 800") serves a weight. */
export function serves(desc: string, w: number): boolean {
const [a, b] = desc.trim().split(/\s+/).map(Number) as [number, number | undefined];
return b === undefined ? a === w : w >= a && w <= b;
}
export interface FetchedFace { weight: string; bin: ArrayBuffer }
type Fetch = (url: string, init?: { headers?: Record<string, string> }) => Promise<{ ok: boolean; status: number; text(): Promise<string>; arrayBuffer(): Promise<ArrayBuffer> }>;
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
/** Downloads a Google family subset to `chars` with the weights the templates use (see axisCandidates). `axis`
* given: that one request (fallback faces), else the weight strategy. fetchFn is injectable (tests run offline). */
export async function fetchFaces(family: string, chars: string, o: { weights?: number[]; axis?: string | null }, fetchFn: Fetch = fetch as unknown as Fetch): Promise<FetchedFace[]> {
const css2 = async (axis: string | null): Promise<{ weight: string; url: string }[] | null> => {
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
const res = await fetchFn(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
if (!res.ok) { if (axis) return null; throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`); }
const faces = parseFaces(await res.text());
if (!faces.length) throw new Error(`${family}: no font url`);
return faces;
};
let faces: { weight: string; url: string }[] | null = null;
if (o.weights?.length && o.axis === undefined) {
for (const axis of axisCandidates(o.weights)) if ((faces = await css2(axis))) break;
if (!faces) { // a static family: each weight it has, one by one
const got: { weight: string; url: string }[] = [];
for (const w of staticWeights(o.weights)) got.push(...((await css2(`wght@${w}`)) ?? []));
faces = got.length ? got : null;
}
} else if (o.axis) faces = await css2(o.axis);
faces ??= await css2(null);
const out: FetchedFace[] = [];
for (const f of faces!) {
const res = await fetchFn(f.url, { headers: { 'user-agent': UA } });
if (!res.ok) throw new Error(`${family}: ${res.status} for ${f.url}`);
out.push({ weight: f.weight, bin: await res.arrayBuffer() });
}
return out;
}
/** Weights a role asks for that a local font file's declared weight does not serve (no declared weight: the file is
* declared for 100-900, so a static file draws its own weight for all of them). */
export function localWeightGap(declared: string | undefined, want: number[]): number[] {
return declared === undefined ? [] : want.filter(w => !serves(declared, w));
}
/** One title element as `shots render` records it: the weight asked for, the family, and the weight descriptors of
* that family's loaded faces. */
export interface WeightSample { el: string; family: string; want: number; served: string[] }
/** The browser drew a title at another weight than asked (no loaded face serves it): a warning. */
export function weightIssues(samples: WeightSample[] | undefined): { level: 'warn'; code: 'font-weight'; msg: string }[] {
return (samples ?? []).filter(s => s.served.length && !s.served.some(d => serves(d, s.want))).map(s => ({ level: 'warn' as const, code: 'font-weight' as const,
msg: `${s.el} asks for weight ${s.want} but ${s.family} is loaded only as ${s.served.join(', ')}, so the browser drew another weight (thin headline): run \`storebound shots fonts\` again (it now fetches the weights the templates use), pick a family with that weight, or give a local font file's real weight` }));
}