chore(web): remove the icon font toolchain and unused icon names

The UI draws Lucide SVGs through ui/icon.tsx and has not loaded the icon
font for a while; the font, its code-point table and the generator only fed
their own tests. Removed: tools/icons (generator, glyph paths, stroker),
src/styles/reader-icons.ttf, src/ui/icon-names.ts, the `icons` npm script and
the font/metrics tests. icons.test.ts keeps the contracts that still apply:
no borrowed names (now checked against ICONS), only icon.tsx imports
lucide-react, the .icon rule and icon buttons. The .icon rule drops its
font-era declarations.

ICONS loses the names nothing draws (the Font Awesome spellings kept as
aliases, add-circle, film, line-height, indent, sign-out); the reader's 朗读
button is `read-aloud` rather than `volume`. Docs and CLAUDE.md describe
adding an icon in icon.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hectorqinandClaude Opus 5.5 committed 2026-10-30 09:48:04 +00:00
1 parent b997e89059
commit 12fbe0cbcd
18 files changed
+39 -1550

No files matched your search

+3 -3
View File
@@ -30,13 +30,13 @@ npm run typecheck --prefix web && npm test --prefix web && npm run build -
Single tests:
- Server (node:test): `cd server && npx tsx --test test/media-api.test.ts` (add `--test-name-pattern="..."` to filter).
- Web has **two runners**. Most files are Vitest: `cd web && npx vitest run test/foo.test.ts`. A few files written for `node:test` (`api`, `asset-url`, `paginator`, `window`, `windowed-toc`) are excluded from Vitest in `vite.config.ts` and run with `npx tsx --test test/<file>`. When adding a node:test file, add it to both the `test` script and the Vitest `exclude` list.
- Web has **two runners**. Most files are Vitest: `cd web && npx vitest run test/foo.test.ts`. A few files written for `node:test` (`api`, `asset-url`, `windowed-toc`) are excluded from Vitest in `vite.config.ts` and run with `npx tsx --test test/<file>`. When adding a node:test file, add it to both the `test` script and the Vitest `exclude` list.
UI review (real Chromium via CDP; needs `npx playwright install chromium` or `CHROME_PATH`): `npm run ui:review --prefix web` (builds, then screenshots + measured assertions into git-ignored `artifacts/ui-review/react-app/`, result in `verification.json`). Also `ui:review:reader` and `ui:sources`. Never commit screenshots to `docs/`. Run this and look at the screenshots after layout changes.
Docker image (context must be repo root): `docker build -f server/Dockerfile -t reader:local .`
Android: `sh android/scripts/build-web-assets.sh && cd android && ./gradlew :app:assembleDebug`.
Icons: add path to `web/tools/icons/paths.mjs` (use this project's own English names — `test/icons.test.ts` rejects names borrowed from other icon sets), then `npm run icons`.
Icons: map a name to a Lucide glyph in `ICONS` in `web/src/ui/icon.tsx` (this project's own English names — `test/icons.test.ts` rejects borrowed names and any other file importing `lucide-react`); screens use `<Icon name>` / `IconButton`, sizes 14/16/18/20/24/32 only.
## Server architecture
@@ -59,7 +59,7 @@ Icons: add path to `web/tools/icons/paths.mjs` (use this project's own English n
- Server data via React Query; shared client state via Zustand; long-lived resources (player, theme, sync) owned by Providers/services and cleaned up there. New pages go in `features/<domain>/pages`; cross-domain code goes through `shared/` or `app/`. Don't add to the old Screen dispatch (`app.ts`, `app/legacy-compat.ts` exist only for Android diagnostics/old tests).
- **Reader is imperative**: `features/reader/pages/ReaderPage.tsx` mounts `ReaderScreen` (`ui/reader-screen.tsx`) in an effect. Book content lives in a Shadow DOM with pagination measured via the DOM (`ui/reader-view.ts`, `render/`). Don't rewrite the stage as a React subtree; only the chrome is React.
- **Theming**: `ThemeController` (`shared/theme/theme.ts`) writes `data-theme`/`data-mode` on `<html>`; components use `--color-*` tokens from `styles/theme-tokens.css` (`--reader-*` and media aliases map onto them). Read/save theme via `readThemePreference`/`saveThemePreference`/`resolveAppTheme` (and `useThemeState` from `shared/theme/use-theme.ts` in React), never storage directly. New colors/spacing/durations become tokens, not literals.
- UI is built from the project's own components (`ui/toolkit.tsx`, `ui/sheet.tsx` as the only modal type, `ui/action-menu.tsx` for every ⋯ menu, `ui/dialog.tsx`, `ui/pager.tsx`) with Lucide/own icon font. Mantine was removed; some docs still mention it.
- UI is built from the project's own components (`ui/toolkit.tsx`, `ui/sheet.tsx` as the only modal type, `ui/action-menu.tsx` for every ⋯ menu, `ui/dialog.tsx`, `ui/pager.tsx`) with Lucide icons drawn only through `ui/icon.tsx`. Mantine was removed; some docs still mention it.
- Lists are server-paginated with the page in the URL (`docs/ui.md` §3.5). After a write, re-fetch and only then report. The client must not reconstruct facts the server already knows (e.g. book↔file joins); write by book id.
- Offline mirror (`store/offline.ts`) is additive-only; removals must call `offline.removeBooks` explicitly.
- **Mobile/PWA layout** (`docs/ui.md` §2.5, §3.1, §3.3): every `:hover` goes inside `@media (hover: hover) and (pointer: fine)` (enforced by `test/design-tokens.test.ts`); safe areas via the `--safe-*` vars, added only by the layer touching that screen edge (in sidebar mode the sidebar owns the left inset and `.app-shell-body` the right one); finger-reachable inputs ≥16px (`--text-title`) or iOS zooms on focus. `ui:review` does not emulate safe areas — check edge-touching bars with CDP `Emulation.setSafeAreaInsetsOverride`. Manifest/icons/launch colours: `docs/pwa.zh-CN.md`.
+1 -1
View File
@@ -100,7 +100,7 @@ Set `UI_REVIEW_DIR` to change the output directory. Run a review and look at the
### Icons
To add an icon, add its path to `web/tools/icons/paths.mjs` (use this project's own English names; `test/icons.test.ts` rejects names borrowed from other icon sets), then run `npm run icons --prefix web`.
Icons are mapped from a name to a Lucide glyph in one place, `ICONS` in `web/src/ui/icon.tsx` (use this project's own English names; `test/icons.test.ts` rejects names borrowed from other icon sets and any other file importing `lucide-react`). The rules are in `docs/ui.md` §1.
## CI and releases
+1 -1
View File
@@ -100,7 +100,7 @@ UI 评审在真实 Chromium 中通过 CDP 截图并测量;浏览器缺失时
### 图标
新增图标时把路径加入 `web/tools/icons/paths.mjs`(使用本项目自己的英文名,`test/icons.test.ts` 会拒绝借用其他图标集的名称),然后运行 `npm run icons --prefix web`。
图标统一在 `web/src/ui/icon.tsx` 的 `ICONS` 中把名称映射到 Lucide 图标(使用本项目自己的英文名,`test/icons.test.ts` 会拒绝借用其他图标集的名称,也只允许这个文件引入 `lucide-react`)。规则见 `docs/ui.md` §1。
## 持续集成与发布
+5 -11
View File
@@ -22,9 +22,6 @@
`filled` 用文字颜色填充(播放三角、已收藏)。
- 装饰图标默认 `aria-hidden`;独立表意时传 `label`。没有文字的按钮必须有中文 aria-label。
原字体文件与生成工具(`tools/icons`、`reader-icons.ttf`、`icon-names.ts`)仅供历史兼容,不再加载到界面,
图标名称也不再由它决定。
## 2. 设计令牌
间距、字号、动效、焦点环、圆角、安全区定义在 `web/src/styles/reader.css` 的 `:root` 里;
@@ -780,9 +777,9 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
1. 新颜色 / 新间距 / 新时长 → 先看 `:root` 里有没有,没有就**加令牌**,
不要在规则里写字面量。
2. 新图标 → 加一条路径到 `tools/icons/paths.mjs`,**键名用这个项目自己的英语词**
(不许借别的图标集的名字,`test/icons.test.ts` 会拒),跑 `npm run icons`,
在 `web/src/ui/icon.tsx` 里把名字映射到 Lucide 图标,然后用 `IconButton` 引用名字。
2. 新图标 → 在 `web/src/ui/icon.tsx` 的 `ICONS` 里加一个名字映射到 Lucide 图标,**名字用这个项目
自己的英语词**(不许借别的图标集的名字,`test/icons.test.ts` 会拒),然后用 `Icon` / `IconButton` 引用名字。
已有意思相同的名字就复用,不要为同一个意思再加一个字形。
3. 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
4. 新动画 → 用 `--motion-duration` 和 `--motion-ease`,不要自己写时长。
5. 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
@@ -809,11 +806,8 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
| 路径 | 内容 |
| --- | --- |
| `web/src/ui/icon.tsx` | `Icon` 组件:图标名到 Lucide SVG 的映射 |
| `web/src/ui/icon-names.ts` | 码位表(生成),`IconName` 的来源 |
| `web/src/styles/reader-icons.ttf` | 字体(生成,界面不再加载) |
| `web/tools/icons/` | 生成器 |
| `web/test/icons.test.ts` | 字体度量、字形包围盒、码位表、**没有借来的名字**、词汇表与 `.icon` 规则的契约 |
| `web/src/ui/icon.tsx` | 图标集:名称(`IconName`)到 Lucide SVG 的唯一映射,尺寸档与线宽 |
| `web/test/icons.test.ts` | **没有借来的名字**、只有 `icon.tsx` 引入 lucide-react、`.icon` 规则与图标按钮的契约 |
| `web/src/ui/toolkit.tsx` | `IconButton` / `IconTextButton` / `Button` / `Segmented` / `Callout` / `SwitchRow` / `SectionTitle` |
| `web/src/ui/action-menu.tsx` / `web/src/styles/action-menu.css` | 每个 `⋯` 菜单的 `ActionMenu` 下拉,以及 `ActionSheet`(§3.7) |
| `web/src/ui/cover-tint.ts` | 无封面占位的色相(§2.7) |
-1
View File
@@ -11,7 +11,6 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run && tsx --test test/api.test.ts test/asset-url.test.ts test/paginator.test.ts test/window.test.ts test/windowed-toc.test.ts",
"test:watch": "vitest",
"icons": "node tools/icons/build.mjs",
"ui:review": "npm run build && node tools/ui-review/react-app.mjs",
"ui:review:only": "node tools/ui-review/react-app.mjs",
"ui:review:reader": "node tools/ui-review/run.mjs",
Binary file not shown.
+2 -11
View File
@@ -666,19 +666,14 @@ book-content {
white-space: nowrap;
}
/* Shared sizing for the SVG icon primitive. */
/* Shared sizing for the SVG icon primitive (ui/icon.tsx): 1em of the title size unless
the icon is given a size, which sets its font-size. */
.icon {
font-size: var(--text-title);
font-style: normal;
font-weight: 400;
font-synthesis: none;
line-height: 1;
display: inline-block;
width: 1em;
text-align: center;
vertical-align: middle;
speak: never;
/* The code point itself must never be selectable or copied. */
user-select: none;
-webkit-user-select: none;
}
@@ -696,10 +691,6 @@ book-content {
* area is the same box, so the pixels are the same pixels on every browser that
* exists; saying it in terms of the box is what keeps that true when the button grows
* a border or a padding, because the centre is still the centre.
*
* This is also why `.icon` must be a *line box* rather than a 1em square: a grid
* centres the box it is given, so the ink has to be in the middle of that box for the
* centring to be visible. See the derivation on `.icon`.
*/
.icon-button {
appearance: none;
-72
View File
@@ -1,72 +0,0 @@
/**
* The icon font's code points.
*
* Generated by `tools/icons/build.mjs` from the same data that builds
* `reader-icons.ttf`. Do not edit by hand: a code point that disagrees with the
* font's `cmap` renders as a missing-glyph box, and nothing in the type system
* can catch it.
*/
export const ICON_CODEPOINTS = {
'menu': '\ue900',
'chevron-left': '\ue901',
'chevron-right': '\ue902',
'arrow-left': '\ue903',
'folder': '\ue904',
'file-text': '\ue905',
'sliders': '\ue906',
'search': '\ue907',
'close': '\ue908',
'plus': '\ue909',
'check': '\ue90a',
'upload': '\ue90b',
'trash': '\ue90c',
'download': '\ue90d',
'refresh': '\ue90e',
'book': '\ue90f',
'shelf': '\ue910',
'play': '\ue911',
'pause': '\ue912',
'step-backward': '\ue913',
'step-forward': '\ue914',
'stop': '\ue915',
'volume': '\ue916',
'moon': '\ue917',
'sun': '\ue918',
'eye': '\ue919',
'info': '\ue91a',
'warning': '\ue91b',
'clock': '\ue91c',
'more': '\ue91d',
'edit': '\ue91e',
'sort': '\ue91f',
'logout': '\ue920',
'sign-out': '\ue921',
'add-circle': '\ue922',
'indent': '\ue923',
'text-size': '\ue924',
'line-height': '\ue925',
'settings': '\ue926',
'library': '\ue927',
'tune': '\ue928',
'bookmark': '\ue929',
} as const;
/** Code points kept as aliases, the way the two spellings of 退出 coexist. */
export const ICON_ALIASES = {
'font': '\ue924',
'volume-high': '\ue916',
'backward-step': '\ue913',
'forward-step': '\ue914',
'bars': '\ue900',
'gear': '\ue926',
'xmark': '\ue908',
'magnifying-glass': '\ue907',
'books': '\ue927',
} as const;
export const ICON_CODEPOINT_TABLE = {
...ICON_CODEPOINTS,
...ICON_ALIASES,
} as const;
export type IconName = keyof typeof ICON_CODEPOINT_TABLE;
+9 -12
View File
@@ -4,9 +4,9 @@ import {
Upload, Trash2, Download, RefreshCw, RotateCw, RotateCcw, BookOpen, Library, LibraryBig,
Play, PlayCircle, Pause, SkipBack, SkipForward, Square, Headphones, Volume2, Moon, Sun,
SunMoon, Palette, Eye, EyeOff, Info, TriangleAlert, CloudOff, Clock, History, Timer,
TimerOff, Ellipsis, Pencil, ArrowDownUp, LogOut, CirclePlus, IndentIncrease, Type,
MoveVertical, Settings, SlidersVertical, Bookmark, Music, ListMusic, ListVideo, ListPlus,
ListOrdered, Layers, Heart, Star, Clapperboard, Film, UserRound, Lock, LockOpen, Gauge,
TimerOff, Ellipsis, Pencil, ArrowDownUp, LogOut, Type,
Settings, SlidersVertical, Bookmark, Music, ListMusic, ListVideo, ListPlus,
ListOrdered, Layers, Heart, Star, Clapperboard, UserRound, Lock, LockOpen, Gauge,
Subtitles, AudioLines, MicVocal, Shuffle, Repeat, Repeat1, Maximize, Minimize, Expand,
Shrink, LocateFixed, LayoutGrid, House, Rss,
} from 'lucide-react';
@@ -23,10 +23,10 @@ import type { CSSProperties, JSX } from 'react';
* what a screen asks for; which glyph answers it is decided once.
*
* The names are the product's, not another icon set's (`test/icons.test.ts` keeps
* borrowed names out of the legacy font table; the same rule applies here). The
* aliases at the end are older spellings kept so existing call sites compile.
* borrowed names out). A name nothing draws is removed rather than kept "just in case".
*/
const ICONS = {
/** Exported for the tests that check the names, not for screens: screens use `IconName`. */
export const ICONS = {
// navigation and structure
menu: Menu, 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight, 'chevron-down': ChevronDown,
'arrow-left': ArrowLeft, 'arrow-up': ArrowUp, 'arrow-down': ArrowDown, external: ArrowUpRight,
@@ -37,7 +37,7 @@ const ICONS = {
// actions
search: Search, close: X, plus: Plus, check: Check, upload: Upload, trash: Trash2,
download: Download, refresh: RefreshCw, edit: Pencil, sort: ArrowDownUp, more: Ellipsis,
'add-circle': CirclePlus, 'queue-add': ListPlus, logout: LogOut, 'sign-out': LogOut,
'queue-add': ListPlus, logout: LogOut,
// playback
play: Play, 'play-circle': PlayCircle, pause: Pause, stop: Square,
'step-backward': SkipBack, 'step-forward': SkipForward,
@@ -47,17 +47,14 @@ const ICONS = {
subtitles: Subtitles, 'audio-track': AudioLines, versions: Layers, episodes: ListVideo,
playlist: ListMusic, 'ordered-list': ListOrdered, lyrics: MicVocal, speaker: Volume2,
// media kinds
video: Clapperboard, film: Film, music: Music, audiobook: Headphones, volume: Headphones,
video: Clapperboard, music: Music, audiobook: Headphones, 'read-aloud': Headphones,
// reading and appearance
'text-size': Type, 'line-height': MoveVertical, indent: IndentIncrease, appearance: SunMoon,
'text-size': Type, appearance: SunMoon,
palette: Palette, moon: Moon, sun: Sun, settings: Settings, sliders: SlidersHorizontal,
tune: SlidersVertical,
// state and people
eye: Eye, 'eye-off': EyeOff, info: Info, warning: TriangleAlert, offline: CloudOff,
clock: Clock, history: History, favorite: Heart, star: Star, account: UserRound,
// older spellings
font: Type, 'volume-high': Headphones, 'backward-step': SkipBack, 'forward-step': SkipForward,
bars: Menu, gear: Settings, xmark: X, 'magnifying-glass': Search, books: LibraryBig,
} as const;
export type IconName = keyof typeof ICONS;
+1 -1
View File
@@ -292,7 +292,7 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
{handlers.onTools && <TopButton icon="search" label="工具" onClick={handlers.onTools} />}
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
{/* 朗读 keeps going with its panel closed (ui.md §3.11), so the button says it is on. */}
<TopButton icon="volume" label={state.tts.active ? '朗读中' : '朗读'} pressed={state.tts.active} onClick={() => handlers.toggleSettings('speech')} />
<TopButton icon="read-aloud" label={state.tts.active ? '朗读中' : '朗读'} pressed={state.tts.active} onClick={() => handlers.toggleSettings('speech')} />
<TopButton icon="text-size" label="外观" onClick={() => handlers.toggleSettings('appearance')} />
<TopButton icon="settings" label="设置" onClick={() => handlers.toggleSettings('behavior')} />
</div>
+17 -320
View File
@@ -3,316 +3,27 @@ import { render } from '../src/shared/ui/render-root.ts';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { describe, expect, it } from 'vitest';
import { ICON_ALIASES, ICON_CODEPOINTS, ICON_CODEPOINT_TABLE } from '../src/ui/icon-names.ts';
import { GLYPHS } from '../tools/icons/paths.mjs';
import { pathToSubpaths } from '../tools/icons/path.mjs';
import { Icon } from '../src/ui/icon.tsx';
import { Icon, ICONS } from '../src/ui/icon.tsx';
import { IconButton } from '../src/ui/toolkit.tsx';
/**
* The icon set's contract with the font.
*
* These are the parts that cannot be caught by the type system and would fail
* *silently* in the browser: a code point the font does not define renders as a
* missing-glyph box, and a glyph name in the table that has no path in the source
* means the font and the table were built from different inputs. Both look like a
* stray square in a toolbar, which is easy to miss and impossible to trace back.
*
* The font is parsed here by reading its `cmap` directly rather than by embedding a
* font parser: the table is small, and a test that depends on a font library is a
* test that breaks when the library does.
* The names are the product's (ui.md §1). The set once borrowed Font Awesome 6's
* names verbatim (#40), which put a *known* name on different artwork; they were
* reverted to the plain English the rest of the codebase speaks, and this list keeps
* them from drifting back.
*/
/** Reads the format-4 `cmap` of the bundled font and returns the code points it maps. */
function fontCodePoints(): Set<number> {
const font = readFileSync(join(process.cwd(), 'src/styles/reader-icons.ttf'));
const numTables = font.readUInt16BE(4);
let cmapOffset = 0;
for (let i = 0; i < numTables; i++) {
const entry = 12 + i * 16;
if (font.toString('ascii', entry, entry + 4) === 'cmap') cmapOffset = font.readUInt32BE(entry + 8);
}
expect(cmapOffset).toBeGreaterThan(0);
// Only one encoding record is written: Windows / Unicode BMP.
const subtable = cmapOffset + font.readUInt32BE(cmapOffset + 8);
expect(font.readUInt16BE(subtable)).toBe(4); // format 4
const segCount = font.readUInt16BE(subtable + 6) / 2;
const endBase = subtable + 14;
const startBase = endBase + segCount * 2 + 2;
const deltaBase = startBase + segCount * 2;
const mapped = new Set<number>();
for (let segment = 0; segment < segCount; segment++) {
const end = font.readUInt16BE(endBase + segment * 2);
const start = font.readUInt16BE(startBase + segment * 2);
const delta = font.readInt16BE(deltaBase + segment * 2);
for (let code = start; code <= end && code !== 0xffff; code++) {
// Every segment here maps one code to one glyph by addition.
if (((code + delta) & 0xffff) !== 0) mapped.add(code);
}
}
return mapped;
}
describe('the icon font and its code point table', () => {
const mapped = fontCodePoints();
const table = Object.values(ICON_CODEPOINT_TABLE).map((value) => value.codePointAt(0)!);
it('maps every code point the table declares', () => {
// A code point in the table that the font does not map is a missing-glyph box.
for (const code of table) {
expect(mapped.has(code), `0x${code.toString(16)} is not in the font cmap`).toBe(true);
}
});
it('does not map anything outside the private use area', () => {
// The set has to be unreachable by typing: a font that also mapped ASCII would
// turn ordinary text into icons wherever the family is applied.
for (const code of mapped) {
expect(code, `0x${code.toString(16)} is outside the private use area`).toBeGreaterThanOrEqual(0xe000);
expect(code).toBeLessThanOrEqual(0xf8ff);
}
});
it('has one code point per icon, with no duplicates', () => {
// Counted on the *distinct* code points rather than on the table's length,
// because the table carries aliases: `font` and `text-size` are two spellings
// of one glyph, and a set that maps a code point twice is a rename, not a
// second icon. What has to hold either way is that the font draws exactly the
// code points the table names — no more, no fewer.
const distinct = new Set(table);
expect(distinct.size).toBe(Object.keys(ICON_CODEPOINTS).length);
expect(mapped.size).toBe(distinct.size);
});
it('was built from the same glyph names as the table', () => {
// Catches the case where the font is rebuilt from edited paths but the table is
// stale (or the reverse): the names are the join between the two.
const tableNames = Object.keys(ICON_CODEPOINTS).sort();
const sourceNames = Object.keys(GLYPHS).sort();
expect(tableNames).toEqual(sourceNames);
});
it('does not give two different icons the same path', () => {
// A duplicated path is a wrong icon that no other assertion here can see: the
// name is in the table, the code point maps, the glyph has ink and fits the em —
// and it draws the other icon. `chevron-right` was exactly this, byte for byte
// the same as `chevron-left`, so the "next chapter" button pointed backwards.
const byPath = new Map<string, string[]>();
for (const [name, path] of Object.entries(GLYPHS as Record<string, string>)) {
const key = path.replace(/\s+/g, '');
byPath.set(key, [...(byPath.get(key) ?? []), name]);
}
const duplicates = [...byPath.values()].filter((names) => names.length > 1);
expect(duplicates, `these icons share a path: ${duplicates.map((n) => n.join(' = ')).join('; ')}`).toEqual([]);
});
it('points the two chevrons in opposite directions', () => {
// The mirror is what makes this pair useful; two left chevrons are a working
// glyph and a broken control. Measured on the flattened polylines rather than
// on the strings, because "which way does it point" is a fact about the
// geometry and a regex over the path data is not that fact.
const points = (name: 'chevron-left' | 'chevron-right') =>
pathToSubpaths((GLYPHS as Record<string, string>)[name]!).flat();
const tip = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
// The tip is the extreme point along the direction the chevron points.
return name === 'chevron-left' ? Math.min(...xs) : Math.max(...xs);
};
const back = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return name === 'chevron-left' ? Math.max(...xs) : Math.min(...xs);
};
// A chevron's tip is its leading edge; for it to point left, the leftmost x
// has to be the tip and the two back corners have to be further right.
expect(tip('chevron-left'), 'chevron-left points left').toBeLessThan(back('chevron-left'));
expect(tip('chevron-right'), 'chevron-right points right').toBeGreaterThan(back('chevron-right'));
// And the two are a matched pair: the same span, mirrored about the centre.
const span = (name: 'chevron-left' | 'chevron-right') => {
const xs = points(name).map((p) => p[0]);
return Math.max(...xs) - Math.min(...xs);
};
expect(span('chevron-right')).toBeCloseTo(span('chevron-left'), 5);
});
/*
* The names are the product's, and they have to stop claiming to be someone
* else's.
*
* The set borrowed Font Awesome 6's class names verbatim for one round of the
* review (#40), on the theory that a shared vocabulary stops "the magnifier" from
* having three spellings. What it actually did was put a *known* name on unknown
* artwork: a reader who knows that set opens `paths.mjs` expecting the glyph the
* name promises, and gets ours. The names were reverted to the plain English the
* rest of this codebase already speaks, and this is what keeps them there — a
* one-line assertion, because the failure is a name quietly drifting back to the
* borrowed list rather than a shape being wrong.
*/
const BORROWED = [
'bars', 'magnifying-glass', 'xmark', 'file-lines', 'file-arrow-up', 'file-arrow-down',
'trash-can', 'arrows-rotate', 'table-columns', 'circle-info', 'triangle-exclamation',
'folder-open', 'ellipsis', 'power-off', 'right-from-bracket', 'circle-plus',
'backward-step', 'forward-step', 'volume-high', 'gear', 'books', 'pen',
'arrow-down-wide-short', 'up-down', 'font',
const BORROWED = [
'bars', 'magnifying-glass', 'xmark', 'file-lines', 'file-arrow-up', 'file-arrow-down',
'trash-can', 'arrows-rotate', 'table-columns', 'circle-info', 'triangle-exclamation',
'folder-open', 'ellipsis', 'power-off', 'right-from-bracket', 'circle-plus',
'backward-step', 'forward-step', 'volume-high', 'gear', 'books', 'pen',
'arrow-down-wide-short', 'up-down', 'font',
];
describe('the icon names', () => {
it('does not borrow another icon set’s names', () => {
const names = Object.keys(GLYPHS);
const borrowed = names.filter((name) => BORROWED.includes(name));
expect(borrowed, `these names are another set's: ${borrowed.join(', ')}`).toEqual([]);
// The *table* may keep a borrowed spelling as an alias so that code written
// against the set this repository is based on keeps compiling. What it may not
// do is add a glyph under one: a borrowed name on our own artwork is what the
// list above is about.
const aliasNames = Object.keys(ICON_ALIASES);
const borrowedAliases = aliasNames.filter((name) => BORROWED.includes(name));
expect(borrowedAliases.length, `an alias is a borrowed name: ${borrowedAliases.join(', ')}`).toBeGreaterThan(0);
});
it('points every alias at a code point the font already draws', () => {
// An alias is a second *spelling*, not a second glyph: two names for one code
// point is a rename, a name for a code point nothing draws is a missing-glyph
// box that no other assertion here can see.
const drawn = new Set(Object.values(ICON_CODEPOINTS));
for (const [name, code] of Object.entries(ICON_ALIASES)) {
expect(drawn.has(code), `${name} points at 0x${code.codePointAt(0)!.toString(16)}, which no glyph uses`).toBe(true);
}
});
it('names every glyph after the thing it draws, in the vocabulary the UI uses', () => {
// A glyph whose name has to be *looked up* is a glyph nobody adds correctly. The
// list is the whole vocabulary, so adding an icon is a deliberate act rather than
// a free-form string that happens to compile.
const VOCABULARY = [
'add-circle', 'arrow-left', 'book', 'bookmark', 'check', 'chevron-left', 'chevron-right',
'clock', 'close', 'download', 'edit', 'eye', 'file-text', 'folder', 'indent',
'info', 'library', 'line-height', 'logout', 'menu', 'moon', 'more', 'pause',
'play', 'plus', 'refresh', 'search', 'settings', 'shelf', 'sign-out', 'sliders',
'sort', 'step-backward', 'step-forward', 'stop', 'sun', 'text-size', 'trash',
'tune', 'upload', 'volume', 'warning',
];
expect(Object.keys(GLYPHS).sort()).toEqual(VOCABULARY);
});
it('assigns code points in the same order as the source, starting at 0xE900', () => {
const names = Object.keys(GLYPHS);
names.forEach((name, index) => {
const expected = 0xe900 + index;
expect((ICON_CODEPOINT_TABLE as Record<string, string>)[name]!.codePointAt(0)).toBe(expected);
});
});
});
/**
* The font's vertical metrics.
*
* These are the numbers that decide whether an icon is *aligned*, and they are the ones
* this file did not check while the icons were half an em out of place: `cmap` was
* verified, the glyph names were verified, and the metrics — the only part a layout reads
* — were not.
*
* The contract has two halves and both are needed:
*
* - the ascent and descent must be equal, because a `1em` box is symmetric and every
* container in the UI centres a box. Asymmetric metrics make "centre the box" and
* "centre the glyph" two different operations, and the difference is exactly the
* asymmetry — which is how the icons were misaligned by a different amount in every
* row of the product.
* - no glyph's ink may leave that window. A glyph that reaches outside the em paints
* outside the box the layout aligned to, which is the same defect by another route,
* and it is invisible to every check that only reads a table.
*/
function fontTables() {
const font = readFileSync(join(process.cwd(), 'src/styles/reader-icons.ttf'));
const numTables = font.readUInt16BE(4);
const offsets: Record<string, number> = {};
for (let i = 0; i < numTables; i++) {
const entry = 12 + i * 16;
offsets[font.toString('ascii', entry, entry + 4)] = font.readUInt32BE(entry + 8);
}
const unitsPerEm = font.readUInt16BE(offsets.head + 18);
const numGlyphs = font.readUInt16BE(offsets.maxp + 4);
return {
font,
offsets,
unitsPerEm,
numGlyphs,
hheaAscent: font.readInt16BE(offsets.hhea + 4),
hheaDescent: font.readInt16BE(offsets.hhea + 6),
typoAscent: font.readInt16BE(offsets['OS/2'] + 68),
typoDescent: font.readInt16BE(offsets['OS/2'] + 70),
winAscent: font.readUInt16BE(offsets['OS/2'] + 74),
winDescent: font.readUInt16BE(offsets['OS/2'] + 76),
selection: font.readUInt16BE(offsets['OS/2'] + 62),
weightClass: font.readUInt16BE(offsets['OS/2'] + 4),
};
}
describe('the font metrics', () => {
it('declares a symmetric window, so a centred box is a centred glyph', () => {
const t = fontTables();
// The em's middle is the line the glyphs are drawn around, and the metrics have to
// say the same thing or every container that centres an icon centres it off by the
// difference. Zero would be symmetric too and is what the font used to declare by
// accident, which sends an engine to a fallback it has to guess at.
expect(t.hheaAscent).toBe(t.unitsPerEm / 2);
expect(t.hheaDescent).toBe(-t.unitsPerEm / 2);
expect(t.typoAscent).toBe(t.hheaAscent);
expect(t.typoDescent).toBe(t.hheaDescent);
});
it('declares Windows metrics that are unsigned and contain the em', () => {
const t = fontTables();
// These were written at the wrong offsets for as long as the font existed, so they
// read as 0 / 65506 (-30, read unsigned) — a window that does not contain the glyphs
// it describes. An engine that falls back to them computes a box instead of using one.
expect(t.winAscent).toBe(t.unitsPerEm);
expect(t.winDescent).toBe(t.unitsPerEm);
});
it('tells engines to use the typographic metrics, and that the face is regular', () => {
const t = fontTables();
// Without USE_TYPO_METRICS a Windows engine prefers the win pair, so getting the win
// metrics right above is only half of it.
expect(t.selection & 0x80).toBeTruthy();
expect(t.selection & 0x40).toBeTruthy(); // regular
expect(t.selection & 0x20).toBeFalsy(); // ... and not bold
expect(t.weightClass).toBe(400);
});
it('keeps every glyph inside the em the metrics declare', () => {
const t = fontTables();
// Walk `glyf` through `loca` and read each glyph's own bounding box — that box *is*
// the glyph's ink in its own coordinate system, so it is exactly what has to fit.
const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
for (let gid = 1; gid <= t.numGlyphs; gid++) {
if (offsets[gid + 1] === offsets[gid]) continue;
const start = t.offsets.glyf + offsets[gid];
const yMin = t.font.readInt16BE(start + 4);
const yMax = t.font.readInt16BE(start + 8);
expect(yMin, `glyph ${gid} reaches below the descender`).toBeGreaterThanOrEqual(-t.unitsPerEm / 2);
expect(yMax, `glyph ${gid} reaches above the ascender`).toBeLessThanOrEqual(t.unitsPerEm / 2);
}
});
it('gives every non-blank glyph a non-empty bounding box', () => {
const t = fontTables();
// An empty box is a glyph a rasteriser is entitled to skip, and `more` was one: three
// dots authored as 0.06-unit strokes rounded to a single point on each axis. The
// button drew blank while `cmap` still mapped its code point, so every table-reading
// assertion above was satisfied by a font that could not draw its own ellipsis.
const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
for (let gid = 1; gid <= t.numGlyphs; gid++) {
if (offsets[gid + 1] === offsets[gid]) continue;
const start = t.offsets.glyf + offsets[gid];
const numContours = t.font.readInt16BE(start);
if (numContours <= 0) continue;
expect(t.font.readInt16BE(start + 6) - t.font.readInt16BE(start + 2), `glyph ${gid} has no width`).toBeGreaterThan(0);
expect(t.font.readInt16BE(start + 8) - t.font.readInt16BE(start + 4), `glyph ${gid} has no height`).toBeGreaterThan(0);
}
const names = Object.keys(ICONS);
expect(names.filter(name => BORROWED.includes(name))).toEqual([]);
});
});
@@ -331,9 +42,7 @@ describe('the icon box', () => {
const host = document.createElement('div');
render(Icon({ name: 'close', label: '关闭' }), host);
const span = host.querySelector('.icon')!;
// `role="img"` is what makes a labelled span announce as an image rather than
// as a stray character, which is what a bare span containing a PUA code point
// would do.
// `role="img"` is what makes a labelled glyph announce as an image.
expect(span.getAttribute('role')).toBe('img');
expect(span.getAttribute('aria-label')).toBe('关闭');
expect(span.hasAttribute('aria-hidden')).toBe(false);
@@ -343,8 +52,7 @@ describe('the icon box', () => {
/**
* The stylesheet's half of the contract.
*
* The font can only be right; whether the icon ends up where the font says is decided in
* CSS. These read `.icon` out of the real stylesheet, because the rules that matter are
* Whether the icon ends up where it should is decided in CSS. These read `.icon` out of the real stylesheet, because the rules that matter are
* the ones that were wrong and a test that re-states them in its own words would have
* been updated alongside the bug.
*/
@@ -354,19 +62,8 @@ describe('the stylesheet', () => {
const declaration = (property: string): string | undefined =>
new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`).exec(iconRule)?.[1]?.trim();
it('lets the font own the line box instead of imposing a square on it', () => {
// `line-height: 1` gives the icon the font's own line box, which is symmetric because
// the font's metrics are. A fixed `height: 1em` plus `line-height: 0` anchors a box at
// the baseline while the ink is drawn around it, so the ink lands at the box's top
// edge and everything that centres the box centres the glyph half an em off — which is
// exactly what shipped, measured at 0.496em across `menu`, `close` and `folder`.
expect(declaration('line-height')).toBe('1');
expect(declaration('height')).toBeUndefined();
});
it('keeps an explicit advance width', () => {
// The one thing the layout needs that the font cannot supply: a row of icons and
// labels has to be laid out the same way whatever glyphs are in it.
// A row of icons and labels has to be laid out the same way whatever glyphs are in it.
expect(declaration('width')).toBe('1em');
});
-681
View File
@@ -1,681 +0,0 @@
/**
* Builds `reader-icons.ttf`.
*
* The icon set is authored as stroked paths (a 3-unit stroke on a 24-unit grid).
* A font renders *filled outlines*, so the stroke has to be expanded into a filled
* region first, and the expansion has to produce a real union: two overlapping bars
* that are merely adjacent outlines render with a hairline seam at every overlap,
* and at 16px that seam is the icon.
*
* The union is computed exactly (a polygon boolean), not by rasterising and tracing.
* The raster route needs a correct boundary walk, and a correct boundary walk on a
* one-cell-wide shape — which is what every stroke here is — is much harder than it
* looks: per-cell edge rules cannot express "the boundary alternates sides", and the
* failure is a glyph traced as dozens of fragments rather than as one outline.
* Exact booleans have no such case.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ALIASES, GLYPHS } from './paths.mjs';
import { pathToSubpaths } from './path.mjs';
import { segmentPolygon } from './stroke.mjs';
import { unionPolygons } from './union.mjs';
const GRID = 24;
const STROKE = 3;
const UPEM = 1024;
const SCALE = UPEM / GRID;
const FIRST_CODE = 0xe900;
/*
* The vertical metrics: a baseline through the middle of the em, and an ink extent that
* is inside it.
*
* The glyphs are drawn with `y = (GRID / 2 - y) * SCALE`, i.e. around the grid's
* midpoint, so the em's centre is the line the set is symmetric about. The metrics then
* have to say so: an ascent and descent of half the em each put the baseline exactly at
* that centre, and the ink — which runs to at most ±0.32em for every glyph in the set —
* lands inside it with room to spare.
*
* This is deliberately *not* the ink's own extent. Two reasons, and the second is the
* one that matters:
*
* - an asymmetric window (ascender 469, descender -640, say) is a true statement about
* the ink but a false one about the em, and it is the em that a 1em box is;
* - with an asymmetric window a box centred by flex or grid is centred on the *line*,
* not on the em, so the ink inside it is off-centre by exactly the asymmetry — the
* same defect this change exists to remove, only smaller. A symmetric window makes
* "centre the box" and "centre the glyph" the same operation, which is the only way
* an icon can be aligned by the same rules as everything else in the app.
*
* What went wrong without any of this. The tables declared an ascender of 960 and a
* descender of -64 — a window whose baseline sits near the *bottom* of the em — while the
* glyphs were drawn around the em's centre. Every glyph was therefore painted about
* 0.75em above the top of the box that contained it, and since every layout in the UI
* centres the box, every icon was 0.75em away from the thing it was aligned to. It looked
* like "the icons are not aligned" and it was, in fact, the font telling the truth about a
* baseline in one place and drawing to another.
*
* `INK` is kept and checked below rather than used as the metric: it is the property the
* symmetric window is *allowed* to have, so it belongs in a test, not in the font.
*/
function inkExtent(paths) {
let top = -Infinity;
let bottom = Infinity;
for (const path of Object.values(paths)) {
for (const [, y] of pathToSubpaths(path).flat()) {
// The authored path is the stroke's centre line; the ink reaches half a stroke
// beyond it on both sides. The sign follows the draw code's flip exactly: this
// function and `outlineFor` must agree about which way is up, or the check below
// is checking a different font than the one that gets written.
top = Math.max(top, GRID / 2 - (y - STROKE / 2));
bottom = Math.min(bottom, GRID / 2 - (y + STROKE / 2));
}
}
return { top, bottom };
}
const INK = inkExtent(GLYPHS);
const ASCENT = UPEM / 2;
const DESCENT = -UPEM / 2;
/*
* The set has to fit inside the window the metrics declare, and this is where that is
* established rather than assumed: the build fails if a glyph reaches outside, because a
* glyph that escapes the em is a glyph that paints outside the box every layout in the UI
* aligns to — which is precisely the bug this file's metrics exist to prevent.
*/
const HALF_EM = GRID / 2;
if (INK.top > HALF_EM || INK.bottom < -HALF_EM) {
throw new Error(
`the icon set reaches ${INK.top.toFixed(2)}..${INK.bottom.toFixed(2)} grid units, ` +
`outside the ±${HALF_EM} the metrics declare; move the glyph or the em`,
);
}
/**
* Stroked path -> font-unit rings, with each ring's nesting depth.
*
* Depth comes from the union rather than being re-derived, because the union already
* knows: a counter such as the eye of a magnifier is a hole, and a hole wound the
* same way as its parent renders solid. Re-deriving it by point-in-ring tests is
* possible but redundant, and it has an edge case this avoids — a ring whose first
* vertex happens to lie on another ring.
*/
function outlineFor(path) {
const pieces = [];
for (const subpath of pathToSubpaths(path)) {
for (let i = 0; i < subpath.length - 1; i++) {
const [x1, y1] = subpath[i];
const [x2, y2] = subpath[i + 1];
const ring = segmentPolygon(x1, y1, x2, y2, STROKE);
if (ring) pieces.push(ring);
}
}
if (!pieces.length) return [];
return unionPolygons(pieces)
.map(({ ring, depth }) => {
/*
* Grid units -> font units, with y flipped: the paths are authored with y
* pointing down (what every icon set uses) and a font's y axis points up.
*
* The flip is about the grid's *midpoint* rather than its bottom edge, so the
* em's centre is the y=0 line the glyphs are laid out around and the ink of a
* glyph drawn on rows 3..21 lands symmetrically on the em. Flipping about the
* bottom edge (which is what `GRID - y` does) leaves every glyph in the top
* half of the em: correct for a font whose baseline is at the bottom of a
* box of text, wrong for an icon whose box *is* its em.
*
* Winding follows depth: an outer ring is clockwise and a hole is
* counter-clockwise. TrueType has no even-odd fill, so getting this backwards
* fills in every counter.
*/
const scaled = simplify(
ring.map(([x, y]) => [x * SCALE, (GRID / 2 - y) * SCALE]),
SCALE * 0.02,
);
const clockwise = signedArea(scaled) < 0;
const wantsClockwise = depth % 2 === 0;
return clockwise === wantsClockwise ? scaled : [...scaled].reverse();
})
.filter((ring) => ring.length >= 3);
}
/** Twice the signed area; the sign is the winding. */
function signedArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[(i + 1) % ring.length];
sum += x1 * y2 - x2 * y1;
}
return sum / 2;
}
/* ---- simplification ---- */
/**
* Collapses near-collinear runs.
*
* The tolerance is 2% of a grid unit: the stroke expansion samples its rounded
* corners at a fixed density, and without this the caps alone contribute more
* vertices than the rest of the glyph. It is deliberately far below the ~0.05 unit
* that a visible facet would need.
*/
function simplify(points, epsilon) {
if (points.length < 4) return points;
const keep = new Uint8Array(points.length);
keep[0] = 1;
keep[points.length - 1] = 1;
const stack = [[0, points.length - 1]];
while (stack.length) {
const [first, last] = stack.pop();
let index = -1;
let worst = 0;
for (let i = first + 1; i < last; i++) {
const distance = distanceToSegment(points[i], points[first], points[last]);
if (distance > worst) {
worst = distance;
index = i;
}
}
if (index > 0 && worst > epsilon) {
keep[index] = 1;
stack.push([first, index], [index, last]);
}
}
return points.filter((_, i) => keep[i] === 1);
}
function distanceToSegment(point, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const length = Math.hypot(dx, dy);
if (!length) return Math.hypot(point[0] - a[0], point[1] - a[1]);
return Math.abs((point[0] - a[0]) * dy - (point[1] - a[1]) * dx) / length;
}
/* ---- TTF ---- */
function buildFont(glyphs) {
const numGlyphs = glyphs.length + 1;
const glyf = [];
const loca = [0];
let glyfLength = 0;
for (const glyph of [null, ...glyphs]) {
const data = glyph ? encodeGlyph(glyph.rings) : new Uint8Array(0);
glyf.push(data);
glyfLength += data.length;
loca.push(glyfLength);
}
const maxPoints = Math.max(...[null, ...glyphs].map((g) => (g ? Math.max(...g.rings.map((r) => r.length)) : 0)));
const maxContours = Math.max(...[null, ...glyphs].map((g) => (g ? g.rings.length : 0)));
const tables = {
head: buildHead(glyphs),
hhea: buildHhea(numGlyphs),
maxp: buildMaxp(numGlyphs, maxPoints, maxContours),
'OS/2': buildOs2(glyphs),
hmtx: buildHmtx(glyphs, numGlyphs),
cmap: buildCmap(glyphs),
loca: buildLoca(loca),
glyf: concatenate(glyf),
name: buildName(),
post: buildPost(),
};
return assemble(['head', 'hhea', 'maxp', 'OS/2', 'hmtx', 'cmap', 'loca', 'glyf', 'name', 'post'], tables);
}
/**
* One simple glyph, uncompressed.
*
* Field order is the spec's and is not the order the data is easiest to reason
* about: endPtsOfContours, instructionLength, then *all* the flags, then all the x
* deltas, then all the y deltas. Writing x/y before the flags gives a glyph whose
* flags are read from coordinate bytes, and the parser's complaint is only
* "not enough glyf table data".
*/
function encodeGlyph(rings) {
const points = rings.flat();
const endPoints = [];
let end = -1;
for (const ring of rings) {
end += ring.length;
endPoints.push(end);
}
let size = 10 + rings.length * 2 + 2 + points.length * 5;
if (size % 4) size += 4 - (size % 4);
const view = new DataView(new ArrayBuffer(size));
const xs = points.map((p) => Math.round(p[0]));
const ys = points.map((p) => Math.round(p[1]));
/*
* The per-glyph bounding box, with a one-unit guard on every edge.
*
* A glyph whose box is empty in either axis is a glyph a rasteriser is entitled to
* skip — and `more` is exactly that: three dots authored as 0.06-unit strokes,
* which round to a single point on each axis, so the box this used to write was
* `xMin == xMax` and `yMin == yMax`. Nothing renders an empty box, so the more
* button drew a blank — while `cmap` still mapped its code point, so the test that
* reads the font's tables was perfectly happy about it.
*
* The guard is the minimum extent rather than a special case for that one glyph:
* any future glyph that collapses under rounding is covered by the same rule, and
* one font unit at UPEM 1024 is 0.1% of the em, which cannot move the box visibly.
*/
const xMin = Math.min(...xs);
const yMin = Math.min(...ys);
const xMax = Math.max(...xs);
const yMax = Math.max(...ys);
view.setInt16(0, rings.length);
view.setInt16(2, xMin);
view.setInt16(4, yMin);
view.setInt16(6, xMax === xMin ? xMin + 1 : xMax);
view.setInt16(8, yMax === yMin ? yMin + 1 : yMax);
let offset = 10;
for (const endPoint of endPoints) {
view.setUint16(offset, endPoint);
offset += 2;
}
view.setUint16(offset, 0);
offset += 2;
for (let i = 0; i < points.length; i++) view.setUint8(offset + i, 0x01); // on-curve
offset += points.length;
let previous = 0;
for (const x of xs) {
view.setInt16(offset, x - previous);
previous = x;
offset += 2;
}
previous = 0;
for (const y of ys) {
view.setInt16(offset, y - previous);
previous = y;
offset += 2;
}
return new Uint8Array(view.buffer);
}
function buildLoca(offsets) {
const view = new DataView(new ArrayBuffer(offsets.length * 4));
offsets.forEach((value, i) => view.setUint32(i * 4, value));
return new Uint8Array(view.buffer);
}
function buildHead(glyphs) {
const view = new DataView(new ArrayBuffer(54));
const xs = glyphs.flatMap((g) => g.rings.flat().map((p) => p[0]));
const ys = glyphs.flatMap((g) => g.rings.flat().map((p) => p[1]));
view.setUint32(0, 0x00010000);
view.setUint32(4, 0x00010000);
view.setUint32(12, 0x5f0f3cf5);
view.setUint16(16, 0x000b);
view.setUint16(18, UPEM);
const now = BigInt(Math.floor(Date.now() / 1000) + 2082844800);
view.setBigInt64(20, now);
view.setBigInt64(28, now);
view.setInt16(36, Math.round(Math.min(...xs)));
view.setInt16(38, Math.round(Math.min(...ys)));
view.setInt16(40, Math.round(Math.max(...xs)));
view.setInt16(42, Math.round(Math.max(...ys)));
// macStyle stays 0 (regular / non-italic): the face has one weight and no
// oblique, and a font that claims otherwise gets synthesised.
view.setUint16(46, 8);
view.setInt16(48, 2);
// indexToLocFormat is at byte 50. At 48 sits fontDirectionHint, and writing the
// loca format there leaves every parser reading the short format.
view.setInt16(50, 1);
return new Uint8Array(view.buffer);
}
function buildHhea(numGlyphs) {
const view = new DataView(new ArrayBuffer(36));
view.setUint32(0, 0x00010000);
view.setInt16(4, ASCENT);
view.setInt16(6, DESCENT);
view.setUint16(10, 1024);
view.setInt16(16, 1024);
view.setInt16(22, 1024);
view.setInt16(24, 1);
view.setUint16(34, numGlyphs);
return new Uint8Array(view.buffer);
}
function buildMaxp(numGlyphs, maxPoints, maxContours) {
const view = new DataView(new ArrayBuffer(32));
view.setUint32(0, 0x00010000);
view.setUint16(4, numGlyphs);
view.setUint16(6, maxPoints);
view.setUint16(8, maxContours);
view.setUint16(14, 2);
return new Uint8Array(view.buffer);
}
function buildHmtx(glyphs, numGlyphs) {
const view = new DataView(new ArrayBuffer(numGlyphs * 4));
for (let i = 0; i < numGlyphs; i++) {
view.setUint16(i * 4, UPEM);
const bearing = i === 0 ? 0 : Math.min(...glyphs[i - 1].rings.flat().map((p) => p[0]));
view.setInt16(i * 4 + 2, Math.round(bearing));
}
return new Uint8Array(view.buffer);
}
/**
* Format 4 cmap: one single-code segment per icon, plus the terminal 0xFFFF segment.
*
* The terminal segment must start *and* end at 0xFFFF and map nothing. Writing
* `start = 0xFFFE` makes every code below it resolve to an icon, and `idDelta` must
* be computed per segment from that segment's own code — a shared delta maps ASCII
* onto the set.
*/
function buildCmap(glyphs) {
const codes = glyphs.map((g) => g.code).sort((a, b) => a - b);
const segments = codes.length + 1;
const subLength = 16 + segments * 8;
const sub = new DataView(new ArrayBuffer(subLength));
const segX2 = segments * 2;
const searchRange = 2 * 2 ** Math.floor(Math.log2(segments));
sub.setUint16(0, 4);
sub.setUint16(2, subLength);
sub.setUint16(6, segX2);
sub.setUint16(8, searchRange);
sub.setUint16(10, Math.log2(searchRange / 2));
sub.setUint16(12, segX2 - searchRange);
let offset = 14;
for (const code of [...codes, 0xffff]) {
sub.setUint16(offset, code);
offset += 2;
}
sub.setUint16(offset, 0);
offset += 2;
for (const code of [...codes, 0xffff]) {
sub.setUint16(offset, code);
offset += 2;
}
const deltas = codes.map((code, index) => {
const delta = (index + 1 - code) & 0xffff;
return delta > 0x7fff ? delta - 0x10000 : delta;
});
deltas.push(1);
for (const delta of deltas) {
sub.setInt16(offset, delta);
offset += 2;
}
for (let i = 0; i < segments; i++) {
sub.setUint16(offset, 0);
offset += 2;
}
const table = new DataView(new ArrayBuffer(4 + 8 + subLength));
table.setUint16(0, 0);
table.setUint16(2, 1);
table.setUint16(4, 3);
table.setUint16(6, 1);
table.setUint32(8, 12);
new Uint8Array(table.buffer).set(new Uint8Array(sub.buffer), 12);
return new Uint8Array(table.buffer);
}
function buildName() {
const records = [
[1, 'Reader Icons'],
[2, 'Regular'],
[3, 'reader-icons'],
[4, 'Reader Icons'],
[5, 'Version 1.0'],
[6, 'reader-icons'],
];
const strings = records.map(([, value]) => {
const bytes = new Uint8Array(value.length * 2);
for (let i = 0; i < value.length; i++) bytes[i * 2 + 1] = value.charCodeAt(i);
return bytes;
});
const storage = concatenate(strings);
const header = 6 + records.length * 12;
const view = new DataView(new ArrayBuffer(header + storage.length));
view.setUint16(0, 0);
view.setUint16(2, records.length);
view.setUint16(4, header);
let offset = 0;
records.forEach(([id], i) => {
view.setUint16(6 + i * 12, 3);
view.setUint16(8 + i * 12, 1);
view.setUint16(10 + i * 12, 0x409);
view.setUint16(12 + i * 12, id);
view.setUint16(14 + i * 12, strings[i].length);
view.setUint16(16 + i * 12, offset);
offset += strings[i].length;
});
new Uint8Array(view.buffer).set(storage, header);
return new Uint8Array(view.buffer);
}
function buildPost() {
const view = new DataView(new ArrayBuffer(32));
view.setUint32(0, 0x00030000);
view.setInt16(8, -100);
view.setInt16(10, 50);
return new Uint8Array(view.buffer);
}
function buildOs2(glyphs) {
const view = new DataView(new ArrayBuffer(96));
const xs = glyphs.flatMap((g) => g.rings.flat().map((p) => p[0]));
const ys = glyphs.flatMap((g) => g.rings.flat().map((p) => p[1]));
const minX = Math.round(Math.min(...xs));
const maxX = Math.round(Math.max(...xs));
const minY = Math.round(Math.min(...ys));
const maxY = Math.round(Math.max(...ys));
const codes = glyphs.map((g) => g.code);
view.setUint16(0, 4);
/*
* usWeightClass = 400 and fsSelection's REGULAR bit.
*
* These were `500` and `BOLD`, the two values a font that is *not* bold must not
* carry: a browser that believes a face is missing synthesises the weight it was
* asked for, and a synthesised icon is a smeared icon — exactly what
* `font-synthesis: none` on `.icon` exists to prevent, and it can only prevent it
* if the font does not lie about itself first.
*/
view.setInt16(2, 500);
view.setUint16(4, 400);
view.setUint16(6, 5);
view.setInt16(10, minY);
view.setInt16(12, maxY);
view.setInt16(14, minY);
view.setInt16(16, maxY);
view.setUint16(18, 700);
view.setUint16(20, 100);
/*
* The typographic window. Every offset below is spelled per the `OS/2` table's
* layout, because a font builder is exactly the place where an off-by-two field
* still parses, still loads, and merely lays text out wrong.
*
* `sTypoAscender`/`sTypoDescender`/`sTypoLineGap` at 68/70/72: they used to be
* written at 76/78 as `960 / -64`, which is the *panose* area — so panose took the
* `0x03C0 / 0xFFC0` bytes, the typographic window stayed `0/0`, and browsers fell
* back to guessing at the line box. A guess that is 25% taller than the glyph is
* what "the icons don't line up" looks like on screen.
*
* `usWinAscent`/`usWinDescent` at 74/76 are *unsigned* and must enclose every
* glyph's ink, i.e. the em square, not the -64 descender that was written there.
*/
view.setUint16(30, UPEM);
view.setUint16(32, UPEM);
view.setUint16(44, Math.min(...codes));
view.setUint16(46, Math.max(...codes));
view.setInt16(68, ASCENT);
view.setInt16(70, DESCENT);
view.setInt16(72, 0);
/*
* fsSelection: REGULAR (bit 6) and USE_TYPO_METRICS (bit 7).
*
* `40` claimed BOLD|REGULAR for a face that is neither, and USE_TYPO_METRICS is the
* one that actually fixes the fallback: it tells the engine to lay the line out
* from the typographic metrics just written instead of the win pair, which on
* Windows is the difference between "the icon font is 2% taller than the text" and
* "the icon font is 25% taller than the text".
*/
view.setUint16(62, 0x40 | 0x80);
view.setUint16(64, 1);
/*
* The Windows metrics, which are *unsigned* and must enclose every glyph's ink.
*
* They were written at 74/76 as `960 / -64`, and 74/76 is not where they live: 74
* and 76 are the em square, so winAscent stayed 0 and winDescent became 65506 —
* i.e. -30 read as unsigned. A font whose win metrics do not contain its own
* glyphs gets a bounding box computed for it, which is the other half of "the icon
* row is taller than it looks like it should be".
*/
view.setUint16(74, UPEM);
view.setUint16(76, UPEM);
view.setInt16(78, minX);
view.setInt16(80, maxX);
view.setInt16(82, minY);
view.setInt16(84, maxY);
/*
* `sxHeight` (86) and `sCapHeight` (88) are version-2 fields and must *not* be
* written into an `OS/2` that declares version 4 while its box positions are off by
* one field: they were landing on usDefaultChar/usBreakChar/usMaxContext, which is
* how a font ends up telling a text engine that its x-height is 0.
*/
view.setUint16(84, 1);
view.setInt16(88, 1);
return new Uint8Array(view.buffer);
}
function concatenate(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}
function pad4(bytes) {
const remainder = bytes.length % 4;
if (!remainder) return bytes;
const out = new Uint8Array(bytes.length + (4 - remainder));
out.set(bytes);
return out;
}
function checksum(bytes) {
let sum = 0;
const padded = pad4(bytes);
for (let i = 0; i < padded.length; i += 4) {
sum = (sum + new DataView(padded.buffer, padded.byteOffset + i, 4).getUint32(0)) >>> 0;
}
return sum;
}
function assemble(order, tables) {
const numTables = order.length;
const searchRange = 2 ** Math.floor(Math.log2(numTables)) * 16;
const entrySelector = Math.floor(Math.log2(numTables));
const rangeShift = numTables * 16 - searchRange;
let offset = 12 + numTables * 16;
const entries = [];
const bodies = [];
for (const tag of order) {
const body = pad4(tables[tag]);
entries.push({ tag, offset, length: tables[tag].length, checksum: checksum(tables[tag]) });
bodies.push(body);
offset += body.length;
}
const header = new DataView(new ArrayBuffer(12));
header.setUint32(0, 0x00010000);
header.setUint16(4, numTables);
header.setUint16(6, searchRange);
header.setUint16(8, entrySelector);
header.setUint16(10, rangeShift);
const directory = new DataView(new ArrayBuffer(numTables * 16));
entries
.sort((a, b) => (a.tag < b.tag ? -1 : 1))
.forEach((entry, i) => {
directory.setUint32(i * 16, [...entry.tag].reduce((acc, ch) => (acc << 8) | ch.charCodeAt(0), 0));
directory.setUint32(i * 16 + 4, entry.checksum);
directory.setUint32(i * 16 + 8, entry.offset);
directory.setUint32(i * 16 + 12, entry.length);
});
return concatenate([new Uint8Array(header.buffer), new Uint8Array(directory.buffer), ...bodies]);
}
/* ---- run ---- */
const glyphs = [];
let code = FIRST_CODE;
let totalPoints = 0;
for (const [name, path] of Object.entries(GLYPHS)) {
const rings = outlineFor(path);
const points = rings.reduce((n, r) => n + r.length, 0);
totalPoints += points;
glyphs.push({ name, code, rings });
console.log(`${name.padEnd(14)} rings=${String(rings.length).padStart(2)} points=${String(points).padStart(4)}`);
code += 1;
}
const here = dirname(fileURLToPath(import.meta.url));
const stylesDir = join(here, '..', '..', 'src', 'styles');
mkdirSync(stylesDir, { recursive: true });
const font = buildFont(glyphs);
writeFileSync(join(stylesDir, 'reader-icons.ttf'), font);
/*
* The code point table is generated rather than hand-written because the code
* points are an encoding detail: they have to match the `cmap` in the font
* exactly, and a table maintained by hand drifts the first time an icon is added
* in the middle of the set.
*/
const table = [
'/**',
' * The icon font\'s code points.',
' *',
' * Generated by `tools/icons/build.mjs` from the same data that builds',
' * `reader-icons.ttf`. Do not edit by hand: a code point that disagrees with the',
' * font\'s `cmap` renders as a missing-glyph box, and nothing in the type system',
' * can catch it.',
' */',
'export const ICON_CODEPOINTS = {',
...glyphs.map((glyph) => ` '${glyph.name}': '\\u${glyph.code.toString(16)}',`),
'} as const;',
'',
'/** Code points kept as aliases, the way the two spellings of 退出 coexist. */',
'export const ICON_ALIASES = {',
...Object.entries(ALIASES).map(([alias, target]) => {
const glyph = glyphs.find((candidate) => candidate.name === target);
if (!glyph) throw new Error(`alias ${alias} points at missing glyph ${target}`);
return ` '${alias}': '\\u${glyph.code.toString(16)}',`;
}),
'} as const;',
'',
'export const ICON_CODEPOINT_TABLE = {',
' ...ICON_CODEPOINTS,',
' ...ICON_ALIASES,',
'} as const;',
'',
'export type IconName = keyof typeof ICON_CODEPOINT_TABLE;',
'',
].join('\n');
writeFileSync(join(here, '..', '..', 'src', 'ui', 'icon-names.ts'), table);
console.log(`\n${glyphs.length} glyphs, ${totalPoints} points, ${font.length} bytes`);
-8
View File
@@ -1,8 +0,0 @@
/**
* Declarations for `path.mjs`, following the same convention as `paths.d.mts`:
* the build scripts stay plain JavaScript and this states the shape the test
* consumes.
*/
/** Flattens an SVG path string into polylines of `[x, y]` points. */
export function pathToSubpaths(d: string): Array<Array<[number, number]>>;
-192
View File
@@ -1,192 +0,0 @@
/**
* A minimal SVG path -> polyline converter.
*
* Only the commands this icon set actually uses: absolute and relative M, L, H, V,
* C, Q, A and Z. Curves are flattened into short chords, because what happens next
* is stroke expansion — which is a per-segment operation on straight edges, and
* which cannot be applied to a curve without first deciding what "the stroke width
* of a curve" means.
*/
const TOKEN = /([MmLlHhVvCcSsQqTtAaZz])|(-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)/g;
export function pathToSubpaths(d) {
const tokens = [];
for (const match of d.matchAll(TOKEN)) tokens.push(match[1] ?? match[2]);
const subpaths = [];
let current = null;
let cursor = [0, 0];
let start = [0, 0];
let command = '';
let index = 0;
const flush = () => {
if (current && current.length >= 2) subpaths.push(current);
current = null;
};
const next = () => Number(tokens[index++]);
const add = (x, y) => {
if (!current) current = [];
current.push([x, y]);
};
while (index < tokens.length) {
if (/[A-Za-z]/.test(tokens[index])) command = tokens[index++];
const relative = command === command.toLowerCase();
const upper = command.toUpperCase();
const ox = relative ? cursor[0] : 0;
const oy = relative ? cursor[1] : 0;
switch (upper) {
case 'M': {
flush();
const x = next() + ox;
const y = next() + oy;
cursor = [x, y];
start = [x, y];
add(x, y);
// Extra coordinate pairs after `M` are implicit `L`s.
command = relative ? 'l' : 'L';
break;
}
case 'L': {
const x = next() + ox;
const y = next() + oy;
// A `Z`-terminated subpath repeats its first point; that repeat is a
// zero-length segment and only confuses the stroke expansion.
if (current && current.length && x === cursor[0] && y === cursor[1]) {
index -= 0;
} else {
add(x, y);
}
cursor = [x, y];
break;
}
case 'H': {
const x = next() + ox;
add(x, cursor[1]);
cursor = [x, cursor[1]];
break;
}
case 'V': {
const y = next() + oy;
add(cursor[0], y);
cursor = [cursor[0], y];
break;
}
case 'C': {
const points = [next() + ox, next() + oy, next() + ox, next() + oy, next() + ox, next() + oy];
flattenCubic(current ?? [], cursor,
[points[0], points[1]], [points[2], points[3]], [points[4], points[5]]);
cursor = [points[4], points[5]];
break;
}
case 'Q': {
const points = [next() + ox, next() + oy, next() + ox, next() + oy];
flattenQuadratic(current ?? [], cursor, [points[0], points[1]], [points[2], points[3]]);
cursor = [points[2], points[3]];
break;
}
case 'A': {
const rx = next();
const ry = next();
const rotation = next();
const large = next();
const sweep = next();
const x = next() + ox;
const y = next() + oy;
for (const point of flattenArc(cursor, [x, y], rx, ry, rotation, large, sweep)) {
add(point[0], point[1]);
}
cursor = [x, y];
break;
}
case 'Z': {
flush();
cursor = start;
command = '';
break;
}
default:
index += 1;
}
}
flush();
return subpaths;
}
function flattenCubic(list, [x0, y0], [x1, y1], [x2, y2], [x3, y3]) {
// Step count from the control polygon's length: a short curve does not need 16
// chords, and a long one needs more than 8.
const approx = Math.hypot(x1 - x0, y1 - y0) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x3 - x2, y3 - y2);
const steps = Math.max(4, Math.min(24, Math.ceil(approx / 1.5)));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const mt = 1 - t;
list.push([
mt ** 3 * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t ** 3 * x3,
mt ** 3 * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t ** 3 * y3,
]);
}
}
function flattenQuadratic(list, [x0, y0], [x1, y1], [x2, y2]) {
const approx = Math.hypot(x1 - x0, y1 - y0) + Math.hypot(x2 - x1, y2 - y1);
const steps = Math.max(3, Math.min(16, Math.ceil(approx / 1.5)));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const mt = 1 - t;
list.push([
mt * mt * x0 + 2 * mt * t * x1 + t * t * x2,
mt * mt * y0 + 2 * mt * t * y1 + t * t * y2,
]);
}
}
/** SVG elliptical-arc endpoint parameterisation, sampled. */
function flattenArc([x1, y1], [x2, y2], rx, ry, rotation, largeArc, sweep) {
if (rx === 0 || ry === 0) return [[x2, y2]];
const phi = (rotation * Math.PI) / 180;
const cos = Math.cos(phi);
const sin = Math.sin(phi);
const dx = (x1 - x2) / 2;
const dy = (y1 - y2) / 2;
const px = cos * dx + sin * dy;
const py = -sin * dx + cos * dy;
const lambda = (px * px) / (rx * rx) + (py * py) / (ry * ry);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
rx *= scale;
ry *= scale;
}
const numerator = rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px;
const denominator = rx * rx * py * py + ry * ry * px * px;
const coefficient = (largeArc !== sweep ? 1 : -1) * Math.sqrt(Math.max(0, numerator / denominator));
const cxp = (coefficient * rx * py) / ry;
const cyp = (-coefficient * ry * px) / rx;
const cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
const cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
const angle = (ux, uy, vx, vy) => {
const dot = ux * vx + uy * vy;
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1;
const value = Math.acos(Math.min(1, Math.max(-1, dot / len)));
return ux * vy - uy * vx < 0 ? -value : value;
};
const startAngle = angle(1, 0, (px - cxp) / rx, (py - cyp) / ry);
let delta = angle((px - cxp) / rx, (py - cyp) / ry, (-px - cxp) / rx, (-py - cyp) / ry);
if (delta < 0 && sweep) delta += 2 * Math.PI;
if (delta > 0 && !sweep) delta -= 2 * Math.PI;
const steps = Math.max(8, Math.ceil((Math.abs(delta) * Math.max(rx, ry)) / 0.8));
const points = [];
for (let s = 1; s <= steps; s++) {
const t = startAngle + (delta * s) / steps;
points.push([
cos * rx * Math.cos(t) - sin * ry * Math.sin(t) + cx,
sin * rx * Math.cos(t) + cos * ry * Math.sin(t) + cy,
]);
}
return points;
}
-8
View File
@@ -1,8 +0,0 @@
/**
* Types for the icon source paths.
*
* The generator is plain JavaScript on purpose — it runs under `node` directly, with
* no build step, so that regenerating the font needs nothing but `npm install`. This
* declaration is what lets the test suite import the same data with a type.
*/
export declare const GLYPHS: Record<string, string>;
-100
View File
@@ -1,100 +0,0 @@
/**
* The icon set.
*
* Every path is drawn on a 24x24 grid with a 3-unit stroke, so all icons share one
* optical weight and one set of implicit margins. They are deliberately geometric
* rather than hand-drawn: a set that is generated from the same stroke expansion is
* the only way to guarantee that, and the guarantee is the reason the set exists —
* mixing a font from one family with glyphs from another is what makes a UI look
* assembled rather than designed.
*
* ## The names are the product's, and they are ours
*
* Every key is an English noun for the *thing the reader sees*, borrowed from the
* vocabulary that is already all over this codebase and the web at large: `menu`,
* `search`, `close`, `settings`, `library`, `shelf`. Nothing here is invented for the
* sake of being different — 菜单 is `menu`, not `hamburger-glyph`, and anyone who has
* written a web UI can guess what `arrow-left` draws.
*
* The set used to borrow the **Font Awesome 6** names verbatim (`bars`,
* `magnifying-glass`, `xmark`, `gear`, `table-columns`, `arrows-rotate`, …). It was
* reverted, and the reason is worth keeping: a borrowed name is a **claim about
* another product's artwork**. `magnifying-glass` is also the name of a Font Awesome
* glyph, so a reader who knows that set opens this file expecting the glyph they know
* and finds a different one — every time, for every borrowed name. The vocabulary was
* supposed to stop "the magnifier has three names"; what it actually did was make the
* one name untrustworthy. The geometry was never Font Awesome's, so the names should
* not have been either. See `docs/ui.md` §1.2.
*
* ## What the names have to satisfy
*
* - **one glyph per idea**, so a control and its label and its test cannot drift onto
* different shapes;
* - **the name says what it draws**, because that is the only thing that makes a
* name better than a code point;
* - **no two glyphs share a path**, which `test/icons.test.ts` pins by comparing the
* flattened geometry rather than the strings.
*
* Only absolute/relative M, L, H, V, C, Q, A and Z are used; see `path.mjs`.
*/
export const GLYPHS = {
'menu': 'M4 7h16M4 12h16M4 17h16',
'chevron-left': 'M14.5 5.5L8 12l6.5 6.5',
'chevron-right': 'M9.5 5.5L16 12l-6.5 6.5',
'arrow-left': 'M10.5 5.5L4 12l6.5 6.5M4 12h16',
'folder': 'M4 6.5h5l1.8 2H20V19H4z',
'file-text': 'M7 3.5h6.5L18 8v12.5H7zM13.5 3.5V8H18M10 12h5M10 15.5h5',
'sliders': 'M5 8.5h14M5 15.5h14M9.5 5.5v6M14.5 12.5v6',
'search': 'M11 16.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM15 15l4.5 4.5',
'close': 'M6.5 6.5l11 11M17.5 6.5l-11 11',
'plus': 'M12 5v14M5 12h14',
'check': 'M5.5 12.5l4.5 4.5L18.5 7',
'upload': 'M12 20V6.5M7.5 11L12 6.5l4.5 4.5M5 4h14',
'trash': 'M5 7h14M9.5 7V4h5v3M7 7l1 13h8l1-13M11 10.5v6M13 10.5v6',
'download': 'M12 4v13.5M7.5 13l4.5 4.5 4.5-4.5M5 20h14',
'refresh': 'M19 12a7 7 0 1 1-2.3-5.2M19 3.5v4.2h-4.2',
'book': 'M12 7.2c-1.4-1.2-3.2-1.9-5.4-1.9H4.8v12.4h1.8c2.2 0 4 .7 5.4 1.9M12 7.2c1.4-1.2 3.2-1.9 5.4-1.9h1.8v12.4h-1.8c-2.2 0-4 .7-5.4 1.9M12 7.2v12.4',
'shelf': 'M4 5.5h16M4 18.5h16M8 5.5v13M16 5.5v13',
'play': 'M8 5l11 7-11 7z',
'pause': 'M9.5 5.5v13M14.5 5.5v13',
'step-backward': 'M17.5 6v12l-8-6zM6.5 6v12',
'step-forward': 'M6.5 6v12l8-6zM17.5 6v12',
'stop': 'M7 7h10v10H7z',
'volume': 'M4 10v4h3l4.5 3.5V6.5L7 10zM15.5 9.8a3 3 0 0 1 0 4.4M18.4 7.4a6.2 6.2 0 0 1 0 9.2',
'moon': 'M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5z',
'sun': 'M12 16.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM12 3v2.2M12 18.8V21M3 12h2.2M18.8 12H21M5.6 5.6l1.6 1.6M16.8 16.8l1.6 1.6M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6',
'eye': 'M2.5 12S6 6.8 12 6.8 21.5 12 21.5 12 18 17.2 12 17.2 2.5 12 2.5 12zM12 14.6a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2z',
'info': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v6M12 7.6v.4',
'warning': 'M12 4l9 16H3zM12 10v4M12 17v.4',
'clock': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7.5V12l3 2',
'more': 'M6.2 12h.06M12 12h.06M17.8 12h.06',
'edit': 'M4 20h4L20 8l-4-4L4 16zM14 6l4 4',
'sort': 'M7 5v14M4 16l3 3 3-3M13 6h7M13 11h5M13 16h3',
'logout': 'M12 3v8M7.5 6.5a7 7 0 1 0 9 0',
'sign-out': 'M14 5H6v14h8M11 12h10M18 9l3 3-3 3',
'add-circle': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 8.5v7M8.5 12h7',
'indent': 'M4 5h16M8 10h12M8 14h12M4 10v4M4 20h16',
'text-size': 'M5 6V4.5h14V6M12 4.5V19M9.5 19h5',
'line-height': 'M4 4h16M4 20h16M12 7v10M9.5 9.5L12 7l2.5 2.5M9.5 14.5L12 17l2.5-2.5',
'settings': 'M12 15.4a3.4 3.4 0 1 0 0-6.8 3.4 3.4 0 0 0 0 6.8zM19.6 12a7.6 7.6 0 0 0-.1-1.2l2-1.5-2-3.4-2.4 1a7.6 7.6 0 0 0-2.1-1.2L14.6 3H9.4L9 5.7a7.6 7.6 0 0 0-2.1 1.2l-2.4-1-2 3.4 2 1.5a7.6 7.6 0 0 0 0 2.4l-2 1.5 2 3.4 2.4-1a7.6 7.6 0 0 0 2.1 1.2l.4 2.7h5.2l.4-2.7a7.6 7.6 0 0 0 2.1-1.2l2.4 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z',
'library': 'M4.5 5.2h3.4v14H4.5zM10.3 5.2h3.4v14h-3.4zM16.6 5.8l3.2.7-2.9 13.3-3.2-.7z',
'tune': 'M4 8.5h9M17 8.5h3M4 15.5h3M11 15.5h9M15 6.5v4M9 13.5v4',
'bookmark': 'M6.5 3.5h11v17L12 16l-5.5 4.5z',
};
/**
* Old spellings kept as aliases of a glyph above (the way the two spellings of 退出
* coexist), so code written against them keeps working. They are not glyphs: each
* shares its target's code point, and no new artwork may be added under one.
*/
export const ALIASES = {
'font': 'text-size',
'volume-high': 'volume',
'backward-step': 'step-backward',
'forward-step': 'step-forward',
'bars': 'menu',
'gear': 'settings',
'xmark': 'close',
'magnifying-glass': 'search',
'books': 'library',
};
-72
View File
@@ -1,72 +0,0 @@
/**
* Stroke expansion.
*
* The set is authored as stroked polylines, and a font renders filled outlines. The
* bridge is to give each segment its own closed ring — a rectangle with a
* semicircular cap at each end — and let a boolean union merge the rings.
*
* Rounding both ends the same way is what makes a polyline read as one continuous
* stroke: square ends leave the two halves of an arc visibly separate, and mitred
* joins put a spike on the outside of every bend at small sizes.
*
* The winding is the part that is easy to get subtly wrong. The ring has to travel
* *out along one side of the segment and back along the other*, turning through the
* head and the tail; if both caps sweep the same way the ring self-intersects and the
* union returns two half-shapes instead of one bar.
*/
/** Cap sampling: a semicircle in 8 chords is well under one font unit of error. */
const CAP_STEPS = 8;
/**
* One segment's outline as a closed ring, or `null` for a zero-length segment.
*
* Coordinates are in the same units as the input; the caller scales.
*/
export function segmentPolygon(x1, y1, x2, y2, width) {
const dx = x2 - x1;
const dy = y2 - y1;
const length = Math.hypot(dx, dy);
if (length === 0) return null;
const radius = width / 2;
const direction = Math.atan2(dy, dx);
const ring = [];
/*
* Start cap: sweep from the side at `direction + 90°` round through the tail
* (`direction + 180°`) to the side at `direction + 270°`. In a y-down coordinate
* system that is half a turn, and it ends where the second cap will begin.
*/
for (let i = 0; i <= CAP_STEPS; i++) {
const t = direction + Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
ring.push([x1 + Math.cos(t) * radius, y1 + Math.sin(t) * radius]);
}
/*
* End cap: the other half turn, starting at `direction - 90°` so it continues from
* the first cap's last point and closes back onto its first.
*/
for (let i = 0; i <= CAP_STEPS; i++) {
const t = direction - Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
ring.push([x2 + Math.cos(t) * radius, y2 + Math.sin(t) * radius]);
}
return close(ring);
}
/**
* Removes consecutive duplicates and the explicit closing point.
*
* Clipper treats a repeated first/last vertex as a degenerate edge, and degenerate
* edges are reported as "unable to complete output ring".
*/
function close(ring) {
const out = [];
for (const point of ring) {
const previous = out[out.length - 1];
if (previous && Math.abs(previous[0] - point[0]) < 1e-9 && Math.abs(previous[1] - point[1]) < 1e-9) {
continue;
}
out.push([+point[0].toFixed(4), +point[1].toFixed(4)]);
}
return out.length >= 3 ? out : null;
}
-56
View File
@@ -1,56 +0,0 @@
/**
* Exact polygon union via Clipper.
*
* Clipper rather than a hand-rolled boolean because the input is adversarial for
* naive clipping: a stroked polyline is a chain of shapes that meet at exactly one
* endpoint each, so consecutive pieces touch at a single point and frequently share
* collinear edges. That is the case a simple sweep-line reports as "unable to
* complete output ring"; Clipper handles touching and collinear input by
* construction.
*
* Two details are load-bearing:
*
* - Clipper works in **integers**. Coordinates are scaled up before clipping and
* down after. Clipping in floats and rounding the output is what leaves rings
* that do not close.
* - The result is read from a **PolyTree**, not a flat path list, because a tree
* says which rings are holes and which are outers. A flat list leaves that to be
* guessed from winding, and a guessed hole renders solid.
*/
import ClipperLib from 'clipper-lib';
const SCALE = 1e6;
export function unionPolygons(rings) {
const clipper = new ClipperLib.Clipper();
const paths = rings.map((ring) => ring.map(([x, y]) => ({
X: Math.round(x * SCALE),
Y: Math.round(y * SCALE),
})));
clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
const solution = new ClipperLib.PolyTree();
clipper.Execute(
ClipperLib.ClipType.ctUnion,
solution,
// `pftNonZero` unions rings wound consistently; `pftEvenOdd` would subtract
// every overlap instead, which is the opposite of what a stroke union means.
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero,
);
return readTree(solution);
}
/** PolyTree -> [{ ring, depth }], depth 0 being an outer boundary. */
function readTree(tree) {
const out = [];
const walk = (node, depth) => {
for (const child of node.Childs()) {
const ring = child.Contour().map((point) => [point.X / SCALE, point.Y / SCALE]);
if (ring.length >= 3) out.push({ ring, depth });
walk(child, depth + 1);
}
};
walk(tree, 0);
return out;
}