optimize-fonts@1.0.0

This commit is contained in:
valentinsilyutin committed 2026-10-12 14:20:54 +04:00
1 parent 9a9ea5d130
commit 22699eac9b
3 files changed
+203

No files matched your search

+1
View File
@@ -7,6 +7,7 @@
- **[format-html](./format-html/SKILL.md)** — форматирование и исправление сырой HTML-разметки (отступы в 4 пробела, выравнивание структуры, закрытие тегов).
- **[html-to-scss](./html-to-scss/SKILL.md)** — генерация структуры SCSS по методологии БЭМ на основе переданной HTML-разметки.
- **[backend-integration-guide](./backend-integration-guide/SKILL.md)** — генерация краткой инструкции по интеграции вёрстки для бэкенда/CMS по git-коммиту.
- **[optimize-fonts](./optimize-fonts/SKILL.md)** — оптимизация подключения шрифтов: сабсетинг, variable-шрифты, `unicode-range` и metric-override fallback для устранения сдвига (CLS).
## Установка
+108
View File
@@ -0,0 +1,108 @@
---
name: optimize-fonts
description: Optimize a project's web fonts — subset the files, prefer variable fonts, add unicode-range and metric-override fallbacks to cut payload and remove layout shift (CLS). Use when the user asks to set up, optimize, subset or fix fonts, @font-face rules or font-family stacks. Fonts may come from Google Fonts or be local files.
license: MIT
metadata:
author: Valentin Silyutin
version: "1.0.0"
---
# Web Font Optimization Instructions
Replace full-coverage font files with subset files, prefer a variable font when one exists, wire `unicode-range`, and add a metric-override fallback so the switch from the fallback font to the web font on load does not shift layout (CLS).
## Inputs
- **Family**: a font family name such as `Montserrat`. If the user does not give one, detect it from the font files in the project.
- **Subsets**: always `cyrillic` and `latin`. All projects in this workspace are Russian + Latin.
- **Paths**: the font folder and the stylesheet that declares `@font-face` / `font-family`. Read them from the project's `AGENTS.md`; ask the user only if `AGENTS.md` does not say.
## Workflow
### 1. Locate the fonts
Read `AGENTS.md` and find (a) the folder that holds the font files and (b) the file(s) where fonts are connected. Detect the family name, the weights the project ships, and which weights the styles actually use (grep for `font-weight`).
### 2. Try Google Fonts
Request the family's CSS **with a desktop browser User-Agent** (without it Google returns TTF instead of WOFF2):
```bash
curl -s -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" \
"https://fonts.googleapis.com/css2?family=<Family>:wght@<min>..<max>&display=swap"
```
- If the response declares `font-weight: <min> <max>`, it is a **variable** font — use it.
- Otherwise request the individual weights the project uses (`wght@400;500;700`).
- Take both the WOFF2 URLs **and** the `unicode-range` values for `cyrillic` and `latin` from the response. They are authoritative — do not hand-write them.
- If the family is **not** on Google Fonts (a custom or local font), keep the project's own file(s) and subset them; derive `unicode-range` from the font `cmap` (see the reference section).
### 3. Place subset files
Download the `cyrillic` and `latin` WOFF2 files into the project's font folder, named `<Family>-cyrillic.woff2` / `<Family>-latin.woff2`. For static fonts include the weight: `<Family>-<weight>-latin.woff2`. Ask the user before deleting the old files (WOFF fallbacks, full-coverage files) — they stop being referenced.
### 4. Rewrite the `@font-face` rules
Write one rule per subset, keeping the project's existing path style to the font folder:
```css
@font-face {
font-display: swap;
font-family: <Family>;
font-style: normal;
font-weight: <min> <max>; /* single value for static fonts */
src: url('./fonts/<Family>-<subset>.woff2') format('woff2');
unicode-range: <from Google Fonts CSS or cmap>;
}
```
### 5. Add the metric-override fallback
Run the bundled script on the web font file; it prints ready `@font-face` blocks.
```bash
python3 scripts/font-metrics.py <path-to-webfont.woff2> --name <Family> --weights "<min> <max>"
```
Append the printed blocks, then put the fallback families into every `font-family` stack, right after the web font:
```css
font-family: <Family>, '<Family> Fallback', '<Family> Fallback Android', system-ui;
```
Grep the whole project for `<Family>` in `font-family` declarations and update each one.
### 6. Drop unused weights
Grep `font-weight` and fix any weight the project does not ship (for example a lone `300` with no 300 file) to an existing weight.
## Font metrics script
`scripts/font-metrics.py` reads the metrics from the font file and prints the fallback `@font-face` blocks. It needs Python 3 with `fonttools` and `brotli` installed (`pip install fonttools brotli`).
- For a variable font it measures the instance at `--weight` (default `500`, the dominant body weight).
- Fallback metrics for Arial and Roboto are built in; it emits one block per fallback (Arial for Windows/macOS, Roboto for Android, where Arial is absent).
The numbers come from the font metrics:
```text
size-adjust = web.avg / fallback.avg · 100%
ascent-override = web.ascent / size-adjust · 100%
descent-override = web.descent / size-adjust · 100%
line-gap-override = web.lineGap / size-adjust · 100%
```
where `avg` is the mean advance width over `a-z A-Z 0-9 space`, normalised to `unitsPerEm`; `ascent` / `descent` / `lineGap` come from the font `hhea` table. `size-adjust` scales every metric, so the overrides are divided by it to land on the target line height.
## Verification
- Run the project's own linters and formatters on the changed files (see the project's `AGENTS.md`). If templates changed, rebuild and validate them the way the project does.
- Build the project and confirm the new font files land in the build output and the new `@font-face` rules are present in the built CSS.
## Reference
- **`unicode-range`** lists the code points actually present in that subset file. The browser downloads a file only when a character in its range is used, so subsets absent from the page are never fetched. The default (when omitted) is `U+0-10FFFF` — the whole of Unicode — which defeats subsetting.
- The Google `latin` subset is **not just letters**: it includes shared punctuation and symbols (dashes, quotes, `€`, `™`, digits). That is why its range is long.
- To derive a range from a font yourself: read the `cmap` table (`fonttools`), sort the code points and collapse consecutive runs into `U+XXXX-YYYY`.
- `size-adjust` is supported by all current browsers; `ascent-override` / `descent-override` / `line-gap-override` are ignored by Safari (a known, harmless gap — it still applies `size-adjust`).
- `font-display: swap` draws text immediately in the fallback font and swaps when the web font loads; the metric-override fallback is what keeps that swap from shifting layout.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Print metric-override fallback @font-face blocks for a web font.
The blocks make a local system font (Arial, Roboto) occupy the same space as the
web font, so switching from the fallback to the web font on load does not shift
layout (CLS).
Usage:
python3 font-metrics.py <webfont.woff2> [--weight 500] [--name Montserrat] [--weights "400 800"]
Requires Python 3 with fonttools and brotli: pip install fonttools brotli
"""
import argparse
import sys
from fontTools.ttLib import TTFont
# Advance-width metrics of common fallback fonts, measured over the same
# character set as the web font (a-z A-Z 0-9 space), normalised to unitsPerEm.
# The ascent/descent/lineGap values are not used: only the average width matters
# for size-adjust. Keys are the local() src names.
FALLBACKS = {
'Arial': {'local': 'Arial', 'suffix': '', 'avg': 0.5742885044642857},
'Roboto': {'local': 'Roboto', 'suffix': ' Android', 'avg': 0.5608646453373016},
}
CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
def web_metrics(path, weight):
font = TTFont(path)
if weight is not None and 'fvar' in font:
from fontTools.varLib import instancer
font = instancer.instantiateVariableFont(font, {'wght': weight}, inplace=False)
head = font['head']
hhea = font['hhea']
cmap = font.getBestCmap()
hmtx = font['hmtx']
upm = head.unitsPerEm
widths = [hmtx[cmap[ord(c)]][0] / upm for c in CHARS if ord(c) in cmap]
return {
'ascent': hhea.ascent / upm,
'descent': abs(hhea.descent) / upm,
'lineGap': hhea.lineGap / upm,
'avg': sum(widths) / len(widths),
}
def family_name(path):
font = TTFont(path, lazy=True)
for id_ in (16, 1):
name = font['name'].getDebugName(id_)
if name:
return name.split()[0]
return 'WebFont'
def pct(value):
text = f'{value:.2f}'.rstrip('0').rstrip('.')
return f'{text}%'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('webfont', help='path to the web font file (woff2/ttf/otf)')
parser.add_argument('--weight', type=int, default=500, help='weight to measure variable fonts at (default: 500)')
parser.add_argument('--name', default=None, help='font family name used in the fallback family names')
parser.add_argument('--weights', default='400 800', help='font-weight value for the fallback rules (default: "400 800")')
args = parser.parse_args()
web = web_metrics(args.webfont, args.weight)
name = args.name or family_name(args.webfont)
for key, fb in FALLBACKS.items():
size_adjust = web['avg'] / fb['avg']
if not size_adjust:
print(f'warning: empty width for {key}', file=sys.stderr)
continue
print('@font-face {')
print(f' ascent-override: {pct(web["ascent"] / size_adjust * 100)};')
print(f' descent-override: {pct(web["descent"] / size_adjust * 100)};')
print(f" font-family: '{name} Fallback{fb['suffix']}';")
print(f' font-weight: {args.weights};')
print(f' line-gap-override: {pct(web["lineGap"] / size_adjust * 100)};')
print(f' size-adjust: {pct(size_adjust * 100)};')
print(f" src: local('{fb['local']}');")
print('}')
print()
if __name__ == '__main__':
main()