From 12fbe0cbcd186b63135c979fde4eaead071c1ca3 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Fri, 30 Oct 2026 09:48:04 +0000 Subject: [PATCH] chore(web): remove the icon font toolchain and unused icon names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 6 +- docs/development.en.md | 2 +- docs/development.zh-CN.md | 2 +- docs/ui.md | 16 +- web/package.json | 1 - web/src/styles/reader-icons.ttf | Bin 18592 -> 0 bytes web/src/styles/reader.css | 13 +- web/src/ui/icon-names.ts | 72 ---- web/src/ui/icon.tsx | 21 +- web/src/ui/reader-chrome.tsx | 2 +- web/test/icons.test.ts | 337 +--------------- web/tools/icons/build.mjs | 681 -------------------------------- web/tools/icons/path.d.mts | 8 - web/tools/icons/path.mjs | 192 --------- web/tools/icons/paths.d.mts | 8 - web/tools/icons/paths.mjs | 100 ----- web/tools/icons/stroke.mjs | 72 ---- web/tools/icons/union.mjs | 56 --- 18 files changed, 39 insertions(+), 1550 deletions(-) delete mode 100644 web/src/styles/reader-icons.ttf delete mode 100644 web/src/ui/icon-names.ts delete mode 100644 web/tools/icons/build.mjs delete mode 100644 web/tools/icons/path.d.mts delete mode 100644 web/tools/icons/path.mjs delete mode 100644 web/tools/icons/paths.d.mts delete mode 100644 web/tools/icons/paths.mjs delete mode 100644 web/tools/icons/stroke.mjs delete mode 100644 web/tools/icons/union.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 7bb0a11..b7f3a61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`. 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/`. 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 `` / `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//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 ``; 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`. diff --git a/docs/development.en.md b/docs/development.en.md index c25faa4..8ace76b 100644 --- a/docs/development.en.md +++ b/docs/development.en.md @@ -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 diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index a0dff54..8010450 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -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。 ## 持续集成与发布 diff --git a/docs/ui.md b/docs/ui.md index d7f3af8..08d4a20 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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) | diff --git a/web/package.json b/web/package.json index 17eaa7e..03e1771 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/styles/reader-icons.ttf b/web/src/styles/reader-icons.ttf deleted file mode 100644 index 5731d6aa5df1789892ae688c9eac0d822ba900b7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18592 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 {/* 朗读 keeps going with its panel closed (ui.md §3.11), so the button says it is on. */} - handlers.toggleSettings('speech')} /> + handlers.toggleSettings('speech')} /> handlers.toggleSettings('appearance')} /> handlers.toggleSettings('behavior')} /> diff --git a/web/test/icons.test.ts b/web/test/icons.test.ts index 6ab3416..2a23c9a 100644 --- a/web/test/icons.test.ts +++ b/web/test/icons.test.ts @@ -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 { - 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(); - 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(); - for (const [name, path] of Object.entries(GLYPHS as Record)) { - 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)[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)[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 = {}; - 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'); }); diff --git a/web/tools/icons/build.mjs b/web/tools/icons/build.mjs deleted file mode 100644 index 86c1aee..0000000 --- a/web/tools/icons/build.mjs +++ /dev/null @@ -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`); diff --git a/web/tools/icons/path.d.mts b/web/tools/icons/path.d.mts deleted file mode 100644 index ae7e883..0000000 --- a/web/tools/icons/path.d.mts +++ /dev/null @@ -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>; diff --git a/web/tools/icons/path.mjs b/web/tools/icons/path.mjs deleted file mode 100644 index ecd4067..0000000 --- a/web/tools/icons/path.mjs +++ /dev/null @@ -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; -} diff --git a/web/tools/icons/paths.d.mts b/web/tools/icons/paths.d.mts deleted file mode 100644 index 6731916..0000000 --- a/web/tools/icons/paths.d.mts +++ /dev/null @@ -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; diff --git a/web/tools/icons/paths.mjs b/web/tools/icons/paths.mjs deleted file mode 100644 index 938e1bd..0000000 --- a/web/tools/icons/paths.mjs +++ /dev/null @@ -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', -}; diff --git a/web/tools/icons/stroke.mjs b/web/tools/icons/stroke.mjs deleted file mode 100644 index 6286faa..0000000 --- a/web/tools/icons/stroke.mjs +++ /dev/null @@ -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; -} diff --git a/web/tools/icons/union.mjs b/web/tools/icons/union.mjs deleted file mode 100644 index ee4d75e..0000000 --- a/web/tools/icons/union.mjs +++ /dev/null @@ -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; -}