113 lines
7.4 KiB
TypeScript
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` }));
|
|
}
|