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
|
|
@ -19,21 +19,19 @@ import { shotsStyle } from '../lib/config';
|
|||
import type { CopyFile } from './lib';
|
||||
import { localByFamily, resolveShotFonts } from './local-fonts';
|
||||
import { fontCodepoints } from './cmap';
|
||||
import { type FaceJob, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
import { SHOTS_PAGES } from '../lib/embedded';
|
||||
import { fetchFaces, localWeightGap, serves, styleWeights } from './weights';
|
||||
import { type FaceJob, type FaceResult, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||
|
||||
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';
|
||||
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·';
|
||||
|
||||
export async function fetchSubset(family: string, chars: string, axis: string | null): Promise<ArrayBuffer> {
|
||||
const fam = encodeURIComponent(family) + (axis ? ':' + axis : '');
|
||||
let res = await fetch(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok && axis) return fetchSubset(family, chars, null);
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
||||
const src = /url\((https:[^)]+)\)/.exec(await res.text())?.[1];
|
||||
if (!src) throw new Error(`${family}: no font url`);
|
||||
res = await fetch(src, { headers: { 'user-agent': UA } });
|
||||
if (!res.ok) throw new Error(`${family}: ${res.status} for ${src}`);
|
||||
return res.arrayBuffer();
|
||||
return (await fetchFaces(family, chars, { axis }))[0]!.bin;
|
||||
}
|
||||
|
||||
/** Weights the style's templates (screenshot + feature graphic) ask for, per role. */
|
||||
export function weightsFor(style: 'game' | 'app'): { title: number[]; body: number[] } {
|
||||
return styleWeights(style === 'app' ? [SHOTS_PAGES['template-app.html']!, SHOTS_PAGES['feature-app.html']!] : [SHOTS_PAGES['template.html']!, SHOTS_PAGES['feature.html']!]);
|
||||
}
|
||||
|
||||
/** Per-language role texts for every language of the copy and the config. */
|
||||
|
|
@ -57,16 +55,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
mkdirSync(dir, { recursive: true });
|
||||
const style = shotsStyle(config);
|
||||
const sf = resolveShotFonts(shots, style, resolve, root);
|
||||
const { titleVariable } = sf;
|
||||
const W = weightsFor(style);
|
||||
const title = sf.title.family, body = sf.body.family;
|
||||
const local = localByFamily([sf.title, sf.body, ...sf.fallback]);
|
||||
const texts = langTexts(copy, config.languages.map(l => l.id), config.app.name);
|
||||
|
||||
const sizes: string[] = [];
|
||||
const manifest = await planFonts({
|
||||
langs: texts, title, body, titleAxis: titleVariable ? 'wght@100..900' : null, bodyAxis: 'wght@100..900',
|
||||
langs: texts, title, body, titleAxis: null, bodyAxis: null, titleWeights: W.title, bodyWeights: W.body,
|
||||
fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: sf.fallback.map(f => f.family), base: ASCII, local,
|
||||
}, async (job: FaceJob) => {
|
||||
}, async (job: FaceJob): Promise<FaceResult> => {
|
||||
if (job.local) { // read in place: the whole file's cmap
|
||||
const bin = new Uint8Array(readFileSync(join(root, job.local.path)));
|
||||
const cps = fontCodepoints(bin);
|
||||
|
|
@ -74,21 +72,39 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (!sizes.includes(line)) sizes.push(line);
|
||||
return cps;
|
||||
}
|
||||
let bin: Uint8Array;
|
||||
try { bin = new Uint8Array(await fetchSubset(job.family, job.chars, job.axis)); } catch (e) {
|
||||
let got: { weight: string; bin: ArrayBuffer }[];
|
||||
try { got = await fetchFaces(job.family, job.chars, job.weights ? { weights: job.weights } : { axis: job.axis }); } catch (e) {
|
||||
if (job.kind !== 'fallback') throw e;
|
||||
log('shots', `${job.family} has none of ${[...job.chars].join(' ')} (${(e as Error).message.split(' for ')[0]}), trying the next fallback`);
|
||||
return new Set<number>();
|
||||
}
|
||||
writeFileSync(join(dir, job.file), bin);
|
||||
const bin = new Uint8Array(got[0]!.bin);
|
||||
writeFileSync(join(dir, job.file), bin); // the cmap / coverage file (every weight has the same subset)
|
||||
const cps = fontCodepoints(bin);
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(bin.byteLength / 1024).toFixed(1)} KB, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
const weights = got.map(g => g.weight).join(', ');
|
||||
sizes.push(`${job.file.padEnd(28)} ${job.family.padEnd(22)} ${(got.reduce((t, g) => t + g.bin.byteLength, 0) / 1024).toFixed(1)} KB, weight ${weights}, ${[...job.chars].length} chars asked, ${cps.size} in the cmap`);
|
||||
if (job.weights) {
|
||||
const files = got.length === 1 ? [{ file: job.file, weight: got[0]!.weight }] : got.map(g => {
|
||||
const file = job.file.replace(/\.woff2$/, `-${g.weight.replace(/ /g, '-')}.woff2`);
|
||||
writeFileSync(join(dir, file), new Uint8Array(g.bin));
|
||||
return { file, weight: g.weight };
|
||||
});
|
||||
const miss = job.weights.filter(w => !files.some(f => serves(f.weight, w)));
|
||||
if (miss.length) warn('shots', `${job.family} (${job.role ?? job.kind}) has no weight ${miss.join(', ')} (got ${weights}): the templates ask for it, so those lines are drawn at the nearest weight. Pick a family with these weights for a heavier headline`);
|
||||
return { cps, files };
|
||||
}
|
||||
return cps;
|
||||
});
|
||||
writeFileSync(join(dir, 'fonts.css'), fontsCss(manifest));
|
||||
writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n');
|
||||
if (ctx.args.has('json')) return printJson(manifest);
|
||||
for (const s of sizes) log('shots', s);
|
||||
for (const [role, ref] of [['title', sf.title], ['body', sf.body]] as const) {
|
||||
if (!ref.local) continue;
|
||||
const gap = localWeightGap(ref.local.weight, W[role]);
|
||||
if (gap.length) warn('shots', `local font ${ref.local.path} is declared as weight ${ref.local.weight}, but the templates ask the ${role} for ${gap.join(', ')}: those lines are drawn at ${ref.local.weight} (never faked bolder). Add a file with that weight or accept it`);
|
||||
else if (ref.local.weight === undefined && role === 'title') log('shots', `local font ${ref.local.path} has no weight: declared for 100-900, so a static file draws its own weight for the ${role} weights ${W[role].join(', ')}. Give { weight } for a static file`);
|
||||
}
|
||||
for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`);
|
||||
let auto = 0;
|
||||
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue