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
113
cli/shots/weights.ts
Normal file
113
cli/shots/weights.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// 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` }));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue