From a2d7d794d5302fe8c80baceea038f3432fc98421 Mon Sep 17 00:00:00 2001 From: Kevin Deng Date: Thu, 20 Aug 2026 02:27:16 +0900 Subject: [PATCH] Self-host the Latin and monospace faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every font the site draws with is now cut and served by us, and nothing reaches fonts.gstatic.com. Latin, digits and punctuation come from Noto Sans and Noto Serif rather than from the CJK families, whose Latin is a compromise; the data columns come from IBM Plex Mono. One file per style rather than one per weight, because the Latin faces are variable and the interface only ever asks for 400 and 500. Two leaks were worth chasing. The tone marks a reading carries -- ā, ǐ, ǜ -- sit in Google's latin-ext chunk, so nearly every character page paid 164KB for a handful of vowels; the subset now carries them, along with the punctuation the templates reach for directly rather than through the message files. And presetWebFonts puts its own family at the head of `font-mono`, so every codepoint and stroke count went to Google no matter what the variable said. With nothing left to fall back to, presetWebFonts goes: its 669 @font-face rules were the entry stylesheet, 638KB of declarations for faces that can no longer be fetched. The typeface switch names each face in that face, so both designs are on screen at once and no single font file can serve them. Rather than ship two files for two glyphs, their outlines are lifted at build time and inlined as SVG. first paint, homepage 10 requests, 45KB from Google -> 7, none character page 3 requests, 210KB from Google -> 0 entry stylesheet 638KB -> 25KB Co-Authored-By: Claude Opus 5 --- .prettierignore | 1 + app/components/StyleToggle.vue | 24 +++++++- app/generated/face-marks.ts | 16 +++++ app/styles/global.css | 6 -- app/styles/vars.css | 32 +++++----- eslint.config.js | 8 ++- scripts/build-fonts.ts | 108 +++++++++++++++++++++++++++++---- scripts/sources.ts | 19 ++++++ uno.config.ts | 12 +--- 9 files changed, 181 insertions(+), 45 deletions(-) create mode 100644 app/generated/face-marks.ts diff --git a/.prettierignore b/.prettierignore index 7816184..4ed3f5a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ public/data/sources.json # Generated by scripts/build-fonts.ts alongside the woff2 files public/fonts/fonts-*.css +app/generated/ diff --git a/app/components/StyleToggle.vue b/app/components/StyleToggle.vue index c845d9a..527882b 100644 --- a/app/components/StyleToggle.vue +++ b/app/components/StyleToggle.vue @@ -1,6 +1,7 @@ diff --git a/app/generated/face-marks.ts b/app/generated/face-marks.ts new file mode 100644 index 0000000..79997be --- /dev/null +++ b/app/generated/face-marks.ts @@ -0,0 +1,16 @@ +/* Generated by scripts/build-fonts.ts -- do not edit. + * The typeface switch names each face in that face, so both designs are on + * screen at once and no single font file can serve them. Two glyphs are not + * worth two requests, so their outlines are inlined instead. + * Noto Sans CJK / Noto Serif CJK, SIL OFL 1.1. + */ +export const FACE_VIEW_BOX = "0 -880 1000 1000" + +export const FACE_MARKS: Record> = { + "sans": { + "黑": "M282 -696C311 -649 337 -586 346 -546L398 -567C390 -607 362 -667 332 -713ZM658 -714C641 -667 607 -598 581 -556L629 -536C656 -576 689 -638 717 -692ZM340 -90C351 -37 358 32 358 74L431 65C431 24 422 -44 410 -96ZM546 -88C568 -36 591 32 599 74L674 56C664 15 640 -52 616 -102ZM749 -92C797 -39 853 35 878 81L951 53C924 6 866 -66 818 -117ZM168 -117C144 -54 101 13 57 52L126 84C174 38 215 -34 240 -99ZM227 -739L461 -739L461 -521L227 -521ZM536 -739L766 -739L766 -521L536 -521ZM55 -224L55 -157L946 -157L946 -224L536 -224L536 -314L861 -314L861 -376L536 -376L536 -458L841 -458L841 -802L155 -802L155 -458L461 -458L461 -376L138 -376L138 -314L461 -314L461 -224Z" + }, + "serif": { + "宋": "M437 -839L427 -832C463 -801 498 -746 504 -701C573 -650 636 -794 437 -839ZM169 -733L152 -732C157 -667 118 -609 79 -588C56 -575 42 -554 51 -531C63 -505 101 -505 127 -523C156 -543 183 -586 183 -651L836 -651C823 -613 802 -566 786 -534L800 -527C839 -556 892 -604 920 -639C941 -640 952 -642 959 -648L880 -725L835 -681L180 -681C178 -697 175 -715 169 -733ZM848 -489L801 -429L532 -429L532 -580C556 -584 565 -593 567 -607L466 -618L466 -429L72 -429L81 -399L430 -399C356 -230 218 -74 37 28L47 44C233 -40 375 -164 466 -315L466 78L479 78C504 78 532 63 532 54L532 -399C609 -206 742 -53 894 31C904 1 928 -17 955 -20L957 -31C801 -94 642 -236 556 -399L910 -399C923 -399 933 -404 935 -415C902 -447 848 -489 848 -489Z" + } +} diff --git a/app/styles/global.css b/app/styles/global.css index 8682391..5542b63 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -148,12 +148,6 @@ html[data-style='serif'] .hanji-jp { .style-option { color: var(--c-ink-mute); } -.style-option[data-option='sans'] { - font-family: 'Hanji Face Sans', sans-serif; -} -.style-option[data-option='serif'] { - font-family: 'Hanji Face Serif', serif; -} button:hover .style-option { color: var(--c-ink-soft); } diff --git a/app/styles/vars.css b/app/styles/vars.css index 5d6f8ac..0703315 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -24,43 +24,47 @@ /* Interface body type, which follows the interface language: a page about glyph differences must not itself set its own copy in the wrong regional - forms. 'UI zh-CN Sans' is our subset of that copy; the Google family - behind it is the safety net for anything the subset missed. */ + forms. Latin comes from a face designed for it, Han from our subset of + the copy. Every face here is served by us -- the Noto names further along + are only picked up if the reader happens to have them installed, and + nothing reaches a font CDN. */ --font-ui: - 'Noto Sans', 'UI zh-CN Sans', 'Noto Sans SC', ui-sans-serif, system-ui, - sans-serif; - --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace; + 'UI Latin Sans', 'UI zh-CN Sans', 'Noto Sans', 'Noto Sans SC', + ui-sans-serif, system-ui, sans-serif; + --font-mono: + 'UI Mono', 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace; } html[lang='zh-TW'] { --font-ui: - 'Noto Sans', 'UI zh-TW Sans', 'Noto Sans TC', 'Noto Sans SC', ui-sans-serif, - system-ui, sans-serif; + 'UI Latin Sans', 'UI zh-TW Sans', 'Noto Sans', 'Noto Sans TC', + 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif; } html[lang='zh-HK'] { --font-ui: - 'Noto Sans', 'UI zh-HK Sans', 'Noto Sans HK', 'Noto Sans SC', ui-sans-serif, - system-ui, sans-serif; + 'UI Latin Sans', 'UI zh-HK Sans', 'Noto Sans', 'Noto Sans HK', + 'Noto Sans SC', ui-sans-serif, system-ui, sans-serif; } /* The typeface switch reaches the whole interface, not only the table. The language pairs stay more specific so they keep winning. */ html[data-style='serif'] { --font-ui: - 'Noto Serif', 'UI zh-CN Serif', 'Noto Serif SC', ui-serif, Georgia, serif; + 'UI Latin Serif', 'UI zh-CN Serif', 'Noto Serif', 'Noto Serif SC', ui-serif, + Georgia, serif; } html[data-style='serif'][lang='zh-TW'] { --font-ui: - 'Noto Serif', 'UI zh-TW Serif', 'Noto Serif TC', 'Noto Serif SC', ui-serif, - Georgia, serif; + 'UI Latin Serif', 'UI zh-TW Serif', 'Noto Serif', 'Noto Serif TC', + 'Noto Serif SC', ui-serif, Georgia, serif; } html[data-style='serif'][lang='zh-HK'] { --font-ui: - 'Noto Serif', 'UI zh-HK Serif', 'Noto Serif HK', 'Noto Serif SC', ui-serif, - Georgia, serif; + 'UI Latin Serif', 'UI zh-HK Serif', 'Noto Serif', 'Noto Serif HK', + 'Noto Serif SC', ui-serif, Georgia, serif; } html.dark { diff --git a/eslint.config.js b/eslint.config.js index 6c29f4f..9ef77b8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,5 +13,11 @@ export default sxzz({ rules: { 'import/no-default-export': 'off' }, }) .append({ - ignores: ['public/data/**', 'public/fonts/**', 'data/raw/**'], + ignores: [ + 'public/data/**', + 'public/fonts/**', + 'data/raw/**', + // Written by scripts/build-fonts.ts; formatting it only churns the diff + 'app/generated/**', + ], }) diff --git a/scripts/build-fonts.ts b/scripts/build-fonts.ts index f45d150..390c7b0 100644 --- a/scripts/build-fonts.ts +++ b/scripts/build-fonts.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' /** * Builds subset woff2 files from the regional Noto CJK faces, in both sans and * serif. @@ -21,6 +22,7 @@ */ import { readdir, readFile, unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' +import * as fontkit from 'fontkit' import subsetFont from 'subset-font' import { messages } from '../app/locales/all.ts' import { @@ -32,7 +34,7 @@ import { import { dictLinks, formsOf } from '../shared/links.ts' import { fontIndexOf } from '../shared/row.ts' import { REGIONS, STYLES, type CharsData, type Style } from '../shared/types.ts' -import { DATA_DIR, FONT_DIR, raw, SOURCES } from './sources.ts' +import { DATA_DIR, FONT_DIR, raw, ROOT, SOURCES } from './sources.ts' /** Region code as Noto names it. */ const NOTO: Record = { cn: 'sc', hk: 'hk', tw: 'tc', jp: 'jp' } @@ -225,31 +227,113 @@ for (const locale of Object.keys(messages)) { } /** - * The typeface switch labels itself with 黑 and 宋, each set in the face it - * names. Those two glyphs have to be available in both faces at all times, - * whereas the serif stylesheet only arrives once a reader asks for serif -- - * hence this pair of two-character files, declared alongside the interface - * font and always loaded. + * Latin, digits and punctuation, cut from faces designed for them. + * + * These sit ahead of Google's families in the stack, which is what keeps the + * page from reaching fonts.gstatic.com at all -- a third-party request that + * costs a round trip everywhere and simply fails behind the Great Firewall. + * One file per style rather than one per weight: the faces are variable, and + * the interface only ever asks for 400 and 500. + */ +const TONE_MARKS = 'āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜńňǹḿüê' + +/** + * Punctuation the templates reach for directly rather than through the + * message files -- an en dash between the stroke bounds, arrows and ellipses + * in prose. Missing one costs a whole Google chunk for a single character. */ -const FACE_SAMPLE = '黑宋' +const PUNCTUATION = '–—…‘’“”·×÷→←↑↓•§¶†‡' +const ASCII = Array.from({ length: 0x7e - 0x20 + 1 }, (_, i) => + String.fromCodePoint(0x20 + i), +).join('') + +const LATIN_SOURCE: Record = { + sans: 'font/NotoSans-VF.ttf', + serif: 'font/NotoSerif-VF.ttf', +} for (const style of STYLES) { - const subset = await subsetFont(await raw(otf(style, 'cn')), FACE_SAMPLE, { + const chars = [...new Set(ASCII + TONE_MARKS + PUNCTUATION + uiText())].join( + '', + ) + const subset = await subsetFont(await raw(LATIN_SOURCE[style]), chars, { targetFormat: 'woff2', noLayoutClosure: true, }) - const file = `face-${style}.woff2` + const file = `ui-latin-${style}.woff2` await writeFile(join(FONT_DIR, file), subset) faces.ui!.push( `@font-face { - font-family: 'Hanji Face ${style === 'sans' ? 'Sans' : 'Serif'}'; + font-family: 'UI Latin ${style === 'sans' ? 'Sans' : 'Serif'}'; src: url('/fonts/${file}') format('woff2'); - font-display: block; + font-weight: 100 900; + font-display: swap; }`, ) - console.error(`${file} ${(subset.length / 1024).toFixed(1)} KB`) + console.error(`${file} ${(subset.length / 1024).toFixed(0)} KB`) } +{ + // The data columns -- codepoints, stroke counts, frequency ranks -- are set + // in one weight, so one static cut covers them + const subset = await subsetFont( + await raw('font/IBMPlexMono-Regular.woff2'), + ASCII + PUNCTUATION, + { targetFormat: 'woff2', noLayoutClosure: true }, + ) + await writeFile(join(FONT_DIR, 'ui-mono.woff2'), subset) + faces.ui!.push( + `@font-face { + font-family: 'UI Mono'; + src: url('/fonts/ui-mono.woff2') format('woff2'); + font-display: swap; +}`, + ) + console.error(`ui-mono.woff2 ${(subset.length / 1024).toFixed(0)} KB`) +} + +/** + * The typeface switch labels itself with 黑 and 宋, each set in the face it + * names, so the two designs have to be on screen at once -- which no single + * font file can do. Two glyphs are not worth two requests, so their outlines + * are lifted here and inlined as SVG instead. + * + * Keyed by character across every locale, so a language that labels the + * switch differently is covered by adding it to the message file. + */ +async function faceMarks(): Promise { + const marks: Record> = {} + // The ideographic em box with the baseline at y=0, which is where these + // glyphs are drawn; hhea's ascent carries line spacing and would frame them + // far too loosely. + const viewBox = '0 -880 1000 1000' + for (const style of STYLES) { + const font = fontkit.create( + Buffer.from(await raw(otf(style, 'cn'))), + ) as fontkit.Font + const scale = 1000 / font.unitsPerEm + for (const locale of Object.keys(messages) as Locale[]) { + // Each label is drawn in the face it names, and only in that face + const char = messages[locale].style[style] + const glyph = font.glyphForCodePoint(char.codePointAt(0)!) + ;(marks[style] ??= {})[char] = glyph.path.scale(scale, -scale).toSVG() + } + } + return `/* Generated by scripts/build-fonts.ts -- do not edit. + * The typeface switch names each face in that face, so both designs are on + * screen at once and no single font file can serve them. Two glyphs are not + * worth two requests, so their outlines are inlined instead. + * Noto Sans CJK / Noto Serif CJK, SIL OFL 1.1. + */ +export const FACE_VIEW_BOX = ${JSON.stringify(viewBox)} + +export const FACE_MARKS: Record> = ${JSON.stringify(marks, undefined, 2)} +` +} + +await writeFile(join(ROOT, 'app/generated/face-marks.ts'), await faceMarks()) +console.error('app/generated/face-marks.ts') + const banner = `/* Generated by scripts/build-fonts.ts -- do not edit. * Noto Sans CJK and Noto Serif CJK (SIL OFL 1.1), subset to the characters * this site uses. Licence text is served alongside at /fonts/OFL.txt. diff --git a/scripts/sources.ts b/scripts/sources.ts index 56f0e80..a08aa55 100644 --- a/scripts/sources.ts +++ b/scripts/sources.ts @@ -211,6 +211,25 @@ export const ASSETS: Record = { 'font/NotoSerifCJKtc-Regular.otf': noto('Serif', 'tc'), 'font/NotoSerifCJKjp-Regular.otf': noto('Serif', 'jp'), + // Latin, digits and punctuation for the interface, plus the tone marks the + // readings carry. Cut from faces designed for Latin rather than from the + // CJK families, whose Latin is a compromise. + 'font/NotoSans-VF.ttf': gh( + 'notofonts/notofonts.github.io', + 'main', + 'fonts/NotoSans/unhinted/variable-ttf/NotoSans%5Bwdth,wght%5D.ttf', + ), + 'font/NotoSerif-VF.ttf': gh( + 'notofonts/notofonts.github.io', + 'main', + 'fonts/NotoSerif/unhinted/variable-ttf/NotoSerif%5Bwdth,wght%5D.ttf', + ), + 'font/IBMPlexMono-Regular.woff2': gh( + 'IBM/plex', + 'master', + 'packages/plex-mono/fonts/complete/woff2/IBMPlexMono-Regular.woff2', + ), + 'font/OFL.txt': gh('notofonts/noto-cjk', 'main', 'Sans/LICENSE'), } diff --git a/uno.config.ts b/uno.config.ts index 0f4a1d6..6a8adf8 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -1,7 +1,6 @@ import { defineConfig, presetIcons, - presetWebFonts, presetWind3, transformerDirectives, } from 'unocss' @@ -24,6 +23,9 @@ export default defineConfig({ }, }, + /** Every face this site uses is cut and served by us; see vars.css. */ + rules: [['font-mono', { 'font-family': 'var(--font-mono)' }]], + shortcuts: { 'border-rule': 'border-$c-rule', 'bg-paper': 'bg-$c-paper', @@ -45,14 +47,6 @@ export default defineConfig({ // Interface body type. The variant follows the locale -- see // app/locales/index.ts. The four Han columns use self-hosted subsets // instead and never come through here. - presetWebFonts({ - provider: 'google', - fonts: { - sans: ['Noto Sans:400,500,700', 'Noto Sans SC:400,500,700'], - serif: ['Noto Serif:400,500,700', 'Noto Serif SC:400,500,700'], - mono: ['IBM Plex Mono:400,500,600'], - }, - }), ], transformers: [transformerDirectives()], -- 2.51.2