Skip to content
PПромтбук
RUEN
02Типографика

Multilingual font stack: Cyrillic / CJK / Arabic

Fallback chain для проекта с несколькими письменностями: что грузить, что брать из системы, как тестировать отсутствие tofu (□).

Действуй как типограф интернационализации. Собери fallback chain для шрифта {{primary_font}} с поддержкой письменностей {{scripts}} так, чтобы на любом языке не было tofu (□), бандл не вырос вдвое, а первая отрисовка прошла быстро.

1. Архитектура: один файл vs unicode-range

Полный Inter с латиницей + кириллицей + греческим + vietnamese весит 380KB в woff2 на каждый вес. С CJK — 4-10MB на JP, 20MB+ на full CJK. Грузить всё сразу — самоубийство для LCP.

Решение: unicode-range в @font-face. Браузер скачивает только тот subset, который реально нужен текущему контенту страницы.

/* Latin core */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/inter-latin.woff2') format('woff2-variations');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
                 U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074,
                 U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
                 U+FEFF, U+FFFD;
}

/* Cyrillic */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/inter-cyrillic.woff2') format('woff2-variations');
  unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

/* Cyrillic Extended */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('/fonts/inter-cyrillic-ext.woff2') format('woff2-variations');
  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF,
                 U+A640-A69F, U+FE2E-FE2F;
}

Google Fonts отдаёт ровно такую разбивку для всех своих шрифтов — открой ссылку на CSS, скопируй unicode-range оттуда. Не пиши руками.

2. CJK — отдельная стратегия

Japanese, Chinese (Simplified/Traditional), Korean — каждый шрифт 3-15MB. Грузить как Inter нельзя.

Варианты:

  1. System fallback only (рекомендую для UI):

    font-family: 'Inter', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN',
                 'Yu Gothic Medium', 'Meiryo', 'Microsoft YaHei',
                 'PingFang SC', 'Malgun Gothic', sans-serif;
    

    У всех современных ОС есть хорошие CJK-шрифты — пользователи уже привыкли.

  2. Noto Sans CJK с subsetting под язык страницы:

    • /fonts/noto-jp.woff2 подгружается только когда lang="ja"
    • Используй unicode-range для базовых hiragana/katakana, остальное — system
  3. Variable Noto Sans (новинка 2024-2025): один файл, веса 100-900, но всё равно 4-8MB на скрипт. Только для proper typography-heavy сайтов.

3. Arabic / Hebrew — RTL-нюансы

Шрифт должен иметь Arabic-glyphs И поддерживать RTL-shaping (лигатуры, контекстные формы).

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-arabic.woff2') format('woff2');
  unicode-range: U+0600-06FF, U+0750-077F, U+0870-088E,
                 U+0890-0891, U+0898-08E1, U+08E3-08FF,
                 U+200C-200E, U+2010-2011, U+204F, U+2E41,
                 U+FB50-FDFF, U+FE70-FEFC, U+102E0-102FB;
}

Inter покрывает Arabic с версии 4.0. Geist — нет, fallback на Noto Sans Arabic.

Hebrew: U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F.

<html lang="ar" dir="rtl">

dir="rtl" обязательно — без него лигатуры ломаются, числа рендерятся в обратном порядке.

4. Каноничный fallback chain для интерфейса

:root {
  --font-sans:
    {{primary_font}},
    /* Latin/Cyrillic system fallback */
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI Variable',
    'Segoe UI',
    /* CJK fallback by OS */
    'Hiragino Sans',
    'Yu Gothic UI',
    'Meiryo',
    'PingFang SC',
    'Microsoft YaHei',
    'Malgun Gothic',
    /* Generic */
    Roboto,
    Helvetica,
    Arial,
    sans-serif,
    /* Emoji */
    'Apple Color Emoji',
    'Segoe UI Emoji',
    'Segoe UI Symbol',
    'Noto Color Emoji';
}

Порядок важен. Браузер идёт слева направо и проверяет на каждом шрифте: есть ли глиф для конкретного символа. Первый, у кого есть — побеждает на этом символе.

Emoji в конце — обязательно. Иначе на Windows эмодзи рендерятся в monochrome через Segoe UI Symbol.

5. Variable fonts + multilingual

Variable Inter / Variable Noto экономят запросы (один файл вместо 9 весов), но subset по unicode-range всё равно обязателен. Иначе грузишь 4MB вместо 200KB.

6. Subsetting своими руками (если нужен кастомный шрифт)

# pyftsubset из fonttools
pyftsubset MyFont.ttf \
  --unicodes="U+0020-007F,U+00A0-00FF,U+0400-045F,U+0490-0491,U+2116" \
  --layout-features="kern,liga,calt,locl,tnum,lnum,onum,ss01" \
  --flavor=woff2 \
  --output-file=myfont-latin-cyrillic.woff2

locl (localized forms) критично для кириллицы: болгарский «б» и сербский «бде» отличаются от русских.

7. Как тестировать отсутствие tofu

Tofu (□) — это маленький квадратик, который браузер показывает, если ни один шрифт в стеке не имеет глифа для символа.

  1. Текстовый смок-тест. Скрипт, который рендерит на одной странице:

    • Латиницу: The quick brown fox jumps over the lazy dog
    • Расширенная латиница: Ñoño, Façade, Mötörhead, Ærø, Žluťoučký
    • Кириллица: Съешь же ещё этих мягких французских булок, да выпей чаю
    • Кириллица расширенная: Ҕ ҥ ӕ ӂ ѣ ѫ ѧ Ѵ (старославянские)
    • Greek: Φαγέδαινα ἥξει
    • Arabic: نص حكيم له سر قاطع وذو شأن عظيم
    • CJK JP: いろはにほへとちりぬるを我が宿の梅の花
    • CJK CN: 视而不见,听而不闻
    • CJK KR: 키스의 고유조건은 입술끼리 만나야 하고
    • Numbers tabular: 0123456789
    • Punctuation: «» „" '' – — … § ¶ † ‡ № ™
    • Math/currency: € ₽ ¥ ₿ ∞ ≈ ≠ ≤ ≥ ± × ÷
    • Emoji: 🚀 ✨ 📝

    Глазами проверь — нет ли квадратиков.

  2. Автоматизированно через Playwright + visual diff:

    await page.goto('/font-smoke-test');
    await expect(page).toHaveScreenshot('fonts-baseline.png', {
      maxDiffPixels: 100,
    });
    

    Если шрифт деградировал на fallback — diff покажет.

  3. DevTools → Computed → Rendered Fonts. Открой инспектор на конкретном слове, посмотри какой шрифт реально использован. Если для кириллицы используется Times New Roman — у тебя в стеке дыра.

  4. Throttle 3G + Slow CPU. Прогрузи страницу с throttling — увидишь FOUT (flash of unstyled text), на котором проявляются дыры fallback'а.

8. font-display стратегия

  • swap — для основного шрифта на body. Видим текст сразу, шрифт долетает за 200-2000ms.
  • optional — для премиум-шрифтов, где допустим fallback навсегда (если не успел за 100ms — забываем).
  • fallback — компромисс: 100ms блокировки, потом swap, потом fallback навсегда.
  • block — НИКОГДА на основной шрифт. Только для иконочных шрифтов где fallback невозможен (но лучше SVG).

9. Формат вывода

## Multilingual font stack

### Scripts coverage
| Script | Source | Size |
|---|---|---|
| Latin | {{primary_font}} subset | 25KB |
| Latin-ext | {{primary_font}} subset | 12KB |
| Cyrillic | {{primary_font}} subset | 18KB |
| Cyrillic-ext | {{primary_font}} subset | 6KB |
| Greek | system fallback | 0 |
| Arabic | Noto Sans Arabic subset | 35KB |
| CJK JP | system fallback (Hiragino/Yu Gothic) | 0 |

### Fallback chain
```css
font-family: {{primary_font}}, -apple-system, BlinkMacSystemFont,
             'Segoe UI', 'Hiragino Sans', 'Yu Gothic UI',
             'PingFang SC', sans-serif,
             'Apple Color Emoji', 'Segoe UI Emoji';

font-display

  • body: swap
  • display headline: optional

Тесты

  • Смок-страница со всеми скриптами — нет tofu
  • Playwright screenshot diff на 3 ОС
  • DevTools Computed → Rendered Fonts для кириллицы и CJK
  • Throttle 3G: fallback корректен

## Anti-patterns

- ❌ Один `@font-face` без `unicode-range` — грузишь 380KB латиницы пользователю, читающему только по-русски.
- ❌ Noto CJK 4MB подключен через стандартный `<link>` — LCP уезжает на +3 секунды, и так на любой странице.
-`font-display: block` на основном шрифте — FOIT на 3 секунды, пользователь думает что сайт сломан.
- ❌ Стек без CJK fallback (`Inter, sans-serif`) — для японского пользователя весь интерфейс рендерится дефолтным санс-серифом ОС, выглядит как 1998.
- ❌ Emoji-шрифт не последним в стеке — на Windows эмодзи monochrome через Segoe UI Symbol, грустно.
- ❌ Кастомный шрифт через base64 в CSS — кеш бесполезен, CSS раздут на 2MB.
- ❌ Тест шрифтов только латиницей — деплой, через час user из Болгарии пишет «у вас вместо буквы квадратик».
- ❌ Игнорирование `locl` при subsetting — болгарский и сербский кириллический рендерятся как русский, локалы недовольны.
-`@import url(googlefonts...)` в CSS вместо `<link rel="preload">` — render-blocking, +500ms к LCP.
- ❌ Хранение woff (не woff2) — woff2 жмёт на 30% лучше, поддерживается везде с 2017.
- ❌ Hebrew/Arabic без `dir="rtl"` на `<html>` — лигатуры ломаются, числа в обратном порядке, пользователь уходит.
- ❌ Загрузка JP-шрифта на странице с английским контентом по `lang="en"` — стоит подгружать по `:lang(ja)` селектору или динамически.
К подразделу «Типографика»
Похожие промты