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 @@
@@ -32,9 +41,18 @@ const toggle = () => {
/
- {{
- t(`style.${option}`)
- }}
+
+
+ {{ t(`style.${option}`) }}
+
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