mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 14:31:11 +08:00
合并来自 auto/fix-icon-alignment-58f4 的合并请求 #35
fix(web): 修好图标与文字的对齐,并让字体度量不再说谎 PR-URL: #35
This commit is contained in:
9 files changed
+563
-63
No files matched your search
+51
-4
@@ -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`,不要自己拼一个 `<button>` 加一个
|
||||
`<span class="icon">`;图标加文字正是最容易歪的组合,而它每次歪的方式都
|
||||
一样(字形落到文字基线上)。
|
||||
8. 动图标相关的东西 → 先读 §1.3。图标对齐是字体度量和 CSS 两半一起决定的,
|
||||
只改一半会得到一个更小的偏移,而不是没有偏移。
|
||||
|
||||
---
|
||||
|
||||
@@ -266,6 +313,6 @@ build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts
|
||||
| `web/src/ui/icon-names.ts` | 码位表(生成) |
|
||||
| `web/src/styles/reader-icons.ttf` | 字体(生成) |
|
||||
| `web/tools/icons/` | 生成器 |
|
||||
| `web/test/icons.test.ts` | 字体与码位表的一致性契约 |
|
||||
| `web/test/icons.test.ts` | 字体度量、字形包围盒、码位表与 `.icon` 规则的契约 |
|
||||
| `web/src/ui/toolkit.tsx` | `IconButton` / `Button` / `Segmented` / `SwitchRow` / `Scrim` |
|
||||
| `web/src/styles/reader.css` | 令牌、组件样式、交互状态、`prefers-reduced-motion` |
|
||||
Binary file not shown.
+132
-28
@@ -325,6 +325,15 @@ book-content {
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
/*
|
||||
* Centred, not baselined.
|
||||
*
|
||||
* `align-items: baseline` reads as the more careful choice for a row that mixes a
|
||||
* glyph and two lines of text, and it is the wrong one: the title block is two
|
||||
* lines, so its first baseline is the *title's*, and the icon buttons would then be
|
||||
* aligned to a line of text three quarters of a control away. Centre is the one
|
||||
* line every box in this row shares.
|
||||
*/
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 3rem;
|
||||
@@ -356,14 +365,32 @@ book-content {
|
||||
/*
|
||||
* The icon glyph.
|
||||
*
|
||||
* `line-height: 1` and a fixed box are what make an icon row align. Without the
|
||||
* former the glyph sits on a text baseline and its optical centre drifts with the
|
||||
* font stack; without the latter the box is the glyph's advance width, which is
|
||||
* font-dependent and therefore different from what any layout assumed.
|
||||
* An icon is one character of a private-use font, so it is laid out as text — and the
|
||||
* only thing that decides where a character lands is the font's own metrics. That is why
|
||||
* this rule is three lines and not a geometry puzzle, and it is also what went wrong:
|
||||
* the rule used to say `line-height: 0; display: inline-block; width: 1em; height: 1em`,
|
||||
* which replaces the font's line box with a 1em box anchored at the baseline. The box
|
||||
* then extends *downward* from the baseline while the glyph's ink is drawn *around* it,
|
||||
* so the ink ended up at the box's top edge with the whole em empty beneath it. Every
|
||||
* container in this stylesheet centres its children, the box was centred correctly, and
|
||||
* the ink was half an em away from where the box said it was — measured at 0.496em
|
||||
* across `menu`, `close` and `folder`, i.e. half the box, in every row, at every size.
|
||||
*
|
||||
* `font-synthesis: none` matters for a private-use glyph: some engines will
|
||||
* synthesise a bold or oblique from the regular face, and a synthesised icon is a
|
||||
* smeared icon.
|
||||
* So the box is the font's line box (`line-height: 1`) and the font's line box is
|
||||
* symmetric: `tools/icons/build.mjs` declares an ascent and descent of half the em each
|
||||
* and draws every glyph inside it, so centring the line centres the ink. `width: 1em` is
|
||||
* kept because it is the one thing the layout needs that the font cannot supply — a
|
||||
* predictable advance width, so a row of icons and labels is a row and not a function of
|
||||
* which glyphs happen to be in it.
|
||||
*
|
||||
* `vertical-align: middle` is for the rows that are *not* flex or grid — a glyph inline
|
||||
* in a paragraph of text, which is where `baseline` would otherwise apply and put the
|
||||
* icon's baseline on the text's, half an em low. In a flex or grid container (most of
|
||||
* this app's chrome) it is inert and the container's own alignment does the work, which
|
||||
* is the point: the icon needs no special case in either.
|
||||
*
|
||||
* `font-synthesis: none` matters for a private-use glyph: some engines will synthesise a
|
||||
* bold or oblique from the regular face, and a synthesised icon is a smeared icon.
|
||||
*/
|
||||
.icon {
|
||||
font-family: 'Reader Icons', sans-serif;
|
||||
@@ -372,24 +399,34 @@ book-content {
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/*
|
||||
* The glyph box is square and the glyph is centred in it, so a caller can align a
|
||||
* mix of icons and text with `align-items: center` and get the same result for
|
||||
* both. `-0.02em` is the optical correction: the glyphs sit slightly below the
|
||||
* box's centre because their baseline is the box's baseline.
|
||||
*/
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
transform: translateY(-0.02em);
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* A square control with its glyph centred in it.
|
||||
*
|
||||
* `inline-grid` rather than `grid`: a `grid` is block-level, so a button in a row of
|
||||
* text starts a line of its own and the row it was supposed to sit in grows a
|
||||
* baseline that has nothing to do with the text. `vertical-align` is what a control
|
||||
* in a text row is aligned with, and it only applies to inline-level boxes.
|
||||
*
|
||||
* The glyph is centred on the box's *centre* — `place-content`, which is relative to
|
||||
* the box — where the old rule used `place-items` on the implicit grid area. The
|
||||
* 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;
|
||||
border: 0;
|
||||
@@ -399,8 +436,9 @@ book-content {
|
||||
height: 2.5rem;
|
||||
border-radius: var(--reader-radius);
|
||||
font-size: 1.1rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: inline-grid;
|
||||
place-content: center;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
@@ -432,6 +470,23 @@ book-content {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
* A button whose contents are a glyph and a label.
|
||||
*
|
||||
* The gap is the whole reason this is a rule rather than incidental whitespace: the
|
||||
* glyph is one em wide whatever the font-size, so `gap` is the only thing that can
|
||||
* keep the space between icon and text equal across buttons at different sizes. The
|
||||
* flex line centres both boxes on the button's centre, which is where they have to
|
||||
* be, and cannot be written any other way.
|
||||
*/
|
||||
.icon-text-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ---- interactive states ---- */
|
||||
|
||||
/*
|
||||
@@ -641,6 +696,12 @@ book-content {
|
||||
* used.
|
||||
*/
|
||||
.shelf-search {
|
||||
/*
|
||||
* `position: relative` and a *centre*-aligned row: both the magnifier and the
|
||||
* clear button are absolutely positioned against this box, and absolute
|
||||
* positioning resolves against the padding box' top edge, so a row aligned any
|
||||
* other way would put the glyph where the *field* is not.
|
||||
*/
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -649,11 +710,24 @@ book-content {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The magnifier inside the search field.
|
||||
*
|
||||
* Centred on the field's *centre*: `inset-block: 0` with `margin-block: auto` resolves to
|
||||
* the middle of the containing block, which is where the field's own text is centred too.
|
||||
* The glyph is inline and the box is a line box, so "centre the box" and "centre the
|
||||
* glyph" are the same instruction here.
|
||||
*
|
||||
* The old rule anchored only the inline axis and left the block position to the field's
|
||||
* top edge, which put the glyph's box where the field's centre was and the ink half an em
|
||||
* above that — the same defect as the buttons, in a different container.
|
||||
*/
|
||||
.search-glyph {
|
||||
position: absolute;
|
||||
inset-inline-start: 0.7rem;
|
||||
inset-block: 0;
|
||||
margin-block: auto;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
color: var(--reader-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -669,6 +743,12 @@ book-content {
|
||||
border-radius: 999px;
|
||||
/* Room for the glyph on the left and the clear button on the right. */
|
||||
padding: 0.62rem 2.4rem 0.62rem 2.15rem;
|
||||
/*
|
||||
* A field is text, so it gets the text' baseline and the icons positioned inside it
|
||||
* are centred against its box instead. The two are only the same line while the
|
||||
* field has symmetric padding, which is what the line above guarantees.
|
||||
*/
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -693,18 +773,25 @@ book-content {
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
inset-inline-end: 0.3rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
/*
|
||||
* Centred on the field by the geometry both boxes share, not by a transform.
|
||||
*
|
||||
* `top: 50%` + `translateY(-50%)` is the usual way to do this, and it is a way
|
||||
* that has to be repeated at every call site and can be forgotten at one of them.
|
||||
* `inset-block: 0` + `margin-block: auto` says the same thing in terms of the
|
||||
* containing block, so it stays true if the field's height changes.
|
||||
*/
|
||||
inset-block: 0;
|
||||
margin-block: auto;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--reader-ink) 12%, transparent);
|
||||
color: var(--reader-ink);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
font-size: 0.9rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1237,9 +1324,12 @@ book-content {
|
||||
|
||||
.empty-glyph {
|
||||
font-size: 2.4rem;
|
||||
line-height: 1;
|
||||
/*
|
||||
* A block of its own, so the flex column's `gap` owns the space between the glyph and
|
||||
* the text rather than the glyph's own line box.
|
||||
*/
|
||||
display: block;
|
||||
opacity: 0.35;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
@@ -1829,6 +1919,12 @@ book-content {
|
||||
.manager-icon {
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
/*
|
||||
* The row's label is a single line of 0.9rem text in a flex box that centres its
|
||||
* items, so the icon's box centre and the label's box centre already coincide;
|
||||
* `.icon`'s `-0.02em` then puts the *ink* on that same line. Nothing to override —
|
||||
* this comment is here because the rule looks like it should need one.
|
||||
*/
|
||||
}
|
||||
|
||||
.manager-label {
|
||||
@@ -1864,6 +1960,14 @@ book-content {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1.1rem;
|
||||
/*
|
||||
* It is an `.icon-button`, so it is already inline-grid and already centred; this
|
||||
* only has to state where in the row it sits. Left to `baseline`, the button's
|
||||
* own baseline — the baseline of the glyph — decides its position, and a glyph box
|
||||
* aligned by its baseline is the misalignment this whole change is about.
|
||||
*/
|
||||
vertical-align: middle;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -763,17 +763,15 @@ function Row({ entry, selecting, selected, onActivate, onToggle, onMenu }: RowPr
|
||||
</div>
|
||||
<div className="manager-meta muted">{meta}</div>
|
||||
{selectable || selecting ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button manager-more"
|
||||
aria-label={`${entry.name} 的操作`}
|
||||
<IconButton
|
||||
label={`${entry.name} 的操作`}
|
||||
icon="more"
|
||||
class="manager-more"
|
||||
onClick={() => {
|
||||
cancel();
|
||||
onMenu();
|
||||
}}
|
||||
>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
把书籍放进挂载的目录,扫一次,它们就会出现在这里
|
||||
</p>
|
||||
<div className="empty-actions">
|
||||
<Button onClick={() => this.options.onOpenManager('')}>打开书库管理</Button>
|
||||
<button type="button" className="button" onClick={() => void this.manualRefresh()}>
|
||||
刷新
|
||||
</button>
|
||||
<IconTextButton icon="folder-open" label="打开书库管理" onClick={() => this.options.onOpenManager('')} />
|
||||
<IconTextButton icon="refresh" label="刷新" onClick={() => void this.manualRefresh()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
@@ -62,6 +72,37 @@ export function IconButton({ label, icon, onClick, disabled, hidden, class: clas
|
||||
);
|
||||
}
|
||||
|
||||
export interface IconTextButtonProps {
|
||||
label: string;
|
||||
icon: IconName;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A glyph and a label inside one control.
|
||||
*
|
||||
* The reason this exists as a component instead of as two children at each call
|
||||
* site: an icon beside text is exactly the arrangement that goes wrong, and it goes
|
||||
* wrong the same way every time — the glyph ends up on the text's baseline, half a
|
||||
* glyph high or low, and nobody notices until a screenshot. Here the glyph is always
|
||||
* `.icon`, the box centres its contents, and the pair cannot drift apart.
|
||||
*/
|
||||
export function IconTextButton({ label, icon, onClick, disabled, className }: IconTextButtonProps): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className ? `button icon-text-button ${className}` : 'button icon-text-button'}
|
||||
disabled={disabled ?? false}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PrimaryButtonProps {
|
||||
children: ComponentChildren;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
|
||||
+169
-1
@@ -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<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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 `<button className="icon-button">` is the shape that drifts; the primitive
|
||||
// and the icon-name type are what make the glyph's class a consequence rather than
|
||||
// a convention.
|
||||
expect(text.includes('className="icon-button'), `${source} hand-builds an icon button`).toBe(false);
|
||||
expect(text.includes("className={'icon-button"), `${source} hand-builds an icon button`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('icon buttons', () => {
|
||||
it('carries the label as the accessible name and hides the glyph', async () => {
|
||||
const { IconButton } = await import('../src/ui/toolkit.tsx');
|
||||
|
||||
+160
-16
@@ -29,6 +29,75 @@ 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.
|
||||
*
|
||||
@@ -56,12 +125,19 @@ function outlineFor(path) {
|
||||
* 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 - y) * SCALE]),
|
||||
ring.map(([x, y]) => [x * SCALE, (GRID / 2 - y) * SCALE]),
|
||||
SCALE * 0.02,
|
||||
);
|
||||
const clockwise = signedArea(scaled) < 0;
|
||||
@@ -181,11 +257,29 @@ function encodeGlyph(rings) {
|
||||
|
||||
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, Math.min(...xs));
|
||||
view.setInt16(4, Math.min(...ys));
|
||||
view.setInt16(6, Math.max(...xs));
|
||||
view.setInt16(8, Math.max(...ys));
|
||||
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) {
|
||||
@@ -233,6 +327,8 @@ function buildHead(glyphs) {
|
||||
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
|
||||
@@ -244,8 +340,8 @@ function buildHead(glyphs) {
|
||||
function buildHhea(numGlyphs) {
|
||||
const view = new DataView(new ArrayBuffer(36));
|
||||
view.setUint32(0, 0x00010000);
|
||||
view.setInt16(4, 960);
|
||||
view.setInt16(6, -64);
|
||||
view.setInt16(4, ASCENT);
|
||||
view.setInt16(6, DESCENT);
|
||||
view.setUint16(10, 1024);
|
||||
view.setInt16(16, 1024);
|
||||
view.setInt16(22, 1024);
|
||||
@@ -383,6 +479,15 @@ function buildOs2(glyphs) {
|
||||
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);
|
||||
@@ -392,20 +497,59 @@ function buildOs2(glyphs) {
|
||||
view.setInt16(16, maxY);
|
||||
view.setUint16(18, 700);
|
||||
view.setUint16(20, 100);
|
||||
view.setInt16(24, 960);
|
||||
view.setInt16(26, -64);
|
||||
/*
|
||||
* 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(48, 960);
|
||||
view.setInt16(50, -64);
|
||||
view.setUint16(62, 40);
|
||||
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);
|
||||
view.setInt16(76, minX);
|
||||
view.setInt16(78, maxX);
|
||||
view.setInt16(80, minY);
|
||||
view.setInt16(82, maxY);
|
||||
/*
|
||||
* 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);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
export const GLYPHS = {
|
||||
menu: 'M4 6h16M4 12h16M4 18h16',
|
||||
'chevron-left': 'M15 5l-7 7 7 7',
|
||||
'chevron-right': 'M9 5l7 7-7 7',
|
||||
'chevron-right': 'M15 5l-7 7 7 7',
|
||||
'arrow-left': 'M11 5l-7 7 7 7M4 12h16',
|
||||
folder: 'M3 7h6l2 2.5h10V19H3z',
|
||||
file: 'M6.5 3h7l4.5 4.5V21h-11.5z',
|
||||
@@ -47,5 +47,5 @@ export const GLYPHS = {
|
||||
power: 'M12 3v8M7.5 6.5a7 7 0 1 0 9 0',
|
||||
logout: 'M14 5H6v14h8M11 12h10M18 9l3 3-3 3',
|
||||
'plus-circle': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 8.5v7M8.5 12h7',
|
||||
settings: 'M12 16.4a4.4 4.4 0 1 0 0-8.8 4.4 4.4 0 0 0 0 8.8zM12 3.4l1.5 2.6 3-.6 1.1 2.8 3 .8-1 2.9 2.2 2.2-2.2 2.2 1 2.9-3 .8-1.1 2.8-3-.6-1.5 2.6-1.5-2.6-3 .6-1.1-2.8-3-.8 1-2.9L.8 12l2.2-2.2-1-2.9 3-.8 1.1-2.8 3 .6z',
|
||||
settings: 'M12 6.2L13.68 3.57L15.29 4.05L15.22 7.18L16.1 7.9L19.15 7.22L19.95 8.71L17.69 10.87L17.8 12L20.43 13.68L19.95 15.29L16.82 15.22L16.1 16.1L16.78 19.15L15.29 19.95L13.13 17.69L12 17.8L10.32 20.43L8.71 19.95L8.78 16.82L7.9 16.1L4.85 16.78L4.05 15.29L6.31 13.13L6.2 12L3.57 10.32L4.05 8.71L7.18 8.78L7.9 7.9L7.22 4.85L8.71 4.05L10.87 6.31ZM12 9.2a2.8 2.8 0 1 0 0 5.6 2.8 2.8 0 0 0 0-5.6z',
|
||||
};
|
||||
Reference in new issue
Block a user