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()],