Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions pipeline/scripts/ensure-font-coverage.ts
Original file line number Diff line number Diff line change
@@ -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<string, { sans: string; serif: string; axis: string }> = {
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 =
`<link rel="preconnect" href="https://fonts.googleapis.com">\n` +
`<link href="https://fonts.googleapis.com/css2?family=${sans.replace(/ /g, '+')}:${axis}` +
`&family=${serif.replace(/ /g, '+')}:${axis}&display=swap" rel="stylesheet">\n`;
out = out.includes('<style') ? out.replace('<style', `${link}<style`) : link + out;
}

// 2. Every font-family list gains the covering family just before its
// generic keyword, so the declared webfonts keep styling Latin and the
// Noto face catches only the glyphs they lack. A serif stack gets the
// serif face; everything else the sans.
out = out.replace(/font-family\s*:\s*([^;}]+)/g, (match, raw: string) => {
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;
}
11 changes: 10 additions & 1 deletion pipeline/scripts/generate-recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,9 +87,17 @@ async function main(): Promise<number> {
const demote: Record<string, number> = {};
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();
Expand Down
61 changes: 61 additions & 0 deletions tests/ensure-font-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,800;0,900&family=Playfair+Display:ital,wght@0,500;0,600&display=swap" rel="stylesheet">
<style>
.sans { font-family:"Archivo","Helvetica Neue",Arial,sans-serif; font-weight:800; }
.serif { font-family:"Playfair Display",Georgia,serif; font-style:italic; }
</style>`;

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 = '<style>.t { font-family:Arial,sans-serif; }</style>';
const out = ensureFontCoverage(bare, '生日快樂');
assert.match(out, /<link href="https:\/\/fonts\.googleapis\.com\/css2\?family=Noto\+Sans\+TC/);
assert.match(out, /font-family:Arial,"Noto Sans TC",sans-serif/);
});

test('a stack with no generic keyword still gains the family', () => {
const bare = '<style>.t { font-family:"Archivo"; }</style>';
const out = ensureFontCoverage(bare, '生日快樂');
assert.match(out, /font-family:"Archivo","Noto Sans TC"/);
});