diff --git a/docs/ui.md b/docs/ui.md index 63ebf40..4b223fa 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -58,7 +58,49 @@ build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts 规则无法表达「边界在一格宽的图形上左右交替」**——结果是一个圆被追踪成 58 段 碎片。精确布尔没有这种情形。`union.mjs` 的注释里写了这段。 -### 1.3 字符编码 +### 1.3 垂直度量:为什么图标能对齐 + +图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的 +**垂直度量**里,而当时所有测试都只读 `cmap` 和字形名。记下来,因为只要再动 +一次生成器就会重新踩到。 + +**约定(生成器与样式表之间的契约):** + +- 网格中点(12)就是 `y = 0`,即 em 的中心线;绘制代码用 `(GRID / 2 - y)`。 +- `hhea` 与 `OS/2` 的上升部/下降部各为半个 em(512 / -512),也就是 + **对称**。对称不是审美:UI 里每个容器都在「居中一个盒子」,只有度量对称时 + 「居中盒子」和「居中字形」才是同一件事。度量不对称时,两者相差正好是那个 + 不对称量——这就是当初每一行图标歪的幅度都不一样的原因。 +- 每个字形的墨迹必须落在 `±0.5em` 内。生成器会检查,越界直接报错。 +- `OS/2` 的 Windows 度量是**无符号**的,且必须包住所有字形;`fsSelection` + 打开 `USE_TYPO_METRICS`(否则引擎会去用 Windows 那一对)。 + +**当初错在哪:** 表格声明上升部 960、下降部 -64(基线靠近 em 底部),而字形是 +围绕 em 中心画的。于是每个字形的墨迹都画在它所在盒子**上边界之上**约 0.75em, +而所有布局居中的都是盒子——`menu`、`close`、`folder` 实测偏移一致是 0.496em。 + +**样式表那一半:** + +```css +.icon { + line-height: 1; /* 用字体自己的行盒,而不是自建一个方块 */ + display: inline-block; + width: 1em; /* 唯一需要显式给的东西:可预测的前进宽度 */ + text-align: center; + vertical-align: middle; /* 非 flex/grid 的行(正文里的行内图标) */ +} +``` + +`line-height: 0` + `height: 1em` 是**错的**:那把盒子锚在基线上、向下一整个 +em,而墨迹画在基线周围,于是墨迹贴在盒子上沿、盒子下方全空。居中盒子的容器 +把盒子居中了,字形却差了半个 em。`transform` 也不行——它在绘制期移动字形,让 +图标占的位置和看起来占的位置不再一致。 + +代价:`web/test/icons.test.ts` 会读字体的 `hhea` / `OS/2` / `head` 与每个字形的 +包围盒来断言上面这些,也会读 `reader.css` 里的 `.icon` 规则。两边都是之前没被 +任何断言覆盖的地方。 + +### 1.4 字符编码 - 码位从 `U+E900` 起,连续分配,位于**私有使用区**。 - 顺序与 `paths.mjs` 中定义的顺序一致。 @@ -68,7 +110,7 @@ build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts **不映射私有使用区之外**的任何码位(否则普通文字在应用了这个字族的元素里 会变成图标)。 -### 1.4 无障碍 +### 1.5 无障碍 `Icon` 组件的 `label` 决定语义: @@ -92,7 +134,7 @@ build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts 现在 `IconButton` 的 `icon` 是 `IconName`,即字形的**名字**, 传一个不存在的名字是编译错误。 -### 1.5 现在有哪些图标 +### 1.6 现在有哪些图标 见 `web/tools/icons/paths.mjs`,或 `npm run icons:sheet` 生成的预览图。 命名按**用途**而非形状:`sliders`(阅读设置)而不是 `knobs`; @@ -255,6 +297,11 @@ build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts 5. 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。 6. 图标按钮 → `label` 必填,且这条 label 是**这个按钮自己的作用**, 不是它所在的区块的名字。 +7. 图标 + 文字并排 → 用 `IconTextButton`,不要自己拼一个 ` + /> ) : null} ); diff --git a/web/src/ui/shelf-screen.tsx b/web/src/ui/shelf-screen.tsx index 3697846..07ab387 100644 --- a/web/src/ui/shelf-screen.tsx +++ b/web/src/ui/shelf-screen.tsx @@ -7,7 +7,7 @@ import type { AppSettings, ShelfSort } from '../store/settings.ts'; import { mountUI } from './mount.ts'; import { DENSITY_LABELS, ShelfSettingsPanel } from './shelf-settings.tsx'; import { sortBooks, shelfOrder } from './shelf-order.ts'; -import { Button, Icon, IconButton } from './toolkit.tsx'; +import { Icon, IconButton, IconTextButton } from './toolkit.tsx'; import { type ComponentChildren, type JSX, useEffect, useState } from './vendor/preact.ts'; export interface ShelfScreenOptions { @@ -513,10 +513,8 @@ export class ShelfScreen { 把书籍放进挂载的目录,扫一次,它们就会出现在这里

