Merge remote-tracking branch 'origin/main' into aso-plus
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
3fb3f2108a
16 changed files with 532 additions and 55 deletions
|
|
@ -26,6 +26,14 @@
|
||||||
- Desktop: an ASO card on the Growth tab (`aso.audit`): keyword field state, keywords ranked, coverage gaps, the
|
- Desktop: an ASO card on the Growth tab (`aso.audit`): keyword field state, keywords ranked, coverage gaps, the
|
||||||
rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions.
|
rank trend of the best three keywords (visx, rank 1 at the top), the rank table and the next 3 actions.
|
||||||
- `examples/demo-app` has listing files (`store/appstore.md`, `store/listing.md`) to try the ASO commands on.
|
- `examples/demo-app` has listing files (`store/appstore.md`, `store/listing.md`) to try the ASO commands on.
|
||||||
|
- Local font files in `shots.fonts`: `title`, `body` and `fallback[]` take, besides a Google Fonts family, a path
|
||||||
|
(relative to the config) or `{ family, file, weight?, style?, license? }` (TTF / OTF / WOFF / WOFF2, validated by
|
||||||
|
extension and signature; the family comes from the name table when not given). `shots fonts` checks the file's cmap
|
||||||
|
per language, tries the listed fallbacks in order before the automatic Noto ones (`pl title: ę ł ą Ł not in ER
|
||||||
|
Display -> Noto Sans Display`), errors when nothing covers a character, and records the file in fonts.json with its
|
||||||
|
path, sha256 and license. Render and `shots video` serve it in place at `/fonts/local/...` (never copied); the hash
|
||||||
|
in fonts.css invalidates the capture cache, and render refuses a font file changed since `shots fonts`. `shots check`
|
||||||
|
treats the local font and listed fallbacks as configured and warns when a local font has no license note.
|
||||||
- Idempotency: every store write is an upsert; running a command twice, or again after a crash or a lost answer,
|
- Idempotency: every store write is an upsert; running a command twice, or again after a crash or a lost answer,
|
||||||
leaves exactly one of each thing (table of every write path: `docs/idempotency.md`).
|
leaves exactly one of each thing (table of every write path: `docs/idempotency.md`).
|
||||||
- New `cli/lib/upsert.ts`: `findOrCreate` looks again with a fresh GET right before a create and asks a journal of
|
- New `cli/lib/upsert.ts`: `findOrCreate` looks again with a fresh GET right before a create and asks a journal of
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -434,7 +434,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
|
||||||
| command | kind | what |
|
| command | kind | what |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step |
|
| `shots capture [--source web\|ios-sim\|android-emu\|folder] [--concurrency N] [--only-missing \| --force]` | local | raw frames from your running app or game; web: parallel contexts, optional one boot per language (`capture.session`), skips unchanged frames, timing per step |
|
||||||
| `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...) and the glyph coverage check per language |
|
| `shots fonts` | network | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...), your own local font files, and the glyph coverage check per language |
|
||||||
| `shots render [--variant b \| --variants] [--concurrency N]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report |
|
| `shots render [--variant b \| --variants] [--concurrency N]` | local | every store size and language in the game or app style, feature graphic, contact sheet, overflow report |
|
||||||
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |
|
| `shots jpeg` | local | JPEG q92 copies of the Play renders (about 4x faster uploads) |
|
||||||
| `shots check [--variants] [--json]` | local, read-only | store rules: sizes, alpha, format, bytes, counts, overflow, fallback / missing glyphs, videos, folder frames |
|
| `shots check [--variants] [--json]` | local, read-only | store rules: sizes, alpha, format, bytes, counts, overflow, fallback / missing glyphs, videos, folder frames |
|
||||||
|
|
@ -730,6 +730,23 @@ languages, every store size, a review gallery and an app preview video. No store
|
||||||
Some display fonts lack Latin Extended: Lilita One has no ş ğ İ ą ę ń ś ź ż ć ă ș ț č ř ě ů ő ű or Vietnamese,
|
Some display fonts lack Latin Extended: Lilita One has no ş ğ İ ą ę ń ś ź ż ć ă ș ț č ř ě ů ő ű or Vietnamese,
|
||||||
so Turkish, Polish, Czech, Romanian, Hungarian or Vietnamese titles need another title font (Paytone One, Baloo 2,
|
so Turkish, Polish, Czech, Romanian, Hungarian or Vietnamese titles need another title font (Paytone One, Baloo 2,
|
||||||
Rowdies) or `fonts: { ..., fallback: ['Noto Sans Display'] }` to accept the mixed glyphs.
|
Rowdies) or `fonts: { ..., fallback: ['Noto Sans Display'] }` to accept the mixed glyphs.
|
||||||
|
|
||||||
|
**Your own brand font.** `shots.fonts.title` / `body` / `fallback[]` also take a local file (relative to the
|
||||||
|
config; TTF, OTF, WOFF or WOFF2, checked by extension and signature) or `{ family, file, weight?, style?, license? }`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
fonts: {
|
||||||
|
title: { family: 'ER Display', file: 'apps/game/public/assets/fonts/er-display.woff2', license: 'OFL-1.1' },
|
||||||
|
body: 'Montserrat',
|
||||||
|
fallback: ['Noto Sans Display'], // tried in order, before the automatic Noto fallbacks
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
`shots fonts` runs the same cmap check on the file (`pl title: ę ł ą Ł not in ER Display -> Noto Sans Display`),
|
||||||
|
prints its size, glyph count, sha256 and license, and records it in `fonts.json`. The file is never copied: render
|
||||||
|
(and `shots video` captions) serve it from where it is, and a changed file changes `fonts.css` (the capture cache
|
||||||
|
misses) and makes `shots render` ask for `shots fonts` again. `shots check` counts the local font and listed
|
||||||
|
fallbacks as configured, not as fallbacks, and warns when a local font has no `license` note.
|
||||||
3. **Render.** Every store size at exact pixels: iPhone 6.9" and 6.5", 13" iPad (if your app runs on iPad), Play
|
3. **Render.** Every store size at exact pixels: iPhone 6.9" and 6.5", 13" iPad (if your app runs on iPad), Play
|
||||||
phone, 7" and 10" tablets, the 1024 x 500 feature graphic. 24-bit RGB, no alpha (both stores reject it).
|
phone, 7" and 10" tablets, the 1024 x 500 feature graphic. 24-bit RGB, no alpha (both stores reject it).
|
||||||
4. **Check and review.** `shots check` applies the store rules locally (sources cited in `cli/shots/spec.ts`);
|
4. **Check and review.** `shots check` applies the store rules locally (sources cited in `cli/shots/spec.ts`);
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,12 @@ export interface ShotFrame { src: string; x?: number; dy?: number; scale?: numbe
|
||||||
export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string }
|
export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string }
|
||||||
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number }
|
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number }
|
||||||
|
|
||||||
|
/** A local font file for shots.fonts. license: 'OFL-1.1', 'proprietary' or a short note (shown by `shots fonts`;
|
||||||
|
* `shots check` warns without one). weight / style: the @font-face descriptors (default: all weights, normal). */
|
||||||
|
export interface LocalFontSpec { family?: string; file: string; weight?: number | string; style?: 'normal' | 'italic'; license?: string }
|
||||||
|
/** A Google Fonts family name, a local font path (relative to the config) or a LocalFontSpec. */
|
||||||
|
export type FontSpec = string | LocalFontSpec;
|
||||||
|
|
||||||
export interface ShotsConfig {
|
export interface ShotsConfig {
|
||||||
/** Look of the rendered screenshots: game (sunburst, sparkles, badges, bold outlined titles) or app (calm gradient,
|
/** Look of the rendered screenshots: game (sunburst, sparkles, badges, bold outlined titles) or app (calm gradient,
|
||||||
* device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles,
|
* device frame, headline + subline, no sparkles or badges). Default: app for the app / subscription-app profiles,
|
||||||
|
|
@ -113,11 +119,12 @@ export interface ShotsConfig {
|
||||||
out: string;
|
out: string;
|
||||||
/** Optional logo image for the hero scene and the feature graphic. */
|
/** Optional logo image for the hero scene and the feature graphic. */
|
||||||
logo?: string;
|
logo?: string;
|
||||||
/** Google Fonts families for titles / body text (downloaded and subset by `shots fonts`). Default: game Paytone One +
|
/** Fonts for titles / body text: a Google Fonts family (downloaded and subset by `shots fonts`), or your own font file
|
||||||
* Montserrat, app Inter (all cover Latin Extended and Vietnamese). `fallback`: Noto families you accept for
|
* (a path relative to the config, TTF / OTF / WOFF / WOFF2, or { family, file, weight?, style?, license? }).
|
||||||
* characters the title / body font lacks (e.g. ['Noto Sans Display']); without it a render that needs an automatic
|
* Default: game Paytone One + Montserrat, app Inter (all cover Latin Extended and Vietnamese). `fallback`: fonts you
|
||||||
* fallback is an ERROR in `shots check`. */
|
* accept for characters the title / body font lacks, tried in order before the automatic Noto fallbacks (e.g.
|
||||||
fonts?: { title?: string; body?: string; fallback?: string[] };
|
* ['Noto Sans Display'] or a local file); a render that needs an automatic fallback is an ERROR in `shots check`. */
|
||||||
|
fonts?: { title?: FontSpec; body?: FontSpec; fallback?: FontSpec[] };
|
||||||
scenes: ShotScene[];
|
scenes: ShotScene[];
|
||||||
feature?: { frame: string; palette?: string | Palette };
|
feature?: { frame: string; palette?: string | Palette };
|
||||||
/** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar).
|
/** Phone screen size of the raw frames in CSS px (default 440 x 902 @3x, iPhone 6.9" minus the 54 px status bar).
|
||||||
|
|
@ -847,6 +854,19 @@ export function validateConfig(c: unknown): string[] {
|
||||||
for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`);
|
for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`);
|
||||||
if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app');
|
if (s.style !== undefined && s.style !== 'game' && s.style !== 'app') e.push('shots.style: game | app');
|
||||||
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
|
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
|
||||||
|
const fontKeys: [string, unknown][] = [['title', s.fonts?.title], ['body', s.fonts?.body], ...(Array.isArray(s.fonts?.fallback) ? s.fonts!.fallback! : s.fonts?.fallback !== undefined ? [s.fonts.fallback] : []).map((f, i) => [`fallback[${i}]`, f] as [string, unknown])];
|
||||||
|
if (s.fonts?.fallback !== undefined && !Array.isArray(s.fonts.fallback)) e.push('shots.fonts.fallback: a list');
|
||||||
|
for (const [k, f] of fontKeys) {
|
||||||
|
if (f === undefined) continue;
|
||||||
|
if (typeof f === 'string') { if (!f.trim()) e.push(`shots.fonts.${k}: empty`); else if (/[\\/]|\.[a-z0-9]{2,5}$/i.test(f) && !/\.(ttf|otf|woff2?)$/i.test(f)) e.push(`shots.fonts.${k}: ${f}: a font file must be TTF, OTF, WOFF or WOFF2`); continue; }
|
||||||
|
const o = f as Partial<LocalFontSpec>;
|
||||||
|
if (!o || typeof o !== 'object' || typeof o.file !== 'string') { e.push(`shots.fonts.${k}: a Google Fonts family, a font file path or { family, file, weight?, style?, license? }`); continue; }
|
||||||
|
if (!/\.(ttf|otf|woff2?)$/i.test(o.file)) e.push(`shots.fonts.${k}.file: ${o.file}: TTF, OTF, WOFF or WOFF2`);
|
||||||
|
if (o.family !== undefined && (typeof o.family !== 'string' || !o.family.trim())) e.push(`shots.fonts.${k}.family: a name`);
|
||||||
|
if (o.style !== undefined && o.style !== 'normal' && o.style !== 'italic') e.push(`shots.fonts.${k}.style: normal | italic`);
|
||||||
|
if (o.weight !== undefined && !/^\d{3}( \d{3})?$/.test(String(o.weight))) e.push(`shots.fonts.${k}.weight: e.g. 400, 800 or "100 900"`);
|
||||||
|
if (o.license !== undefined && typeof o.license !== 'string') e.push(`shots.fonts.${k}.license: text ('OFL-1.1', 'proprietary', ...)`);
|
||||||
|
}
|
||||||
for (const sc of s.scenes ?? []) if (!sc.frames?.length) e.push(`shots.scenes[${sc.id}].frames: at least one frame`);
|
for (const sc of s.scenes ?? []) if (!sc.frames?.length) e.push(`shots.scenes[${sc.id}].frames: at least one frame`);
|
||||||
e.push(...validateCapture(s.capture));
|
e.push(...validateCapture(s.capture));
|
||||||
const sceneIds = new Set((s.scenes ?? []).map(x => x.id));
|
const sceneIds = new Set((s.scenes ?? []).map(x => x.id));
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,9 @@ export function normalizeKeywords(text: string, o: { name?: string; subtitle?: s
|
||||||
return { text: kept.join(','), dropped };
|
return { text: kept.join(','), dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; limit: number; problems: string[] }
|
/** `chars` is what the store counts: characters, except App Store keywords, which App Store Connect limits in UTF-8 bytes
|
||||||
|
* (`unit` says which). */
|
||||||
|
export interface FieldCheck { store: 'apple' | 'play'; field: string; chars: number; unit: 'chars' | 'bytes'; limit: number; problems: string[] }
|
||||||
|
|
||||||
/** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */
|
/** Limits and rules of one translated section. `source` = the source language's fields (identical text is flagged). */
|
||||||
export function checkFields(store: 'apple' | 'play', f: Record<string, string | undefined>, source: Record<string, string | undefined> = {}, status?: TStatus): FieldCheck[] {
|
export function checkFields(store: 'apple' | 'play', f: Record<string, string | undefined>, source: Record<string, string | undefined> = {}, status?: TStatus): FieldCheck[] {
|
||||||
|
|
@ -139,10 +141,11 @@ export function checkFields(store: 'apple' | 'play', f: Record<string, string |
|
||||||
const v = f[k];
|
const v = f[k];
|
||||||
if (v === undefined) continue;
|
if (v === undefined) continue;
|
||||||
const limit = (FIELD_LIMITS[store] as Record<string, number>)[k]!;
|
const limit = (FIELD_LIMITS[store] as Record<string, number>)[k]!;
|
||||||
const c: FieldCheck = { store, field: k, chars: len(v), limit, problems: [] };
|
const bytes = store === 'apple' && k === 'keywords';
|
||||||
if (len(v) > limit) c.problems.push(`${len(v)} > ${limit} characters`);
|
const n = bytes ? Buffer.byteLength(v, 'utf8') : len(v);
|
||||||
if (store === 'apple' && k === 'keywords') {
|
const c: FieldCheck = { store, field: k, chars: n, unit: bytes ? 'bytes' : 'chars', limit, problems: [] };
|
||||||
if (Buffer.byteLength(v) > limit) c.problems.push(`${Buffer.byteLength(v)} bytes > ${limit}`);
|
if (n > limit) c.problems.push(`${n} > ${limit} ${bytes ? 'bytes' : 'characters'}`);
|
||||||
|
if (bytes) {
|
||||||
if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas');
|
if (/,\s|\s,/.test(v)) c.problems.push('spaces around commas');
|
||||||
const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean);
|
const words = v.split(',').map(w => w.trim().toLocaleLowerCase()).filter(Boolean);
|
||||||
const dup = words.filter((w, i) => words.indexOf(w) !== i);
|
const dup = words.filter((w, i) => words.indexOf(w) !== i);
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ async function translate(ctx: Ctx): Promise<void> {
|
||||||
if (a.has('json')) printJson({ source: src.id, dryRun: dry, hook: !!hook, files: { apple: files.apple?.path ?? null, play: files.play?.path ?? null }, results: rows.map(r => ({ ...r, problems: problemsOf(r) })) });
|
if (a.has('json')) printJson({ source: src.id, dryRun: dry, hook: !!hook, files: { apple: files.apple?.path ?? null, play: files.play?.path ?? null }, results: rows.map(r => ({ ...r, problems: problemsOf(r) })) });
|
||||||
else {
|
else {
|
||||||
console.log(textTable(['language', 'store', 'section', 'fields', 'problems'], rows.map(r => [r.lang, r.store === 'apple' ? 'App Store' : 'Google Play', r.action,
|
console.log(textTable(['language', 'store', 'section', 'fields', 'problems'], rows.map(r => [r.lang, r.store === 'apple' ? 'App Store' : 'Google Play', r.action,
|
||||||
r.checks.map(c => `${c.field} ${c.chars}/${c.limit}`).join(', '), problemsOf(r).join('; ')])));
|
r.checks.map(c => `${c.field} ${c.chars}/${c.limit}${c.unit === 'bytes' ? ' B' : ''}`).join(', '), problemsOf(r).join('; ')])));
|
||||||
}
|
}
|
||||||
const json = a.has('json');
|
const json = a.has('json');
|
||||||
if (dry) { if (!json) done(TAG, 'dry run: listing files not written'); return; }
|
if (dry) { if (!json) done(TAG, 'dry run: listing files not written'); return; }
|
||||||
|
|
@ -167,7 +167,7 @@ function check(ctx: Ctx): void {
|
||||||
if (ctx.args.has('json')) printJson({ sections: r, ok: !bad.length, drafts: drafts.map(d => `${d.store}:${d.lang}`) });
|
if (ctx.args.has('json')) printJson({ sections: r, ok: !bad.length, drafts: drafts.map(d => `${d.store}:${d.lang}`) });
|
||||||
else {
|
else {
|
||||||
console.log(textTable(['store', 'language', 'status', 'fields', 'problems'], r.map(x => [x.store === 'apple' ? 'App Store' : 'Google Play', x.lang,
|
console.log(textTable(['store', 'language', 'status', 'fields', 'problems'], r.map(x => [x.store === 'apple' ? 'App Store' : 'Google Play', x.lang,
|
||||||
x.status === 'draft' ? 'DRAFT (review)' : x.status === 'translate' ? 'TO TRANSLATE' : 'reviewed', x.checks.map(c => `${c.field} ${c.chars}/${c.limit}`).join(', '),
|
x.status === 'draft' ? 'DRAFT (review)' : x.status === 'translate' ? 'TO TRANSLATE' : 'reviewed', x.checks.map(c => `${c.field} ${c.chars}/${c.limit}${c.unit === 'bytes' ? ' B' : ''}`).join(', '),
|
||||||
x.checks.flatMap(c => c.problems.map(p => `${c.field}: ${p}`)).join('; ')])));
|
x.checks.flatMap(c => c.problems.map(p => `${c.field}: ${p}`)).join('; ')])));
|
||||||
if (drafts.length) warn(TAG, `${drafts.length} machine-translated draft(s) not reviewed yet: ${drafts.map(d => `${d.store}:${d.lang}`).join(', ')} (storebound listing reviewed --lang <ids> after reading them)`);
|
if (drafts.length) warn(TAG, `${drafts.length} machine-translated draft(s) not reviewed yet: ${drafts.map(d => `${d.store}:${d.lang}`).join(', ')} (storebound listing reviewed --lang <ids> after reading them)`);
|
||||||
done(TAG, bad.length ? `${bad.length} section(s) with problems` : `${r.length} section(s) within the store limits`);
|
done(TAG, bad.length ? `${bad.length} section(s) with problems` : `${r.length} section(s) within the store limits`);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic.
|
// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic.
|
||||||
// Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set.
|
// Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set.
|
||||||
// Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame
|
// Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame
|
||||||
// the scenes use must exist with the right aspect. Exit 1 on any error (warnings do not fail).
|
// the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn.
|
||||||
|
// Exit 1 on any error (warnings do not fail).
|
||||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { ShipConfig } from '../lib/config';
|
import type { ShipConfig } from '../lib/config';
|
||||||
|
|
@ -17,6 +18,8 @@ import { defaultSizes, frameDims, SIZES } from './lib';
|
||||||
import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
|
import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
|
||||||
import { allIssues, requiredSets, type RendersScan, scanRenders, storeOf } from './report';
|
import { allIssues, requiredSets, type RendersScan, scanRenders, storeOf } from './report';
|
||||||
import { imageInfo } from './spec';
|
import { imageInfo } from './spec';
|
||||||
|
import { fontsDirOf, readManifest } from './fonts';
|
||||||
|
import { localFontIssues } from './local-fonts';
|
||||||
|
|
||||||
/** Frame files the scenes (and the feature graphic) use, per language and device class. */
|
/** Frame files the scenes (and the feature graphic) use, per language and device class. */
|
||||||
export function framesNeeded(config: ShipConfig, langs: string[]): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
export function framesNeeded(config: ShipConfig, langs: string[]): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
|
||||||
|
|
@ -58,9 +61,9 @@ export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' |
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, resolve } = ctx.loaded;
|
const { config, resolve, root } = ctx.loaded;
|
||||||
const scan = scanProject(ctx);
|
const scan = scanProject(ctx);
|
||||||
const issues = allIssues(scan);
|
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))];
|
||||||
const cap = config.shots?.capture;
|
const cap = config.shots?.capture;
|
||||||
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : [];
|
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : [];
|
||||||
if (ctx.args.has('json')) {
|
if (ctx.args.has('json')) {
|
||||||
|
|
|
||||||
|
|
@ -8,20 +8,25 @@ const tag = (d: DataView, o: number): string => String.fromCharCode(d.getUint8(o
|
||||||
const view = (b: Uint8Array): DataView => new DataView(b.buffer, b.byteOffset, b.byteLength);
|
const view = (b: Uint8Array): DataView => new DataView(b.buffer, b.byteOffset, b.byteLength);
|
||||||
|
|
||||||
/** The raw cmap table of a font file (any of the formats above). Throws on anything else. */
|
/** The raw cmap table of a font file (any of the formats above). Throws on anything else. */
|
||||||
export function cmapTable(file: Uint8Array): Uint8Array {
|
export const cmapTable = (file: Uint8Array): Uint8Array => fontTable(file, 'cmap');
|
||||||
|
|
||||||
|
/** One raw (untransformed) table of a font file, e.g. 'cmap' or 'name'. Throws when the file is not a font or the
|
||||||
|
* table is missing. */
|
||||||
|
export function fontTable(file: Uint8Array, want: string): Uint8Array {
|
||||||
const d = view(file);
|
const d = view(file);
|
||||||
|
if (file.byteLength < 12) throw new Error('not a font file (too short)');
|
||||||
const sig = tag(d, 0);
|
const sig = tag(d, 0);
|
||||||
if (sig === 'wOF2') return woff2Cmap(file);
|
if (sig === 'wOF2') return woff2Table(file, want);
|
||||||
if (sig === 'wOFF') {
|
if (sig === 'wOFF') {
|
||||||
const n = d.getUint16(12);
|
const n = d.getUint16(12);
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const o = 44 + i * 20;
|
const o = 44 + i * 20;
|
||||||
if (tag(d, o) !== 'cmap') continue;
|
if (tag(d, o) !== want) continue;
|
||||||
const off = d.getUint32(o + 4), comp = d.getUint32(o + 8), orig = d.getUint32(o + 12);
|
const off = d.getUint32(o + 4), comp = d.getUint32(o + 8), orig = d.getUint32(o + 12);
|
||||||
const raw = file.subarray(off, off + comp);
|
const raw = file.subarray(off, off + comp);
|
||||||
return comp < orig ? new Uint8Array(inflateSync(raw)) : raw;
|
return comp < orig ? new Uint8Array(inflateSync(raw)) : raw;
|
||||||
}
|
}
|
||||||
throw new Error('woff: no cmap table');
|
throw new Error(`woff: no ${want} table`);
|
||||||
}
|
}
|
||||||
let base = 0;
|
let base = 0;
|
||||||
if (sig === 'ttcf') base = d.getUint32(12);
|
if (sig === 'ttcf') base = d.getUint32(12);
|
||||||
|
|
@ -29,9 +34,9 @@ export function cmapTable(file: Uint8Array): Uint8Array {
|
||||||
const n = d.getUint16(base + 4);
|
const n = d.getUint16(base + 4);
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const o = base + 12 + i * 16;
|
const o = base + 12 + i * 16;
|
||||||
if (tag(d, o) === 'cmap') { const off = d.getUint32(o + 8); return file.subarray(off, off + d.getUint32(o + 12)); }
|
if (tag(d, o) === want) { const off = d.getUint32(o + 8); return file.subarray(off, off + d.getUint32(o + 12)); }
|
||||||
}
|
}
|
||||||
throw new Error('sfnt: no cmap table');
|
throw new Error(`sfnt: no ${want} table`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WOFF2_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG',
|
const WOFF2_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG',
|
||||||
|
|
@ -39,7 +44,7 @@ const WOFF2_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'pos
|
||||||
'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar',
|
'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar',
|
||||||
'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop', 'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill'];
|
'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop', 'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill'];
|
||||||
|
|
||||||
function woff2Cmap(file: Uint8Array): Uint8Array {
|
function woff2Table(file: Uint8Array, want: string): Uint8Array {
|
||||||
const d = view(file);
|
const d = view(file);
|
||||||
const n = d.getUint16(12), compLen = d.getUint32(20);
|
const n = d.getUint16(12), compLen = d.getUint32(20);
|
||||||
let p = 48;
|
let p = 48;
|
||||||
|
|
@ -56,10 +61,10 @@ function woff2Cmap(file: Uint8Array): Uint8Array {
|
||||||
const version = (flags >> 6) & 3;
|
const version = (flags >> 6) & 3;
|
||||||
const transformed = t === 'glyf' || t === 'loca' ? version === 0 : version !== 0;
|
const transformed = t === 'glyf' || t === 'loca' ? version === 0 : version !== 0;
|
||||||
const len = transformed ? base128() : orig;
|
const len = transformed ? base128() : orig;
|
||||||
if (t === 'cmap') found = [off, orig];
|
if (t === want) found = [off, orig];
|
||||||
off += len;
|
off += len;
|
||||||
}
|
}
|
||||||
if (!found) throw new Error('woff2: no cmap table');
|
if (!found) throw new Error(`woff2: no ${want} table`);
|
||||||
if (tag(d, 4) === 'ttcf') { // collection directory: skip it (version, numFonts 255UInt16, per font numTables + indices)
|
if (tag(d, 4) === 'ttcf') { // collection directory: skip it (version, numFonts 255UInt16, per font numTables + indices)
|
||||||
p += 4;
|
p += 4;
|
||||||
const u16 = (): number => { const c = d.getUint8(p++); return c === 253 ? (p += 2, d.getUint16(p - 2)) : c === 255 ? 253 + d.getUint8(p++) : c === 254 ? 506 + d.getUint8(p++) : c; };
|
const u16 = (): number => { const c = d.getUint8(p++); return c === 253 ? (p += 2, d.getUint16(p - 2)) : c === 255 ? 253 + d.getUint8(p++) : c === 254 ? 506 + d.getUint8(p++) : c; };
|
||||||
|
|
@ -110,6 +115,28 @@ function readSub(d: DataView, o: number, out: Set<number>): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Family name from the name table (typographic family 16, else family 1; Windows Unicode, else Mac Roman), or null. */
|
||||||
|
export function fontFamilyName(file: Uint8Array): string | null {
|
||||||
|
let t: Uint8Array;
|
||||||
|
try { t = fontTable(file, 'name'); } catch { return null; }
|
||||||
|
const d = view(t);
|
||||||
|
const count = d.getUint16(2), strings = d.getUint16(4);
|
||||||
|
const recs: { pid: number; lang: number; id: number; text: string }[] = [];
|
||||||
|
for (let i = 0; i < count && 6 + i * 12 + 12 <= t.byteLength; i++) {
|
||||||
|
const o = 6 + i * 12;
|
||||||
|
const pid = d.getUint16(o), lang = d.getUint16(o + 4), id = d.getUint16(o + 6), len = d.getUint16(o + 8), off = strings + d.getUint16(o + 10);
|
||||||
|
if ((id !== 1 && id !== 16) || off + len > t.byteLength) continue;
|
||||||
|
const raw = t.subarray(off, off + len);
|
||||||
|
let text = '';
|
||||||
|
if (pid === 3 || pid === 0) for (let k = 0; k + 1 < raw.length; k += 2) text += String.fromCharCode((raw[k]! << 8) | raw[k + 1]!);
|
||||||
|
else if (pid === 1) text = String.fromCharCode(...raw);
|
||||||
|
else continue;
|
||||||
|
recs.push({ pid, lang, id, text: text.trim() });
|
||||||
|
}
|
||||||
|
const score = (r: { pid: number; lang: number; id: number }): number => (r.id === 16 ? 10 : 0) + (r.pid === 3 ? 4 : r.pid === 0 ? 3 : 1) + (r.lang === 0x409 || r.lang === 0 ? 1 : 0);
|
||||||
|
return recs.filter(r => r.text).sort((a, b) => score(b) - score(a))[0]?.text ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Code points a font file covers. */
|
/** Code points a font file covers. */
|
||||||
export const fontCodepoints = (file: Uint8Array): Set<number> => cmapCodepoints(cmapTable(file));
|
export const fontCodepoints = (file: Uint8Array): Set<number> => cmapCodepoints(cmapTable(file));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,16 @@
|
||||||
// character no fallback covers fails with the language and the characters.
|
// character no fallback covers fails with the language and the characters.
|
||||||
// storebound shots fonts [--json] (re-run after editing the copy or the fonts)
|
// storebound shots fonts [--json] (re-run after editing the copy or the fonts)
|
||||||
// Out: <project>/.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render).
|
// Out: <project>/.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render).
|
||||||
// All of these fonts are SIL Open Font License 1.1.
|
// The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap
|
||||||
|
// check, is read in place (never copied) and recorded with its path, sha256 and license note.
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Ctx } from '../lib/ctx';
|
import type { Ctx } from '../lib/ctx';
|
||||||
import { need } from '../lib/ctx';
|
import { need } from '../lib/ctx';
|
||||||
import { done, log, printJson, warn } from '../lib/log';
|
import { done, log, printJson, warn } from '../lib/log';
|
||||||
import { shotsStyle } from '../lib/config';
|
import { shotsStyle } from '../lib/config';
|
||||||
import { type CopyFile, shotFonts } from './lib';
|
import type { CopyFile } from './lib';
|
||||||
|
import { localByFamily, resolveShotFonts } from './local-fonts';
|
||||||
import { fontCodepoints } from './cmap';
|
import { fontCodepoints } from './cmap';
|
||||||
import { type FaceJob, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
import { type FaceJob, type FontManifest, fontsCss, planFonts, roleTexts, type RoleTexts } from './glyphs';
|
||||||
|
|
||||||
|
|
@ -54,14 +56,24 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const dir = fontsDirOf(root);
|
const dir = fontsDirOf(root);
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
const style = shotsStyle(config);
|
const style = shotsStyle(config);
|
||||||
const { title, body, titleVariable } = shotFonts(shots, style);
|
const sf = resolveShotFonts(shots, style, resolve, root);
|
||||||
|
const { titleVariable } = sf;
|
||||||
|
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 texts = langTexts(copy, config.languages.map(l => l.id), config.app.name);
|
||||||
|
|
||||||
const sizes: string[] = [];
|
const sizes: string[] = [];
|
||||||
const manifest = await planFonts({
|
const manifest = await planFonts({
|
||||||
langs: texts, title, body, titleAxis: titleVariable ? 'wght@100..900' : null, bodyAxis: 'wght@100..900',
|
langs: texts, title, body, titleAxis: titleVariable ? 'wght@100..900' : null, bodyAxis: 'wght@100..900',
|
||||||
fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: shots.fonts?.fallback, base: ASCII,
|
fallbackTitleAxis: style === 'app' ? 'wght@100..900' : 'wght@800', chosen: sf.fallback.map(f => f.family), base: ASCII, local,
|
||||||
}, async (job: FaceJob) => {
|
}, async (job: FaceJob) => {
|
||||||
|
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);
|
||||||
|
const line = `${job.local.path} ${job.family}, ${(bin.byteLength / 1024).toFixed(1)} KB, ${cps.size} in the cmap, sha256 ${job.local.hash}, license: ${job.local.license ?? 'none noted (add license to the shots.fonts entry)'}`;
|
||||||
|
if (!sizes.includes(line)) sizes.push(line);
|
||||||
|
return cps;
|
||||||
|
}
|
||||||
let bin: Uint8Array;
|
let bin: Uint8Array;
|
||||||
try { bin = new Uint8Array(await fetchSubset(job.family, job.chars, job.axis)); } catch (e) {
|
try { bin = new Uint8Array(await fetchSubset(job.family, job.chars, job.axis)); } catch (e) {
|
||||||
if (job.kind !== 'fallback') throw e;
|
if (job.kind !== 'fallback') throw e;
|
||||||
|
|
@ -77,12 +89,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n');
|
writeFileSync(join(dir, 'fonts.json'), JSON.stringify(manifest, null, 1) + '\n');
|
||||||
if (ctx.args.has('json')) return printJson(manifest);
|
if (ctx.args.has('json')) return printJson(manifest);
|
||||||
for (const s of sizes) log('shots', s);
|
for (const s of sizes) log('shots', s);
|
||||||
|
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;
|
let auto = 0;
|
||||||
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
|
for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) {
|
||||||
const ok = manifest.faces[f.face]?.chosen;
|
const ok = manifest.faces[f.face]?.chosen;
|
||||||
if (!ok) auto++;
|
if (!ok) auto++;
|
||||||
const base = f.role === 'title' ? title : body;
|
const base = f.role === 'title' ? title : body;
|
||||||
(ok ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} not in ${base} -> ${f.family}${ok ? ' (shots.fonts.fallback)' : ' (automatic fallback)'}`);
|
(ok ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} not in ${base} -> ${f.family}${ok ? ' (shots.fonts.fallback)' : ' (automatic fallback)'}${manifest.faces[f.face]?.local ? ' (local file)' : ''}`);
|
||||||
}
|
}
|
||||||
if (auto) warn('shots', `${auto} automatic fallbacks: renders that use them fail \`shots check\`. Pick title / body fonts that cover them${style === 'game' ? ' (titles: e.g. Paytone One, Baloo 2, Rowdies)' : ''} or accept the fallback with shots.fonts.fallback: ['<family>']`);
|
if (auto) warn('shots', `${auto} automatic fallbacks: renders that use them fail \`shots check\`. Pick title / body fonts that cover them${style === 'game' ? ' (titles: e.g. Paytone One, Baloo 2, Rowdies)' : ''} or accept the fallback with shots.fonts.fallback: ['<family>']`);
|
||||||
done('shots', 'fonts done');
|
done('shots', 'fonts done');
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import type { SceneCopy } from './lib';
|
import type { SceneCopy } from './lib';
|
||||||
import { scriptFont } from './lib';
|
import { scriptFont } from './lib';
|
||||||
import { needsGlyph } from './cmap';
|
import { needsGlyph } from './cmap';
|
||||||
|
import { type LocalFontInfo, localFile, slug } from './local-fonts';
|
||||||
|
|
||||||
export type Role = 'title' | 'body';
|
export type Role = 'title' | 'body';
|
||||||
export interface RoleTexts { title: string; body: string }
|
export interface RoleTexts { title: string; body: string }
|
||||||
|
|
@ -61,16 +62,16 @@ export function fallbackFamilies(ch: string, lang: string, role: Role): string[]
|
||||||
export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans TC',
|
export const FALLBACK_ORDER = ['Noto Sans Display', 'Noto Sans', 'Noto Sans KR', 'Noto Sans JP', 'Noto Sans SC', 'Noto Sans TC',
|
||||||
...NOTO_SCRIPTS.map(s => `Noto Sans ${s}`), ...SYMBOLS];
|
...NOTO_SCRIPTS.map(s => `Noto Sans ${s}`), ...SYMBOLS];
|
||||||
|
|
||||||
const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
||||||
|
|
||||||
export type FaceKind = 'title' | 'body' | 'script' | 'fallback';
|
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",
|
/** 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. */
|
* "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 }
|
export interface FaceJob { face: string; family: string; file: string; kind: FaceKind; role?: Role; axis: string | null; chars: string; local?: LocalFontInfo }
|
||||||
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
|
export interface LangFonts { title: string[]; body: string[]; fallbacks: { role: Role; face: string; family: string; chars: string }[] }
|
||||||
export interface FontManifest {
|
export interface FontManifest {
|
||||||
v: 1;
|
v: 1;
|
||||||
faces: Record<string, { family: string; file: string; kind: FaceKind; role?: Role; chosen: boolean }>;
|
/** 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 }>;
|
||||||
langs: Record<string, LangFonts>;
|
langs: Record<string, LangFonts>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,8 +82,11 @@ export interface PlanInput {
|
||||||
titleAxis: string | null; bodyAxis: string | null;
|
titleAxis: string | null; bodyAxis: string | null;
|
||||||
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
|
/** Weight axis for automatic title fallbacks (game titles are heavy display faces: "wght@800"). */
|
||||||
fallbackTitleAxis: string | null;
|
fallbackTitleAxis: string | null;
|
||||||
/** shots.fonts.fallback: families the owner accepts as fallbacks. */
|
/** shots.fonts.fallback: families the owner accepts as fallbacks, in order. Families that are not one of the automatic
|
||||||
|
* Noto fallbacks (another Google family, a local file) are tried first, for every character the stack lacks. */
|
||||||
chosen?: string[];
|
chosen?: string[];
|
||||||
|
/** Local font files by family (title, body or fallback). */
|
||||||
|
local?: Record<string, LocalFontInfo>;
|
||||||
/** Extra characters for the title / body downloads (ASCII, so later copy edits rarely need a new download). */
|
/** Extra characters for the title / body downloads (ASCII, so later copy edits rarely need a new download). */
|
||||||
base?: string;
|
base?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +104,11 @@ export class GlyphError extends Error {
|
||||||
* and returns the code points its cmap covers. Throws GlyphError when no fallback covers a character. */
|
* 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, get: (job: FaceJob) => Promise<Set<number>>): Promise<FontManifest> {
|
||||||
const chosen = new Set((input.chosen ?? []).map(s => s.toLowerCase()));
|
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()));
|
||||||
|
const order = [...extra, ...FALLBACK_ORDER];
|
||||||
|
const fileOf = (family: string, google: string): string => local[family] ? localFile(family, local[family]!) : google;
|
||||||
|
const loc = (family: string): { local?: LocalFontInfo } => (local[family] ? { local: local[family] } : {});
|
||||||
const base = input.base ?? '';
|
const base = input.base ?? '';
|
||||||
const scripts = new Map<string, { family: string; chars: Set<string> }>();
|
const scripts = new Map<string, { family: string; chars: Set<string> }>();
|
||||||
const langScript = new Map<string, string | null>();
|
const langScript = new Map<string, string | null>();
|
||||||
|
|
@ -121,13 +130,13 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
|
||||||
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
|
const add = async (job: FaceJob, isChosen: boolean): Promise<void> => {
|
||||||
cps.set(job.face, await get(job));
|
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
|
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 };
|
faces[job.face] = { family: job.family, file: job.file, kind: job.kind, ...(job.role ? { role: job.role } : {}), chosen: isChosen, ...(job.local ? { local: job.local } : {}) };
|
||||||
};
|
};
|
||||||
await add({ face: input.title, family: input.title, file: 'title.woff2', kind: 'title', role: 'title', axis: input.titleAxis, chars: sorted(roleChars.title) }, 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) }, true);
|
||||||
if (input.body !== input.title) await add({ face: input.body, family: input.body, file: 'body.woff2', kind: 'body', role: 'body', axis: input.bodyAxis, chars: sorted(roleChars.body) }, 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);
|
||||||
else { // same family: one download (title axis) with both roles' characters
|
else { // same family: one download (title axis) with both roles' characters
|
||||||
const t = faces[input.title]!;
|
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]) }));
|
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) }));
|
||||||
}
|
}
|
||||||
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);
|
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);
|
||||||
|
|
||||||
|
|
@ -139,17 +148,17 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
|
||||||
langs[lang] = { title: [...(x ? [x] : []), input.title], body: [...(x ? [x] : []), input.body], fallbacks: [] };
|
langs[lang] = { title: [...(x ? [x] : []), input.title], body: [...(x ? [x] : []), input.body], fallbacks: [] };
|
||||||
for (const role of ['title', 'body'] as const) {
|
for (const role of ['title', 'body'] as const) {
|
||||||
const stack = langs[lang]![role].map(f => cps.get(f)!);
|
const stack = langs[lang]![role].map(f => cps.get(f)!);
|
||||||
for (const ch of uncoveredChars(t[role], stack)) pending.push({ lang, role, ch, tries: fallbackFamilies(ch, lang, role) });
|
for (const ch of uncoveredChars(t[role], stack)) pending.push({ lang, role, ch, tries: [...extra, ...fallbackFamilies(ch, lang, role)] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gaps = new Map<string, { lang: string; role: Role; chars: string[]; tried: string[] }>();
|
const gaps = new Map<string, { lang: string; role: Role; chars: string[]; tried: string[] }>();
|
||||||
const hits = new Map<string, Map<string, Set<string>>>(); // lang|role -> face -> chars
|
const hits = new Map<string, Map<string, Set<string>>>(); // lang|role -> face -> chars
|
||||||
for (const role of ['title', 'body'] as const) for (const family of FALLBACK_ORDER) {
|
for (const role of ['title', 'body'] as const) for (const family of order) {
|
||||||
const want = pending.filter(p => p.role === role && p.tries.includes(family));
|
const want = pending.filter(p => p.role === role && p.tries.includes(family));
|
||||||
if (!want.length) continue;
|
if (!want.length) continue;
|
||||||
const face = `Store fb ${slug(family)} ${role}`;
|
const face = `Store fb ${slug(family)} ${role}`;
|
||||||
const axis = family === 'Noto Color Emoji' ? null : role === 'title' ? input.fallbackTitleAxis : 'wght@100..900';
|
const axis = family === 'Noto Color Emoji' ? null : role === 'title' ? input.fallbackTitleAxis : 'wght@100..900';
|
||||||
await add({ face, family, file: `fb-${slug(family)}-${role}.woff2`, kind: 'fallback', role, axis, chars: sorted(want.map(p => p.ch)) }, chosen.has(family.toLowerCase()));
|
await add({ face, family, file: fileOf(family, `fb-${slug(family)}-${role}.woff2`), kind: 'fallback', role, axis, chars: sorted(want.map(p => p.ch)), ...loc(family) }, chosen.has(family.toLowerCase()));
|
||||||
const got = cps.get(face)!;
|
const got = cps.get(face)!;
|
||||||
for (const p of want) {
|
for (const p of want) {
|
||||||
p.tries = p.tries.filter(f => f !== family);
|
p.tries = p.tries.filter(f => f !== family);
|
||||||
|
|
@ -165,14 +174,14 @@ export async function planFonts(input: PlanInput, get: (job: FaceJob) => Promise
|
||||||
for (const p of pending) {
|
for (const p of pending) {
|
||||||
if ((p as { done?: boolean }).done) continue;
|
if ((p as { done?: boolean }).done) continue;
|
||||||
const k = `${p.lang}|${p.role}`;
|
const k = `${p.lang}|${p.role}`;
|
||||||
const g = gaps.get(k) ?? { lang: p.lang, role: p.role, chars: [], tried: [...langs[p.lang]![p.role].map(f => faces[f]?.family ?? f), ...fallbackFamilies(p.ch, p.lang, p.role)] };
|
const g = gaps.get(k) ?? { lang: p.lang, role: p.role, chars: [], tried: [...langs[p.lang]![p.role].map(f => faces[f]?.family ?? f), ...extra, ...fallbackFamilies(p.ch, p.lang, p.role)] };
|
||||||
g.chars.push(p.ch);
|
g.chars.push(p.ch);
|
||||||
gaps.set(k, g);
|
gaps.set(k, g);
|
||||||
}
|
}
|
||||||
if (gaps.size) throw new GlyphError([...gaps.values()]);
|
if (gaps.size) throw new GlyphError([...gaps.values()]);
|
||||||
for (const [k, m] of hits) {
|
for (const [k, m] of hits) {
|
||||||
const [lang, role] = k.split('|') as [string, Role];
|
const [lang, role] = k.split('|') as [string, Role];
|
||||||
for (const face of [...m.keys()].sort((a, b) => FALLBACK_ORDER.indexOf(faces[a]!.family) - FALLBACK_ORDER.indexOf(faces[b]!.family))) {
|
for (const face of [...m.keys()].sort((a, b) => order.indexOf(faces[a]!.family) - order.indexOf(faces[b]!.family))) {
|
||||||
langs[lang]![role].push(face);
|
langs[lang]![role].push(face);
|
||||||
langs[lang]!.fallbacks.push({ role, face, family: faces[face]!.family, chars: [...m.get(face)!].join('') });
|
langs[lang]!.fallbacks.push({ role, face, family: faces[face]!.family, chars: [...m.get(face)!].join('') });
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +196,12 @@ export function fontsCss(m: FontManifest): string {
|
||||||
for (const [face, f] of Object.entries(m.faces)) {
|
for (const [face, f] of Object.entries(m.faces)) {
|
||||||
if (seen.has(face)) continue;
|
if (seen.has(face)) continue;
|
||||||
seen.add(face);
|
seen.add(face);
|
||||||
|
if (f.local) { // the project's own font file, served in place; ?v= changes with its content
|
||||||
|
const l = f.local;
|
||||||
|
css += `/* ${f.family}: local file ${l.path} (${l.license ? 'license: ' + l.license.replace(/\*\//g, '') : 'no license note'}) */\n`;
|
||||||
|
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;
|
||||||
|
}
|
||||||
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
|
css += `@font-face { font-family: '${face}'; font-weight: 100 900; font-synthesis: none; src: url(${f.file}) format('woff2'); }\n`;
|
||||||
}
|
}
|
||||||
return css;
|
return css;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
||||||
|
import { specFamily } from './local-fonts';
|
||||||
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
||||||
|
|
||||||
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
|
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
|
||||||
|
|
@ -54,7 +55,8 @@ export const palette = (p: string | Palette | undefined, style: 'game' | 'app' =
|
||||||
* Cyrillic. The app title is a variable font (bold headline weights). Per-script stack: glyphs.ts. */
|
* Cyrillic. The app title is a variable font (bold headline weights). Per-script stack: glyphs.ts. */
|
||||||
export function shotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app'): { title: string; body: string; titleVariable: boolean } {
|
export function shotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app'): { title: string; body: string; titleVariable: boolean } {
|
||||||
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Paytone One', body: 'Montserrat' };
|
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Paytone One', body: 'Montserrat' };
|
||||||
return { title: shots?.fonts?.title ?? d.title, body: shots?.fonts?.body ?? d.body, titleVariable: style === 'app' };
|
// local files: the family given in the config, else the file name (resolveShotFonts reads the real name table)
|
||||||
|
return { title: specFamily(shots?.fonts?.title ?? d.title), body: specFamily(shots?.fonts?.body ?? d.body), titleVariable: style === 'app' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] }
|
export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] }
|
||||||
|
|
|
||||||
130
cli/shots/local-fonts.ts
Normal file
130
cli/shots/local-fonts.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// A game's or app's own brand font as a local file, next to the Google Fonts families:
|
||||||
|
// shots.fonts.title / body / fallback[] = 'Paytone One' (Google Fonts family)
|
||||||
|
// | 'assets/fonts/er-display.woff2' (local file, family from its name table)
|
||||||
|
// | { family, file, weight?, style?, license? }
|
||||||
|
// Paths are relative to the config. TTF / OTF / WOFF / WOFF2 (checked by extension and signature). The file is never
|
||||||
|
// copied: `shots fonts` reads its cmap and records it in fonts.json (path relative to the project, sha256, license),
|
||||||
|
// fonts.css points at /fonts/local/<slug>.<ext>?v=<hash> and the render / video servers serve that route from the
|
||||||
|
// original file. A changed file changes fonts.css (capture cache) and fails render until `shots fonts` runs again.
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { basename, join, relative } from 'node:path';
|
||||||
|
import { fontFamilyName } from './cmap';
|
||||||
|
import type { FontManifest } from './glyphs';
|
||||||
|
|
||||||
|
import type { FontSpec, LocalFontSpec, ShotsConfig } from '../lib/config';
|
||||||
|
export type { FontSpec, LocalFontSpec };
|
||||||
|
export type FontFormat = 'truetype' | 'opentype' | 'woff' | 'woff2';
|
||||||
|
|
||||||
|
/** A local font as fonts.json records it. path: relative to the project root. */
|
||||||
|
export interface LocalFontInfo { path: string; format: FontFormat; hash: string; bytes: number; weight?: string; style?: string; license?: string }
|
||||||
|
/** A resolved font of shots.fonts: the family name the templates use, plus the file for a local font. */
|
||||||
|
export interface FontRef { family: string; local?: LocalFontInfo & { abs: string } }
|
||||||
|
|
||||||
|
const EXT: Record<string, FontFormat> = { ttf: 'truetype', otf: 'opentype', woff: 'woff', woff2: 'woff2' };
|
||||||
|
|
||||||
|
/** A string spec is a local file when it ends in a font extension or contains a path separator. */
|
||||||
|
export const isLocalSpec = (s: FontSpec): boolean => typeof s !== 'string' || /\.(ttf|otf|woff2?)$/i.test(s) || /[\\/]/.test(s);
|
||||||
|
|
||||||
|
/** Format from the file signature (null = not a font file storebound reads). */
|
||||||
|
export function sniffFormat(bin: Uint8Array): FontFormat | null {
|
||||||
|
if (bin.byteLength < 12) return null;
|
||||||
|
const sig = String.fromCharCode(bin[0]!, bin[1]!, bin[2]!, bin[3]!);
|
||||||
|
if (sig === 'wOF2') return 'woff2';
|
||||||
|
if (sig === 'wOFF') return 'woff';
|
||||||
|
if (sig === 'OTTO') return 'opentype';
|
||||||
|
if (sig === 'true' || (bin[0] === 0 && bin[1] === 1 && bin[2] === 0 && bin[3] === 0)) return 'truetype';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileHash = (bin: Uint8Array): string => createHash('sha256').update(bin).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
/** Resolves and validates one local font spec (file exists, TTF / OTF / WOFF / WOFF2). Throws with the config key. */
|
||||||
|
export function loadLocalFont(spec: FontSpec, key: string, resolve: (p: string) => string, root: string): FontRef {
|
||||||
|
const o: LocalFontSpec = typeof spec === 'string' ? { file: spec } : spec;
|
||||||
|
if (!o.file || typeof o.file !== 'string') throw new Error(`${key}: { family, file } needs a file path (relative to the config)`);
|
||||||
|
const ext = o.file.split('.').pop()!.toLowerCase();
|
||||||
|
if (!EXT[ext]) throw new Error(`${key}: ${o.file}: not a font file (TTF, OTF, WOFF or WOFF2)`);
|
||||||
|
const abs = resolve(o.file);
|
||||||
|
if (!existsSync(abs) || !statSync(abs).isFile()) throw new Error(`${key}: ${o.file} not found (paths are relative to the config: ${abs})`);
|
||||||
|
const bin = new Uint8Array(readFileSync(abs));
|
||||||
|
const format = sniffFormat(bin);
|
||||||
|
if (!format) throw new Error(`${key}: ${o.file} is not a TTF / OTF / WOFF / WOFF2 font (unknown signature)`);
|
||||||
|
const sfnt = (f: FontFormat): boolean => f === 'truetype' || f === 'opentype';
|
||||||
|
if (format !== EXT[ext] && !(sfnt(format) && sfnt(EXT[ext]!))) throw new Error(`${key}: ${o.file} is ${format.toUpperCase()} inside, but named .${ext}: rename it`);
|
||||||
|
const family = (o.family ?? fontFamilyName(bin) ?? basename(o.file).replace(/\.[^.]+$/, '')).trim();
|
||||||
|
if (!family) throw new Error(`${key}: ${o.file}: no family name (give { family, file })`);
|
||||||
|
return { family, local: { abs, path: relative(root, abs), format, hash: fileHash(bin), bytes: bin.byteLength,
|
||||||
|
...(o.weight !== undefined ? { weight: String(o.weight) } : {}), ...(o.style ? { style: o.style } : {}), ...(o.license ? { license: o.license } : {}) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** shots.fonts resolved: Google families as names, local files loaded and validated (throws on a bad file). */
|
||||||
|
export function resolveShotFonts(shots: Pick<ShotsConfig, 'fonts'> | undefined, style: 'game' | 'app', resolve: (p: string) => string, root: string): { title: FontRef; body: FontRef; fallback: FontRef[]; titleVariable: boolean } {
|
||||||
|
const d = style === 'app' ? { title: 'Inter', body: 'Inter' } : { title: 'Paytone One', body: 'Montserrat' };
|
||||||
|
const ref = (s: FontSpec, key: string): FontRef => isLocalSpec(s) ? loadLocalFont(s, key, resolve, root) : { family: s as string };
|
||||||
|
const fallback = (shots?.fonts?.fallback ?? []).map((f, i) => ref(f, `shots.fonts.fallback[${i}]`));
|
||||||
|
return { title: ref(shots?.fonts?.title ?? d.title, 'shots.fonts.title'), body: ref(shots?.fonts?.body ?? d.body, 'shots.fonts.body'), fallback, titleVariable: style === 'app' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local files by family for planFonts (one family, one file). */
|
||||||
|
export function localByFamily(refs: FontRef[]): Record<string, LocalFontInfo> {
|
||||||
|
const out: Record<string, LocalFontInfo> = {};
|
||||||
|
for (const r of refs) if (r.local) {
|
||||||
|
const { abs: _abs, ...info } = r.local;
|
||||||
|
if (out[r.family] && out[r.family]!.hash !== info.hash) throw new Error(`shots.fonts: two different files for the family "${r.family}" (give each a distinct family)`);
|
||||||
|
out[r.family] = info;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Family name of a spec without reading files (Google family, { family }, else the file name). For callers that only
|
||||||
|
* need a name and never a local file's name table. */
|
||||||
|
export const specFamily = (s: FontSpec): string => typeof s !== 'string' ? s.family ?? basename(s.file).replace(/\.[^.]+$/, '')
|
||||||
|
: isLocalSpec(s) ? basename(s).replace(/\.[^.]+$/, '') : s;
|
||||||
|
|
||||||
|
export const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
|
||||||
|
/** fonts.json file name of a local face: served at /fonts/local/<slug>.<ext>. */
|
||||||
|
export const localFile = (family: string, l: Pick<LocalFontInfo, 'format'>): string =>
|
||||||
|
`local/${slug(family)}.${l.format === 'truetype' ? 'ttf' : l.format === 'opentype' ? 'otf' : l.format}`;
|
||||||
|
|
||||||
|
/** Absolute file of a face: the downloaded subset in .storebound/fonts, or the local font file itself. */
|
||||||
|
export function faceFilePath(fontsDir: string, root: string, f: FontManifest['faces'][string]): string {
|
||||||
|
return f.local ? join(root, f.local.path) : join(fontsDir, f.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File for a /fonts/<rel> request of the render / video page (null = not ours). */
|
||||||
|
export function fontsRoute(rel: string, fontsDir: string, root: string, m: FontManifest | null): string | null {
|
||||||
|
rel = rel.split('?')[0]!;
|
||||||
|
if (rel.includes('..')) return null;
|
||||||
|
if (rel.startsWith('local/')) {
|
||||||
|
const f = Object.values(m?.faces ?? {}).find(x => x.local && x.file === rel);
|
||||||
|
return f ? faceFilePath(fontsDir, root, f) : null;
|
||||||
|
}
|
||||||
|
return join(fontsDir, rel);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FONT_TYPES: Record<string, string> = { woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css' };
|
||||||
|
|
||||||
|
/** Local faces whose file changed (or vanished) since `shots fonts`. */
|
||||||
|
export function staleLocalFonts(m: FontManifest, root: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const f of new Map(Object.values(m.faces).filter(x => x.local).map(x => [x.local!.path, x] as const)).values()) {
|
||||||
|
const abs = join(root, f.local!.path);
|
||||||
|
if (!existsSync(abs)) out.push(`${f.family}: ${f.local!.path} is gone`);
|
||||||
|
else if (fileHash(new Uint8Array(readFileSync(abs))) !== f.local!.hash) out.push(`${f.family}: ${f.local!.path} changed`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `shots check` warnings for local fonts without a license note. */
|
||||||
|
export function localFontIssues(m: FontManifest | null): { level: 'warn'; code: 'font-license'; msg: string }[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: { level: 'warn'; code: 'font-license'; msg: string }[] = [];
|
||||||
|
for (const f of Object.values(m?.faces ?? {})) {
|
||||||
|
if (!f.local || f.local.license || seen.has(f.local.path)) continue;
|
||||||
|
seen.add(f.local.path);
|
||||||
|
out.push({ level: 'warn', code: 'font-license', msg: `local font ${f.family} (${f.local.path}) has no license note: add license ('OFL-1.1', 'proprietary', or a short note) to its shots.fonts entry, and make sure you may embed it in store graphics` });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
// Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character
|
// Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character
|
||||||
// of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the
|
// of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the
|
||||||
// characters); while rendering, the page measures which face drew every character, and report.json records it per
|
// characters); while rendering, the page measures which face drew every character, and report.json records it per
|
||||||
// image (`fonts`: faces used, automatic fallbacks, system-font glyphs) for `shots check`.
|
// image (`fonts`: faces used, automatic fallbacks, system-font glyphs) for `shots check`. Local font files (shots.fonts
|
||||||
|
// paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails.
|
||||||
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
|
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
|
||||||
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
|
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
|
||||||
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
|
@ -22,11 +23,12 @@ import { done, log, warn } from '../lib/log';
|
||||||
import { launch } from './browser';
|
import { launch } from './browser';
|
||||||
import { defaultConcurrency, pool, progressLine } from './capture-web';
|
import { defaultConcurrency, pool, progressLine } from './capture-web';
|
||||||
import { shotsStyle } from '../lib/config';
|
import { shotsStyle } from '../lib/config';
|
||||||
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, shotFonts, validateCopy, variantScenes } from './lib';
|
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
|
||||||
|
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
|
||||||
import { type RenderReport, mergeReport, readReport } from './report';
|
import { type RenderReport, mergeReport, readReport } from './report';
|
||||||
|
|
||||||
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
||||||
webp: 'image/webp', woff2: 'font/woff2', css: 'text/css', svg: 'image/svg+xml' };
|
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root, resolve } = ctx.loaded;
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
|
@ -62,7 +64,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { phone, tablet } = frameDims(config);
|
const { phone, tablet } = frameDims(config);
|
||||||
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
|
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
|
||||||
const style = shotsStyle(config);
|
const style = shotsStyle(config);
|
||||||
const sf = shotFonts(shots, style);
|
const rf = resolveShotFonts(shots, style, resolve, root);
|
||||||
|
const sf = { title: rf.title.family, body: rf.body.family };
|
||||||
const fonts = { title: sf.title, body: sf.body };
|
const fonts = { title: sf.title, body: sf.body };
|
||||||
const manifest = existsSync(join(fontsDir, 'fonts.css')) ? readManifest(fontsDir) : null;
|
const manifest = existsSync(join(fontsDir, 'fonts.css')) ? readManifest(fontsDir) : null;
|
||||||
if (existsSync(join(fontsDir, 'fonts.css')) && !manifest) warn('shots', 'fonts from an older storebound (no fonts.json): run `storebound shots fonts` again for the glyph coverage check');
|
if (existsSync(join(fontsDir, 'fonts.css')) && !manifest) warn('shots', 'fonts from an older storebound (no fonts.json): run `storebound shots fonts` again for the glyph coverage check');
|
||||||
|
|
@ -79,10 +82,14 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const x = stacks(lang, job.copy).script;
|
const x = stacks(lang, job.copy).script;
|
||||||
if (x) faces[x] = { family: x, file: '', kind: 'script', chosen: true };
|
if (x) faces[x] = { family: x, file: '', kind: 'script', chosen: true };
|
||||||
}
|
}
|
||||||
|
if (manifest) {
|
||||||
|
const stale = staleLocalFonts(manifest, root);
|
||||||
|
if (stale.length) throw new Error(`shots render: local font files changed since \`shots fonts\`: run \`storebound shots fonts\`\n ${stale.join('\n ')}`);
|
||||||
|
}
|
||||||
if (manifest) { // glyph coverage of the local font files, before rendering anything
|
if (manifest) { // glyph coverage of the local font files, before rendering anything
|
||||||
const cmaps = new Map<string, Set<number>>();
|
const cmaps = new Map<string, Set<number>>();
|
||||||
const cps = (face: string): Set<number> => {
|
const cps = (face: string): Set<number> => {
|
||||||
if (!cmaps.has(face)) { const f = manifest.faces[face]; cmaps.set(face, f && existsSync(join(fontsDir, f.file)) ? fontCodepoints(readFileSync(join(fontsDir, f.file))) : new Set()); }
|
if (!cmaps.has(face)) { const f = manifest.faces[face]; const file = f && faceFilePath(fontsDir, root, f); cmaps.set(face, file && existsSync(file) ? fontCodepoints(readFileSync(file)) : new Set()); }
|
||||||
return cmaps.get(face)!;
|
return cmaps.get(face)!;
|
||||||
};
|
};
|
||||||
const gaps: string[] = [];
|
const gaps: string[] = [];
|
||||||
|
|
@ -121,7 +128,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
let file = '';
|
let file = '';
|
||||||
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
|
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
|
||||||
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
|
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
|
||||||
else if (p.startsWith('/fonts/')) file = join(fontsDir, p.slice(7));
|
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
|
||||||
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
|
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
|
||||||
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) file = join(tpl, p.slice(1));
|
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) file = join(tpl, p.slice(1));
|
||||||
if (!file || file.includes('..')) return new Response('no', { status: 404 });
|
if (!file || file.includes('..')) return new Response('no', { status: 404 });
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import { done, log, warn } from '../lib/log';
|
||||||
import { launch } from './browser';
|
import { launch } from './browser';
|
||||||
import { shotsStyle } from '../lib/config';
|
import { shotsStyle } from '../lib/config';
|
||||||
import { readManifest } from './fonts';
|
import { readManifest } from './fonts';
|
||||||
import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont, shotFonts } from './lib';
|
import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont } from './lib';
|
||||||
|
import { FONT_TYPES, fontsRoute, resolveShotFonts } from './local-fonts';
|
||||||
import { ffprobe } from './report';
|
import { ffprobe } from './report';
|
||||||
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec';
|
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec';
|
||||||
|
|
||||||
|
|
@ -159,12 +160,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const style = shotsStyle(ctx.loaded.config);
|
const style = shotsStyle(ctx.loaded.config);
|
||||||
const pal = palette(shots.scenes[0]?.palette, style);
|
const pal = palette(shots.scenes[0]?.palette, style);
|
||||||
return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang),
|
return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang),
|
||||||
fonts: { title: shotFonts(shots, style).title, script: script ? `Store ${script.slug}` : null, titleStack: readManifest(fontsDir)?.langs[lang]?.title } });
|
fonts: { title: resolveShotFonts(shots, style, ctx.loaded.resolve, root).title.family, script: script ? `Store ${script.slug}` : null, titleStack: readManifest(fontsDir)?.langs[lang]?.title } });
|
||||||
}
|
}
|
||||||
if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
||||||
if (url.pathname.startsWith('/fonts/') && !url.pathname.includes('..')) {
|
if (url.pathname.startsWith('/fonts/')) {
|
||||||
const f = Bun.file(join(fontsDir, url.pathname.slice(7)));
|
const file = fontsRoute(decodeURIComponent(url.pathname.slice(7)), fontsDir, root, readManifest(fontsDir));
|
||||||
if (await f.exists()) return new Response(f, { headers: { 'content-type': url.pathname.endsWith('.css') ? 'text/css' : 'font/woff2' } });
|
const f = file ? Bun.file(file) : null;
|
||||||
|
if (f && await f.exists()) return new Response(f, { headers: { 'content-type': FONT_TYPES[file!.split('.').pop()!.toLowerCase()] ?? 'font/woff2' } });
|
||||||
}
|
}
|
||||||
return new Response('no', { status: 404 });
|
return new Response('no', { status: 404 });
|
||||||
} });
|
} });
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,11 @@ in a plain Android tablet body instead of the iPad one.
|
||||||
titles; Montserrat, Inter, Nunito for body), or, if the owner accepts mixed glyphs, `shots.fonts.fallback:
|
titles; Montserrat, Inter, Nunito for body), or, if the owner accepts mixed glyphs, `shots.fonts.fallback:
|
||||||
['Noto Sans Display']`. Lilita One lacks most of Latin Extended (Turkish ş ğ İ, Polish, Czech, Romanian) and
|
['Noto Sans Display']`. Lilita One lacks most of Latin Extended (Turkish ş ğ İ, Polish, Czech, Romanian) and
|
||||||
Vietnamese. Re-run after copy or font edits (render refuses fonts that no longer cover the copy).
|
Vietnamese. Re-run after copy or font edits (render refuses fonts that no longer cover the copy).
|
||||||
|
A game's own brand font: put the file path (relative to the config; TTF / OTF / WOFF / WOFF2) or
|
||||||
|
`{ family, file, license }` into `shots.fonts.title` / `body` / `fallback`. The same coverage check runs on it, listed
|
||||||
|
fallbacks are tried before Noto (`pl title: ę ł ą Ł not in ER Display -> Noto Sans Display`), the file is served in
|
||||||
|
place (never copied) and its hash is in fonts.json, so edits to the font need `shots fonts` again. Ask the owner for
|
||||||
|
the license ('OFL-1.1', 'proprietary', ...) and put it in `license`; `shots check` warns without one.
|
||||||
5. **Style and scenes.** `shots.style`: `game` (sunburst, sparkles, outlined titles, badges) or `app` (calm gradient,
|
5. **Style and scenes.** `shots.style`: `game` (sunburst, sparkles, outlined titles, badges) or `app` (calm gradient,
|
||||||
one clean device frame, headline + subline, no sparkles; badges only as quiet chips when the copy has them).
|
one clean device frame, headline + subline, no sparkles; badges only as quiet chips when the copy has them).
|
||||||
Default: `app` for the app / subscription-app profiles, `game` otherwise. `shots.scenes`: palette (game: fire,
|
Default: `app` for the app / subscription-app profiles, `game` otherwise. `shots.scenes`: palette (game: fire,
|
||||||
|
|
|
||||||
|
|
@ -97,3 +97,12 @@ describe('listing commands', () => {
|
||||||
expect(sb(dir, 'listing', 'translate', '--to', 'xx').stderr.toString()).toContain('not a language of the config');
|
expect(sb(dir, 'listing', 'translate', '--to', 'xx').stderr.toString()).toContain('not a language of the config');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('listing check counts App Store keywords in UTF-8 bytes, the other fields in characters', () => {
|
||||||
|
const ru = 'игра,бег,прыжки,монеты,уровни,рекорд,аркада,быстро'; // 50 characters, 93 bytes
|
||||||
|
const kw = checkFields('apple', { keywords: ru, subtitle: 'Быстрая аркада' }).find(c => c.field === 'keywords')!;
|
||||||
|
expect(kw).toMatchObject({ chars: 93, unit: 'bytes', problems: [] });
|
||||||
|
expect(checkFields('apple', { keywords: ru + ',звук' }).find(c => c.field === 'keywords')!.problems).toContain('102 > 100 bytes');
|
||||||
|
expect(checkFields('apple', { subtitle: 'Быстрая аркада' })[0]).toMatchObject({ chars: 14, unit: 'chars' });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
216
tests/local-fonts.test.ts
Normal file
216
tests/local-fonts.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
// A game's own brand font as a local file in shots.fonts: validation, cmap coverage with listed + Noto fallbacks,
|
||||||
|
// @font-face served in place, `shots check` (local + listed fallback pass, license warning) and the content hash that
|
||||||
|
// invalidates the capture cache and stale renders. Fonts are tiny hand-made sfnt / WOFF2 files (cmap + name only).
|
||||||
|
import { afterAll, describe, expect, test } from 'bun:test';
|
||||||
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { brotliCompressSync } from 'node:zlib';
|
||||||
|
import { fontCodepoints, fontFamilyName } from '../cli/shots/cmap';
|
||||||
|
import { type FaceJob, fontIssues, fontsCss, fontUsage, type FontManifest, GlyphError, planFonts } from '../cli/shots/glyphs';
|
||||||
|
import { langTexts } from '../cli/shots/fonts';
|
||||||
|
import { faceFilePath, fontsRoute, loadLocalFont, localByFamily, localFontIssues, resolveShotFonts, staleLocalFonts } from '../cli/shots/local-fonts';
|
||||||
|
import { planWeb } from '../cli/shots/capture-web';
|
||||||
|
import { validateConfig } from '../cli/lib/config';
|
||||||
|
|
||||||
|
// ---- minimal font writer: an sfnt (or WOFF2) with a format 12 cmap and a name table ----
|
||||||
|
function cmapBytes(cps: number[]): Uint8Array {
|
||||||
|
const sorted = [...new Set(cps)].sort((a, b) => a - b);
|
||||||
|
const len = 16 + sorted.length * 12;
|
||||||
|
const b = new DataView(new ArrayBuffer(12 + len));
|
||||||
|
b.setUint16(2, 1); b.setUint16(4, 3); b.setUint16(6, 10); b.setUint32(8, 12);
|
||||||
|
b.setUint16(12, 12); b.setUint32(16, len); b.setUint32(24, sorted.length);
|
||||||
|
sorted.forEach((c, i) => { b.setUint32(28 + i * 12, c); b.setUint32(32 + i * 12, c); b.setUint32(36 + i * 12, i + 1); });
|
||||||
|
return new Uint8Array(b.buffer);
|
||||||
|
}
|
||||||
|
function nameBytes(family: string): Uint8Array {
|
||||||
|
const str = new Uint8Array(family.length * 2);
|
||||||
|
[...family].forEach((c, i) => { str[i * 2] = 0; str[i * 2 + 1] = c.charCodeAt(0); });
|
||||||
|
const b = new DataView(new ArrayBuffer(6 + 12 + str.length));
|
||||||
|
b.setUint16(2, 1); b.setUint16(4, 18);
|
||||||
|
b.setUint16(6, 3); b.setUint16(8, 1); b.setUint16(10, 0x409); b.setUint16(12, 1); b.setUint16(14, str.length); b.setUint16(16, 0);
|
||||||
|
new Uint8Array(b.buffer).set(str, 18);
|
||||||
|
return new Uint8Array(b.buffer);
|
||||||
|
}
|
||||||
|
function sfnt(cps: number[], family: string, sig = 0x00010000): Uint8Array {
|
||||||
|
const tables: [string, Uint8Array][] = [['cmap', cmapBytes(cps)], ['name', nameBytes(family)]];
|
||||||
|
let off = 12 + tables.length * 16;
|
||||||
|
const out = new Uint8Array(off + tables.reduce((n, [, t]) => n + t.length, 0));
|
||||||
|
const d = new DataView(out.buffer);
|
||||||
|
d.setUint32(0, sig); d.setUint16(4, tables.length);
|
||||||
|
tables.forEach(([tag, t], i) => {
|
||||||
|
out.set([...tag].map(c => c.charCodeAt(0)), 12 + i * 16);
|
||||||
|
d.setUint32(12 + i * 16 + 8, off); d.setUint32(12 + i * 16 + 12, t.length);
|
||||||
|
out.set(t, off); off += t.length;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function woff2(cps: number[], family: string): Uint8Array {
|
||||||
|
const cmap = cmapBytes(cps), name = nameBytes(family);
|
||||||
|
const stream = new Uint8Array(brotliCompressSync(Buffer.concat([cmap, name])));
|
||||||
|
const b128 = (n: number): number[] => { const b = [n & 0x7f]; n = Math.floor(n / 128); while (n) { b.unshift(0x80 | (n & 0x7f)); n = Math.floor(n / 128); } return b; };
|
||||||
|
const dir = [0, ...b128(cmap.length), 5, ...b128(name.length)]; // known-table indices: 0 = cmap, 5 = name
|
||||||
|
const out = new Uint8Array(48 + dir.length + stream.length);
|
||||||
|
const d = new DataView(out.buffer);
|
||||||
|
out.set([...'wOF2'].map(c => c.charCodeAt(0)), 0); d.setUint32(4, 0x00010000); d.setUint32(8, out.length);
|
||||||
|
d.setUint16(12, 2); d.setUint32(20, stream.length);
|
||||||
|
out.set(dir, 48); out.set(stream, 48 + dir.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = (a: number, b: number): number[] => Array.from({ length: b - a + 1 }, (_, i) => a + i);
|
||||||
|
const cpsOf = (s: string): number[] => [...s].map(c => c.codePointAt(0)!);
|
||||||
|
// "ER Display": ASCII, Latin-1 and Latin Extended-A without ą ę Ł ł; no Vietnamese (ơ ư, U+1EA0..)
|
||||||
|
const ER = [...range(0x20, 0x7e), ...range(0xa0, 0xff), ...range(0x100, 0x17f).filter(c => ![0x105, 0x119, 0x141, 0x142].includes(c))];
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'sb-localfonts-'));
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const fontsDir = join(root, '.storebound', 'fonts');
|
||||||
|
mkdirSync(join(root, 'assets', 'fonts'), { recursive: true });
|
||||||
|
mkdirSync(fontsDir, { recursive: true });
|
||||||
|
writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display'));
|
||||||
|
writeFileSync(join(root, 'assets/fonts/er-pl.ttf'), sfnt([0x105, 0x119, 0x141, 0x142], 'ER Polish'));
|
||||||
|
const resolve = (p: string): string => join(root, p);
|
||||||
|
|
||||||
|
describe('local font files: config and validation', () => {
|
||||||
|
test('a path reads the family from the name table (WOFF2 and TTF); the object form sets family, weight, license', () => {
|
||||||
|
expect(fontFamilyName(woff2(ER, 'ER Display'))).toBe('ER Display');
|
||||||
|
const a = loadLocalFont('assets/fonts/er-display.woff2', 'shots.fonts.title', resolve, root);
|
||||||
|
expect(a.family).toBe('ER Display');
|
||||||
|
expect(a.local).toMatchObject({ path: 'assets/fonts/er-display.woff2', format: 'woff2' });
|
||||||
|
expect(a.local!.hash).toMatch(/^[0-9a-f]{16}$/);
|
||||||
|
const b = loadLocalFont({ family: 'ER Pl', file: 'assets/fonts/er-pl.ttf', weight: 800, license: 'OFL-1.1' }, 'k', resolve, root);
|
||||||
|
expect(b).toMatchObject({ family: 'ER Pl', local: { format: 'truetype', weight: '800', license: 'OFL-1.1' } });
|
||||||
|
expect(fontCodepoints(readFileSync(join(root, 'assets/fonts/er-display.woff2'))).size).toBe(ER.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing file, wrong extension, not a font, mismatched format', () => {
|
||||||
|
writeFileSync(join(root, 'assets/fonts/bad.ttf'), new Uint8Array(64));
|
||||||
|
writeFileSync(join(root, 'assets/fonts/fake.ttf'), woff2(ER, 'X'));
|
||||||
|
expect(() => loadLocalFont('assets/fonts/nope.ttf', 'shots.fonts.title', resolve, root)).toThrow('shots.fonts.title: assets/fonts/nope.ttf not found');
|
||||||
|
expect(() => loadLocalFont({ file: 'assets/fonts/er.svg' }, 'k', resolve, root)).toThrow('not a font file (TTF, OTF, WOFF or WOFF2)');
|
||||||
|
expect(() => loadLocalFont('assets/fonts/bad.ttf', 'k', resolve, root)).toThrow('unknown signature');
|
||||||
|
expect(() => loadLocalFont('assets/fonts/fake.ttf', 'k', resolve, root)).toThrow('is WOFF2 inside, but named .ttf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateConfig: font entry shapes', () => {
|
||||||
|
const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en' }] };
|
||||||
|
const shots = (fonts: unknown) => ({ ...base, shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a' }] }], fonts } });
|
||||||
|
expect(validateConfig(shots({ title: 'assets/er.woff2', body: 'Montserrat', fallback: [{ family: 'ER Pl', file: 'a/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display'] }))).toEqual([]);
|
||||||
|
const errs = validateConfig(shots({ title: 'assets/er.svg', body: { family: 'B' }, fallback: [{ file: 'x.otf', style: 'oblique', weight: 'bold' }] }));
|
||||||
|
expect(errs).toEqual([
|
||||||
|
'shots.fonts.title: assets/er.svg: a font file must be TTF, OTF, WOFF or WOFF2',
|
||||||
|
'shots.fonts.body: a Google Fonts family, a font file path or { family, file, weight?, style?, license? }',
|
||||||
|
'shots.fonts.fallback[0].style: normal | italic',
|
||||||
|
'shots.fonts.fallback[0].weight: e.g. 400, 800 or "100 900"',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy: ER Display covers tr / de / fr titles; Polish needs ą ę Ł ł, Vietnamese needs ơ ư ạ ...
|
||||||
|
const COPY = {
|
||||||
|
tr: { s1: { title: ["Taş Devri'nden", 'İleri!'] as [string, string], sub: 'Kasabaları yağmala' } },
|
||||||
|
de: { s1: { title: ['Größer', 'schöner'] as [string, string] } },
|
||||||
|
pl: { s1: { title: ['Gęsi łąka', 'Łódź'] as [string, string], sub: 'Szybka gra' } },
|
||||||
|
vi: { s1: { title: ['Chiến đấu', 'Thế giới'] as [string, string], sub: 'Trận nhanh' } },
|
||||||
|
};
|
||||||
|
const NOTO: Record<string, (cp: number) => boolean> = {
|
||||||
|
Montserrat: () => true, // body: covers everything here
|
||||||
|
'Noto Sans Display': cp => cp < 0x250 || (cp >= 0x1ea0 && cp <= 0x1ef9),
|
||||||
|
};
|
||||||
|
/** Getter like `shots fonts`: local files read in place, Google families faked. */
|
||||||
|
function getter(asked: FaceJob[]): (job: FaceJob) => Promise<Set<number>> {
|
||||||
|
return async job => {
|
||||||
|
asked.push(job);
|
||||||
|
if (job.local) return fontCodepoints(readFileSync(join(root, job.local.path)));
|
||||||
|
const f = NOTO[job.family] ?? (() => false);
|
||||||
|
return new Set(cpsOf(job.chars).filter(f));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function plan(fallback: (string | { family?: string; file: string; license?: string })[] = [], asked: FaceJob[] = []): Promise<FontManifest> {
|
||||||
|
const sf = resolveShotFonts({ fonts: { title: 'assets/fonts/er-display.woff2', body: 'Montserrat', fallback } }, 'game', resolve, root);
|
||||||
|
return planFonts({ langs: langTexts(COPY, [], 'STAR DASH'), title: sf.title.family, body: sf.body.family, titleAxis: null, bodyAxis: 'wght@100..900',
|
||||||
|
fallbackTitleAxis: 'wght@800', chosen: sf.fallback.map(f => f.family), local: localByFamily([sf.title, sf.body, ...sf.fallback]) }, getter(asked));
|
||||||
|
}
|
||||||
|
const fb = (m: FontManifest, lang: string) => m.langs[lang]!.fallbacks.filter(f => f.role === 'title').map(f => `${f.family}:${[...f.chars].join(' ')}`);
|
||||||
|
|
||||||
|
describe('coverage of a local title font', () => {
|
||||||
|
test('pl / vi titles: the characters ER Display lacks go to Noto Sans Display (automatic); tr / de need nothing', async () => {
|
||||||
|
const asked: FaceJob[] = [];
|
||||||
|
const m = await plan([], asked);
|
||||||
|
expect(fb(m, 'pl')).toEqual(['Noto Sans Display:ę ł ą Ł']);
|
||||||
|
expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']);
|
||||||
|
for (const l of ['tr', 'de']) expect(m.langs[l]!.fallbacks).toEqual([]);
|
||||||
|
expect(m.faces['ER Display']).toMatchObject({ family: 'ER Display', kind: 'title', chosen: true, file: 'local/er-display.woff2', local: { path: 'assets/fonts/er-display.woff2', format: 'woff2' } });
|
||||||
|
expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(false);
|
||||||
|
expect(asked.find(j => j.family === 'ER Display')!.local).toBeDefined(); // read in place, not downloaded
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listed fallbacks first (a local file), then Noto; chosen, so check passes', async () => {
|
||||||
|
const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }, 'Noto Sans Display']);
|
||||||
|
expect(fb(m, 'pl')).toEqual(['ER Polish:ę ł ą Ł']);
|
||||||
|
expect(fb(m, 'vi')).toEqual(['Noto Sans Display:ế ấ ớ']);
|
||||||
|
expect(m.langs.pl!.title).toEqual(['ER Display', 'Store fb er-polish title']);
|
||||||
|
expect(m.faces['Store fb er-polish title']).toMatchObject({ kind: 'fallback', chosen: true, file: 'local/er-polish.ttf', local: { license: 'OFL-1.1' } });
|
||||||
|
expect(m.faces['Store fb noto-sans-display title']!.chosen).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing covers it: GlyphError names the local font and the fallbacks tried', async () => {
|
||||||
|
const err = await planFonts({ langs: langTexts({ pl: { s1: { title: ['ą', 'x'] } } }, [], ''), title: 'ER Display', body: 'Montserrat', titleAxis: null, bodyAxis: null,
|
||||||
|
fallbackTitleAxis: null, local: localByFamily([loadLocalFont('assets/fonts/er-display.woff2', 'k', resolve, root)]) },
|
||||||
|
async job => job.local ? fontCodepoints(readFileSync(join(root, job.local.path))) : new Set()).catch(e => e);
|
||||||
|
expect(err).toBeInstanceOf(GlyphError);
|
||||||
|
expect(err.message).toContain('pl (title): "ą" U+0105; tried ER Display, Noto Sans Display, Noto Sans');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render and check with a local font', () => {
|
||||||
|
test('fonts.css: @font-face for the local file, served in place at /fonts/local/... with its hash', async () => {
|
||||||
|
const m = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'OFL-1.1' }]);
|
||||||
|
const css = fontsCss(m);
|
||||||
|
const h = m.faces['ER Display']!.local!.hash;
|
||||||
|
expect(css).toContain(`@font-face { font-family: 'ER Display'; font-weight: 100 900; font-style: normal; font-synthesis: none; src: url(local/er-display.woff2?v=${h}) format('woff2'); }`);
|
||||||
|
expect(css).toContain("src: url(local/er-polish.ttf?v=");
|
||||||
|
expect(css).toContain("format('truetype')");
|
||||||
|
expect(css).toContain('/* ER Polish: local file assets/fonts/er-pl.ttf (license: OFL-1.1) */');
|
||||||
|
expect(fontsRoute(`local/er-display.woff2?v=${h}`, fontsDir, root, m)).toBe(join(root, 'assets/fonts/er-display.woff2'));
|
||||||
|
expect(fontsRoute('local/other.ttf', fontsDir, root, m)).toBeNull();
|
||||||
|
expect(fontsRoute('local/../../x.ttf', fontsDir, root, m)).toBeNull();
|
||||||
|
expect(fontsRoute('fb-noto-sans-display-title.woff2', fontsDir, root, m)).toBe(join(fontsDir, 'fb-noto-sans-display-title.woff2'));
|
||||||
|
expect(faceFilePath(fontsDir, root, m.faces['ER Display']!)).toBe(join(root, 'assets/fonts/er-display.woff2'));
|
||||||
|
// nothing is copied into .storebound/fonts
|
||||||
|
expect(Object.values(m.faces).filter(f => f.local).every(f => f.file.startsWith('local/'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check: the local font and the listed fallback are not fallbacks the config did not choose; an automatic Noto is', async () => {
|
||||||
|
const m = await plan([{ file: 'assets/fonts/er-pl.ttf' }]);
|
||||||
|
const ok = fontUsage([
|
||||||
|
{ el: 'h1 span:1', text: 'Gęsi łąka', faces: { 'ER Display': 'Gsika', 'Store fb er-polish title': 'ęłą' } },
|
||||||
|
{ el: 'p.sub', text: 'Szybka gra', faces: { Montserrat: 'Szybkagr' } },
|
||||||
|
], m.faces);
|
||||||
|
expect(ok.used).toEqual({ 'ER Display': 5, 'ER Polish': 3, Montserrat: 8 });
|
||||||
|
expect(fontIssues(ok)).toEqual([]);
|
||||||
|
const bad = fontUsage([{ el: 'h1 span:1', text: 'Chiến', faces: { 'ER Display': 'Chin', 'Store fb noto-sans-display title': 'ế' } }], m.faces);
|
||||||
|
expect(fontIssues(bad).map(i => i.code)).toEqual(['font-fallback']);
|
||||||
|
// license note: warn for the local file without one
|
||||||
|
expect(localFontIssues(m).map(i => i.msg.split(' has')[0])).toEqual(['local font ER Display (assets/fonts/er-display.woff2)', 'local font ER Polish (assets/fonts/er-pl.ttf)']);
|
||||||
|
const licensed = await plan([{ file: 'assets/fonts/er-pl.ttf', license: 'proprietary' }]);
|
||||||
|
expect(localFontIssues(licensed).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a changed font file changes the content hash: capture cache misses, render refuses the stale fonts.json', async () => {
|
||||||
|
const m1 = await plan();
|
||||||
|
const cap = { source: 'web' as const, baseUrl: 'http://x', shots: { a: { path: '/a' } } };
|
||||||
|
const dims = { phone: { w: 10, h: 20, dpr: 1, bar: 0 }, tablet: { w: 10, h: 20, dpr: 1, bar: 0 } };
|
||||||
|
const job = (m: FontManifest) => planWeb(cap, { langs: ['tr'], devices: ['phone'], only: [], dims, framesDir: '/f', build: 'b', fonts: fontsCss(m) })[0]!.hash;
|
||||||
|
expect(staleLocalFonts(m1, root)).toEqual([]);
|
||||||
|
writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2([...ER, 0x105], 'ER Display'));
|
||||||
|
try {
|
||||||
|
expect(staleLocalFonts(m1, root)).toEqual(['ER Display: assets/fonts/er-display.woff2 changed']);
|
||||||
|
const m2 = await plan();
|
||||||
|
expect(m2.faces['ER Display']!.local!.hash).not.toBe(m1.faces['ER Display']!.local!.hash);
|
||||||
|
expect(job(m2)).not.toBe(job(m1));
|
||||||
|
expect(fb(m2, 'pl')).toEqual(['Noto Sans Display:ę ł Ł']); // ą now in the font
|
||||||
|
} finally { writeFileSync(join(root, 'assets/fonts/er-display.woff2'), woff2(ER, 'ER Display')); }
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue