Merge remote-tracking branch 'origin/main' into aso-plus

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
CRTkafa 2026-10-12 04:24:52 +08:00
commit 3fb3f2108a
16 changed files with 532 additions and 55 deletions

View file

@ -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

View file

@ -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`);

View file

@ -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));

View file

@ -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);

View file

@ -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`);

View file

@ -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')) {

View file

@ -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));

View 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');

View file

@ -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;

View file

@ -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
View 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;
}

View file

@ -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 });

View file

@ -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 });
} }); } });

View file

@ -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,

View file

@ -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
View 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')); }
});
});