- - + this.options.onOpenManager('')} /> + void this.manualRefresh()} />
) diff --git a/web/src/ui/toolkit.tsx b/web/src/ui/toolkit.tsx index a3e8488..25dd578 100644 --- a/web/src/ui/toolkit.tsx +++ b/web/src/ui/toolkit.tsx @@ -47,6 +47,16 @@ export interface IconButtonProps { class?: string; } +/** + * A square control holding one glyph. + * + * `hidden` rather than a conditional: the attribute is what the stylesheet hides on, + * so a control that is not available disappears *and* keeps its place in the markup, + * and no caller has to decide which of the two it wants. + * + * The glyph is passed as a *name*, so it is always `.icon` — which is what makes the + * control's own alignment guaranteed rather than inherited from a caller's layout. + */ export function IconButton({ label, icon, onClick, disabled, hidden, class: className }: IconButtonProps): JSX.Element { return ( + ); +} + export interface PrimaryButtonProps { children: ComponentChildren; onClick?: (event: MouseEvent) => void; diff --git a/web/test/icons.test.ts b/web/test/icons.test.ts index 7d6fb41..3dd791c 100644 --- a/web/test/icons.test.ts +++ b/web/test/icons.test.ts @@ -94,7 +94,117 @@ describe('the icon font and its code point table', () => { }); }); -describe('the Icon component', () => { +/** + * 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); + } + }); +}); + +describe('the icon box', () => { it('renders the glyph and hides it from assistive technology when decorative', async () => { const { Icon } = await import('../src/ui/icon.tsx'); const { render } = await import('../src/ui/vendor/preact.ts'); @@ -121,6 +231,64 @@ describe('the Icon component', () => { }); }); +/** + * 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 + * the ones that were wrong and a test that re-states them in its own words would have + * been updated alongside the bug. + */ +describe('the stylesheet', () => { + const css = readFileSync(join(process.cwd(), 'src/styles/reader.css'), 'utf8'); + const iconRule = /(^|\n)\.icon \{([^}]*)\}/.exec(css)?.[2] ?? ''; + 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. + expect(declaration('width')).toBe('1em'); + }); + + it('aligns on the middle for the rows that are not flex or grid', () => { + // In a paragraph, `baseline` would put the icon's baseline on the text's, and the icon + // has no baseline in the text sense — its ink is centred on the em. + expect(declaration('vertical-align')).toBe('middle'); + }); + + it('does not move the glyph with a transform', () => { + // A transform would also move the ink inside a flex or grid container, where + // `vertical-align` is inert — but it moves the glyph at paint time, so the space the + // icon reserves stops agreeing with the space it appears to occupy. + expect(declaration('transform')).toBeUndefined(); + }); + + it('is applied by every icon button, including the hand-built ones', () => { + // The glyph has to be `.icon` for any of the above to reach it, and a button that + // builds its own markup is where that gets forgotten. + const sources = ['toolkit.tsx', 'shelf-screen.tsx', 'manager-screen.tsx', 'reader-chrome.tsx', 'shelf-settings.tsx']; + for (const source of sources) { + const text = readFileSync(join(process.cwd(), 'src/ui', source), 'utf8'); + // A raw `