mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 13:04:08 +08:00
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:
1 parent
b997e89059
commit
12fbe0cbcd
18 files changed
+39
-1550
No files matched your search
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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.
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
@@ -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]>>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user