diff --git a/pipeline/scripts/ensure-font-coverage.ts b/pipeline/scripts/ensure-font-coverage.ts new file mode 100644 index 0000000..7f6112b --- /dev/null +++ b/pipeline/scripts/ensure-font-coverage.ts @@ -0,0 +1,81 @@ +// Guarantee the generated document can draw every script the transcript uses. +// +// Every library recipe declares a Latin webfont stack (Archivo, Playfair, …) +// with Latin system tails. The engine resolves only the webfonts it fetches — +// it does not fall back to system fonts per glyph — so a Chinese, Japanese or +// Korean transcript renders as tofu boxes on every caption, and probe-qa +// cannot tell (it measures ink and contrast, and tofu has both). +// +// This is a deterministic post-pass inside the one writer of the final +// document: when the transcript contains CJK and the document's font stacks +// name no CJK-capable family, the matching Noto family is added to the +// Google Fonts request and appended to each font-family list, ahead of the +// generic keyword. A Latin transcript, or a recipe that already covers the +// script, passes through byte-identical. + +const HAN = /[㐀-鿿豈-﫿]/; +const KANA = /[぀-ヿ]/; +const HANGUL = /[가-힯ᄀ-ᇿ]/; + +// Han without kana or hangul is undecidable between Traditional and +// Simplified from codepoints alone; Traditional is the default here and a +// recipe (or operator copy) that names its own CJK family always wins. +const FAMILIES: Record = { + tc: { sans: 'Noto Sans TC', serif: 'Noto Serif TC', axis: 'wght@400;500;700;900' }, + jp: { sans: 'Noto Sans JP', serif: 'Noto Serif JP', axis: 'wght@400;500;700;900' }, + kr: { sans: 'Noto Sans KR', serif: 'Noto Serif KR', axis: 'wght@400;500;700;900' }, +}; + +export function cjkScriptOf(text: string): 'tc' | 'jp' | 'kr' | null { + if (KANA.test(text)) return 'jp'; + if (HANGUL.test(text)) return 'kr'; + if (HAN.test(text)) return 'tc'; + return null; +} + +const COVERING = /Noto (Sans|Serif) (TC|SC|JP|KR|HK)|PingFang|Hiragino|Microsoft (JhengHei|YaHei)|Yu Gothic|Malgun|Source Han|思源/; + +export function ensureFontCoverage(wv: string, transcriptText: string): string { + const script = cjkScriptOf(transcriptText); + if (!script) return wv; + if (COVERING.test(wv)) return wv; + const { sans, serif, axis } = FAMILIES[script]; + + let out = wv; + + // 1. The webfont request. Extend an existing css2 URL so the fonts arrive + // over the connection the recipe already opens; add a link only when + // the document loads no webfonts at all. + const css2 = /(https:\/\/fonts\.googleapis\.com\/css2\?[^"']*?)(&display=[a-z]+)?(["'])/; + const familyParams = + `&family=${sans.replace(/ /g, '+')}:${axis}` + + `&family=${serif.replace(/ /g, '+')}:${axis}`; + if (css2.test(out)) { + out = out.replace(css2, (_m, url, display, quote) => + `${url}${familyParams}${display ?? ''}${quote}`); + } else { + const link = + `\n` + + `\n`; + out = out.includes(' { + const families = raw.trim(); + if (COVERING.test(families)) return match; + const wanted = /(^|,)\s*serif\s*($|,)/.test(families) ? serif : sans; + const generic = families.match(/,\s*(sans-serif|serif|monospace|system-ui)\s*$/); + if (generic) { + const head = families.slice(0, generic.index); + return `font-family:${head},"${wanted}"${generic[0]}`; + } + return `font-family:${families},"${wanted}"`; + }); + + return out; +} diff --git a/pipeline/scripts/generate-recipe.ts b/pipeline/scripts/generate-recipe.ts index e531010..5786041 100644 --- a/pipeline/scripts/generate-recipe.ts +++ b/pipeline/scripts/generate-recipe.ts @@ -17,6 +17,7 @@ import { join, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; import { REPO_ROOT, VEED_ENGINE_BIN } from '../../config.ts'; import { lintTemplate } from './lint-template.ts'; +import { ensureFontCoverage } from './ensure-font-coverage.ts'; import { probeRun } from './probe-qa.ts'; import { readStylePick } from './sample-style.ts'; import { generatorRelPath, type RecipeGenerator, type RunMeta } from '../recipes/lib.ts'; @@ -86,9 +87,17 @@ async function main(): Promise { const demote: Record = {}; const tplPath = join(finalDir, 'template.wv'); const manifestPath = join(finalDir, 'manifest.json'); + // The transcript's own words, for the font-coverage guarantee below. + const transcriptText = timings.beats + .flatMap((beat) => beat.words.map((word) => word.w)) + .join(''); const write = () => { const out = recipe.generate(meta, timings, { demote }); - writeFileSync(tplPath, out.wv); + // A Latin-only font stack renders a CJK transcript as tofu on every + // caption, and probe-qa cannot tell (tofu has ink and contrast). The + // covering family is added deterministically inside the one writer of + // the document; Latin runs pass through byte-identical. + writeFileSync(tplPath, ensureFontCoverage(out.wv, transcriptText)); writeFileSync(manifestPath, out.manifest); }; write(); diff --git a/tests/ensure-font-coverage.test.ts b/tests/ensure-font-coverage.test.ts new file mode 100644 index 0000000..150ba65 --- /dev/null +++ b/tests/ensure-font-coverage.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { cjkScriptOf, ensureFontCoverage } from '../pipeline/scripts/ensure-font-coverage.ts'; + +const DOC = ` + +`; + +test('a latin transcript passes through byte-identical', () => { + assert.equal(ensureFontCoverage(DOC, 'happy birthday to you'), DOC); +}); + +test('script detection: han→tc, kana→jp, hangul→kr, latin→null', () => { + assert.equal(cjkScriptOf('第二個願望'), 'tc'); + assert.equal(cjkScriptOf('誕生日おめでとう'), 'jp'); + assert.equal(cjkScriptOf('생일 축하해'), 'kr'); + assert.equal(cjkScriptOf('happy birthday'), null); +}); + +test('a chinese transcript gains noto on the existing css2 request', () => { + const out = ensureFontCoverage(DOC, '第二個願望就是大家的感情都有個好歸宿'); + assert.match(out, /family=Noto\+Sans\+TC/); + assert.match(out, /family=Noto\+Serif\+TC/); + // extended in place, not a second request + assert.equal((out.match(/fonts\.googleapis\.com\/css2/g) ?? []).length, 1); + // &display stays terminal on the URL + assert.match(out, /family=Noto\+Serif\+TC:[^"]*&display=swap/); +}); + +test('the covering family lands ahead of the generic keyword', () => { + const out = ensureFontCoverage(DOC, '生日快樂'); + assert.match(out, /font-family:"Archivo","Helvetica Neue",Arial,"Noto Sans TC",sans-serif/); + assert.match(out, /font-family:"Playfair Display",Georgia,"Noto Serif TC",serif/); +}); + +test('a japanese transcript picks the jp faces', () => { + const out = ensureFontCoverage(DOC, 'おめでとう'); + assert.match(out, /Noto\+Sans\+JP/); + assert.match(out, /"Noto Sans JP",sans-serif/); +}); + +test('a recipe that already covers the script is left alone', () => { + const covered = DOC.replace('"Helvetica Neue"', '"Noto Sans TC","Helvetica Neue"'); + assert.equal(ensureFontCoverage(covered, '第二個願望'), covered); +}); + +test('a document with no webfont link gains one', () => { + const bare = ''; + const out = ensureFontCoverage(bare, '生日快樂'); + assert.match(out, / { + const bare = ''; + const out = ensureFontCoverage(bare, '生日快樂'); + assert.match(out, /font-family:"Archivo","Noto Sans TC"/); +});