From e81003aaaee132ba00069cf6c3a8637a2d266733 Mon Sep 17 00:00:00 2001 From: natyang1234 Date: Sat, 8 Aug 2026 14:55:07 +0800 Subject: [PATCH] fix: cover CJK transcripts instead of rendering tofu Every library recipe declares a Latin webfont stack (Archivo, Playfair, ...) with Latin system tails, and the engine resolves only the webfonts it fetches - there is no per-glyph system-font fallback. A Chinese, Japanese or Korean transcript therefore renders as tofu boxes on every caption, and probe-qa cannot tell: tofu has ink and contrast. Found on the first real Chinese video this pipeline was given (a zh-TW birthday clip through the custom whisper provider): every caption drew as empty rectangles while all gates reported clean. generate-recipe now runs a deterministic font-coverage pass inside the one writer of the final document. When the transcript contains CJK and the document's font stacks name no covering family, the matching Noto family (TC / JP / KR by script; Han without kana or hangul defaults to Traditional) is added to the existing Google Fonts css2 request and appended to each font-family list ahead of the generic keyword - so the recipe's webfonts keep styling Latin and the Noto face catches only the glyphs they lack. A Latin transcript, or a recipe that already names a covering family, passes through byte-identical, keeping recipe runs deterministic for the content they were authored against. Verified end to end: the same run renders tofu before and correct Traditional Chinese after, with lint and probe-qa clean; unit tests cover script detection, URL extension, stack placement, the serif/sans split, and the byte-identical pass-throughs. Run: node --import tsx --test tests/ensure-font-coverage.test.ts --- pipeline/scripts/ensure-font-coverage.ts | 81 ++++++++++++++++++++++++ pipeline/scripts/generate-recipe.ts | 11 +++- tests/ensure-font-coverage.test.ts | 61 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 pipeline/scripts/ensure-font-coverage.ts create mode 100644 tests/ensure-font-coverage.test.ts 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"/); +});