MOffice

Спайк A8: harfrust против системного шейпинга

Сессия A, 2026-10-04. Задача A8 брифа docs/sessions/A-kernel.md («harfrust против системного шейпинга»). Стенд — kernel/spikes/shaping-bench (в продукт не входит): Rust-драйвер на harfrust 0.13.3 / read-fonts 0.43.3 (те же версии, что у meridian-layout), помощник на Swift для CoreText (coretext/ctshape.swift), эталон — hb-shape из HarfBuzz 14.3.1. Сырые данные — results-2026-10-04-m5max.{md,jsonl}, разбор расхождений — results-2026-10-04-m5max-diffs.txt. Решение оформлено как ADR в разделе 7.

1. Вопрос

Ядро шейпит текст документов через harfrust одинаково на всех ОС (meridian-layout/src/shape.rs, детерминизм — docs/algorithms/kernel-core.md §19). Нужно ли вместо него или в дополнение звать системный шейпер (CoreText на macOS, DirectWrite на Windows, HarfBuzz/Pango на Linux)? Конкретно:

  1. совпадают ли глифы и их положения у harfrust и CoreText на одних и тех же файлах шрифтов и текстах;
  2. насколько они различаются по скорости;
  3. где и почему расходятся, и есть ли случаи, когда без системного шейпинга не обойтись.

2. Методика

Три пути шейпинга на одних и тех же байтах шрифта и абзацах:

Обозначение Что это Как вызывается
harfrust Rust-порт HarfBuzz, шейпер ядра ровно как shape_segment: guess_segment_properties, явное направление и язык, в режиме word выключены kern, liga, clig (WORD_DEFAULT_OFF)
CoreText системный шейпер macOS ctshape.swift: CTFontManagerCreateFontDescriptorsFromURL (лицо выбирается по PostScript-имени из таблицы name), CTFontCreateWithFontDescriptor на 12 pt, абзац — одна CTLine без переноса, из каждого CTRun — глифы, string indices, продвижения и положения
HarfBuzz C эталон, от которого портирован harfrust; это же — системный шейпинг Linux (Pango) hb-shape --shapers=ot внешней программой
HB→CT обёртка HarfBuzz над CoreText hb-shape --shapers=coretext — перекрёстная проверка Swift-помощника

Режимы свойств. word — так шейпит ядро (Word по умолчанию не включает кернинг и стандартные/контекстные лигатуры): у harfrust и HarfBuzz свойства kern, liga, clig = 0; у CoreText — те же теги в kCTFontFeatureSettingsAttribute плюс kCTKernAttributeName = 0.0 («не кернить вовсе») и kCTLigatureAttributeName = 0 («только обязательные лигатуры»). default — настройки шейпера по умолчанию. Для шрифтов AAT без GSUB проверен ещё режим word-aat (выключены только kern и liga, см. раздел 5.2).

Метрики (все — в единицах шрифта; CoreText отдаёт пункты, они переводятся × upem / 12):

Корпус (kernel/spikes/shaping-bench/corpus/, по абзацу на строку, тексты написаны для спайка): ru — 14 абзацев (ё, ъ, кавычки-ёлочки, тире, цифры), en — 13, ligkern — 10 строк на лигатуры и кернинг (fi, ffl, AV, To, Ty, Wa, «Г»-образный, Уфа…), he — 12 (из них 3 с огласовками), ar — 12 (2 с харакатами, лам-алиф), hi — 11 (конъюнкты क्ष, ज्ञ, श्र, द्व, त्र्य…), zh — 6. Всего 236 абзацев × режим, ≈ 20,6 тыс. глифов. Все символы корпуса есть в cmap каждого шрифта (стенд проверяет покрытие; подстановок шрифта в CoreText нет).

Шрифты: комплект ядра kernel/fonts/ — Liberation Serif, Liberation Sans, Carlito (ru/en/ligkern, иврит у Liberation); системные macOS — Arial (Supplemental/Arial.ttf: латиница, кириллица, иврит, арабский; OpenType), Geeza Pro (GeezaPro.ttc, лицо 0: только AAT — morx, kern, feat, без GSUB/GPOS), Kohinoor Devanagari (Kohinoor.ttc, OpenType), Devanagari MT (Supplemental/DevanagariMT.ttc, только AAT — morx, feat), Hiragino Sans GB (Hiragino Sans GB.ttc, OpenType). PingFang в этой версии macOS нет в /System/Library/Fonts.

Скорость. Один проход = шейпинг всех абзацев набора. Сначала отдельно меряется холодный проход, затем 3 прогрева и 50 замеренных проходов; в таблицах — медиана, p10–p90, мкс на абзац и млн глифов в секунду. harfrust меряется в двух вариантах: «как в ядре» — шейпер (ShaperData::shaper().build()), набор свойств и план шейпинга строятся на каждый вызов, результат собирается в новый Vec с переводом в EMU (так сейчас делает shape_segment); «кэш плана» — шейпер один на шрифт, ShapePlan один на (письменность, направление, язык, свойства), буфер переиспользуется. CoreText (swiftc -O): в каждом проходе — CFAttributedString → CTLine → копирование глифов, индексов, продвижений и положений всех прогонов в заранее выделенные массивы (строки CFString построены заранее). HarfBuzz C — через hb-shape --num-iterations: (t(3001) − t(1)) / 3000, лучший из трёх запусков; это оценка, в ней есть накладные расходы цикла hb-shape на буфер.

3. Условия

Машина Apple M5 Max, 18 ядер, 128 ГБ
ОС macOS 27.0 (26A428)
Rust rustc 1.97.1 (8bab26f4f 2026-07-14), release: opt-level=3, thin LTO, codegen-units=1
Swift Apple Swift 6.4 (swiftlang-6.4.0.34.1), swiftc -O, CoreText системный
Шейперы harfrust 0.13.3, read-fonts 0.43.3, HarfBuzz 14.3.1 (hb-shape из Homebrew, шейперы ot, coretext)
Кегль 12 pt; абзац — одна строка без переноса

4. Замеры

4.1 Совпадение harfrust и CoreText

Сводно (236 абзацев на режим):

Пара Режим Глифы = Глифов Сдвигов > 0,5 ед. Σ|Δx|
harfrust ↔︎ CoreText word (как ядро) 225/236 (95,3 %); без Devanagari MT — 225/225 (100 %) 20 695 0 0
harfrust ↔︎ CoreText default 236/236 (100 %) 20 540 1 670 ед. (0,004 % ширины корпуса)
harfrust ↔︎ CoreText word-aat (только AAT-шрифты) 23/23 (100 %) 1 746 0 0
harfrust ↔︎ HarfBuzz C все режимы 100 % — 0 0
HB→CT ↔︎ CoreText (Swift) все режимы 100 % — 0 / 1 / 0 тот же глиф, что выше

По наборам (A = harfrust, B = CoreText; «HB C» — сравнение harfrust с HarfBuzz C; «ΔW от kern/liga» — насколько режим default у harfrust сужает строки относительно word, то есть что дают кернинг и лигатуры):

Корпус Шрифт Режим Абз. Глифов Глифы = CT Кластеры = CT Сдвигов Σ|Δx|, ед. |ΔW| ср./макс., % Σ|Δadv|, ед. HB C: глифы = / сдвигов ΔW от kern/liga, %
ru Liberation Serif word / default 14 1 434 100 % / 100 % 14/14 0 / 0 0 0 / 0 0 14/14 / 0 −0,44
ru Liberation Sans word / default 14 1 434 100 % / 100 % 14/14 0 / 0 0 0 / 0 0 14/14 / 0 −0,42
ru Carlito word / default 14 1 434 100 % / 100 % 14/14 0 / 0 0 0 / 0 0 14/14 / 0 −0,36
ru Arial word / default 14 1 434 100 % / 100 % 14/14 0 / 0 0 0 / 0 0 14/14 / 0 −0,42
en Liberation Serif word / default 13 1 418 100 % / 100 % 13/13 0 / 0 0 0 / 0 0 13/13 / 0 −0,09
en Liberation Sans word / default 13 1 418 100 % / 100 % 13/13 0 / 0 0 0 / 0 0 13/13 / 0 −0,09
en Carlito word / default 13 1 418 / 1 394 100 % / 100 % 13/13 0 / 0 0 0 / 0 0 13/13 / 0 −0,51
en Arial word / default 13 1 418 100 % / 100 % 13/13 0 / 0 0 0 / 0 0 13/13 / 0 −0,09
ligkern Liberation Serif word / default 10 756 100 % / 100 % 10/10 0 / 0 0 0 / 0 0 10/10 / 0 −2,83
ligkern Liberation Sans word / default 10 756 100 % / 100 % 10/10 0 / 0 0 0 / 0 0 10/10 / 0 −2,14
ligkern Carlito word / default 10 756 / 687 100 % / 100 % 10/10 0 / 0 0 0 / 0 0 10/10 / 0 −2,49
ligkern Arial word / default 10 756 100 % / 100 % 10/10 0 / 0 0 0 / 0 0 10/10 / 0 −2,14
he Liberation Serif word / default 12 829 100 % / 100 % 9/12 0 / 0 0 0 / 0 40 040 12/12 / 0 0,00
he Liberation Sans word / default 12 829 100 % / 100 % 9/12 0 / 0 0 0 / 0 45 040 / 50 683 12/12 / 0 −0,43
he Arial word / default 12 830 100 % / 100 % 9/12 0 / 0 0 0 / 0 19 106 12/12 / 0 0,00
ar Arial word 12 887 100 % 9/12 0 0 0 / 0 9 786 12/12 / 0 —
ar Arial default 12 887 100 % 9/12 1 670 (0,108 %) 0 / 0 8 498 12/12 / 0 0,00
ar Geeza Pro (AAT) word / default 12 875 100 % / 100 % 9/12 0 / 0 0 0 / 0 0 / 33 556 12/12 / 0 −0,96
ar Geeza Pro (AAT) word-aat 12 875 100 % 9/12 0 0 0 / 0 0 12/12 / 0 0,00 к word
hi Kohinoor Devanagari word / default 11 895 100 % / 100 % 0/11 0 / 0 0 0 / 0 8 144 11/11 / 0 0,00
hi Devanagari MT (AAT) word 11 933 (CT: 871) 0/11 (0 %) — — — 0,198 / 1,613 — 11/11 / 0 —
hi Devanagari MT (AAT) default 11 871 100 % 0/11 0 0 0 / 0 0 11/11 / 0 +0,20
hi Devanagari MT (AAT) word-aat 11 871 100 % 0/11 0 0 0 / 0 0 11/11 / 0 +0,20 к word
zh Hiragino Sans GB word / default 6 185 100 % / 100 % 6/6 0 / 0 0 0 / 0 0 6/6 / 0 0,00

Дополнительная проверка вне таблицы: системный вариативный шрифт .SF Hebrew (SFHebrew.ttf: оси wght, opsz 17–28, таблица trak) — ширина корпуса he у CoreText на 4,8 % больше, чем у HarfBuzz/harfrust с экземпляром по умолчанию (797 760 против 761 425 ед.); --font-ptem=12 у hb-shape ширину не меняет, opsz=17 даёт 818 805. То есть CoreText сам подбирает для таких шрифтов оптический размер и/или трекинг по кеглю; подробно не разбиралось (см. 5.4).

4.2 Скорость

Сводно по письменностям (сумма медиан по шрифтам набора / число абзацев; прогон из results-2026-10-04-m5max.md):

Корпус Режим harfrust, как в ядре harfrust, кэш плана CoreText HarfBuzz C HB→CT CoreText / harfrust (ядро) CoreText / harfrust (кэш)
ru word 2,32 1,13 15,64 1,37 18,94 ×6,7 ×13,8
ru default 3,40 2,36 9,84 2,93 9,94 ×2,9 ×4,2
en word 2,29 1,22 13,00 1,48 16,47 ×5,7 ×10,7
en default 3,09 2,10 6,94 3,21 8,29 ×2,2 ×3,3
ligkern word 1,94 0,86 12,66 1,07 15,53 ×6,5 ×14,8
ligkern default 2,65 1,60 6,26 2,39 7,10 ×2,4 ×3,9
he word 3,93 2,73 22,78 3,83 23,69 ×5,8 ×8,3
he default 4,05 3,41 16,93 4,41 14,41 ×4,2 ×5,0
ar word 6,56 5,33 33,18 5,30 28,49 ×5,1 ×6,2
ar default 6,80 6,05 22,53 6,94 19,86 ×3,3 ×3,7
hi word 12,24 10,47 69,22 14,21 50,03 ×5,7 ×6,6
hi default 12,52 10,65 51,31 14,64 40,59 ×4,1 ×4,8
zh word 1,31 0,44 7,87 0,50 13,05 ×6,0 ×17,7
zh default 1,26 0,45 2,14 0,44 3,03 ×1,7 ×4,7

Значения — мкс на абзац (медиана). Весь корпус в режиме word: harfrust «как в ядре» — 3,82 мкс/абзац (22,9 млн глифов/с), с кэшем плана — 2,63 (33,3 млн/с), CoreText — 22,2 (3,9 млн/с), HarfBuzz C — 3,29 (26,6 млн/с).

Устойчивость. Ещё два полных прогона по 50 повторов (без hb-shape) дали абсолютные времена на 20–35 % выше (на машине параллельно работали другие процессы), но отношения стабильны: CoreText / harfrust «как в ядре» в режиме word — ×6,4–7,2 (ru), ×5,7–5,9 (en), ×5,4–5,8 (he), ×4,3–5,1 (ar), ×4,2–5,7 (hi), ×6,0–6,2 (zh); в режиме default — ×1,7–4,2. Разброс p10–p90 внутри прогона у большинства наборов — единицы процентов у harfrust и до 10–30 % у CoreText; отдельные наборы под фоновой нагрузкой дают p90 до ×1,5–2 от медианы у обоих движков. Холодный (первый) проход: harfrust 4–31 мкс/абзац, CoreText 66–280 мкс/абзац (загрузка шрифта и кэшей CoreText). Полная таблица по шрифтам — results-2026-10-04-m5max.md.

5. Где расходятся и почему

5.1 Что выглядит как расхождение, но им не является

  1. Продвижения у знаков огласовки (иврит, арабский, деванагари). HarfBuzz/harfrust дают знаку нулевое продвижение и смещение x_offset; CoreText переносит это смещение в продвижения — знак получает отрицательное продвижение, а основа — увеличенное (например, 1301: +1067, 1262: 0 против 1301: +1867, 1262: −800). Положения всех глифов при этом совпадают до единицы шрифта (0 сдвигов во всех таких наборах), отсюда большие Σ|Δadv| при нулевых Σ|Δx|. Попутно: сумма CTRunGetAdvances у строк с огласовками не равна ширине строки (на 8 % в абзаце с полной огласовкой), поэтому ширину CoreText надо брать из CTLineGetTypographicBounds, а не складывать продвижения.
  2. Кернинг справа налево. В Liberation Sans (иврит, default) кернинг пары harfrust прибавляет к продвижению одного глифа, CoreText — соседнего; положения совпадают.
  3. Кластеры. harfrust (как и HarfBuzz, уровень кластеров 0) сливает знак с основой в один кластер и сохраняет кластер слога для переставленных матр деванагари; CoreText отдаёт каждому глифу индекс его собственного символа. Отсюда 9/12 (иврит, арабский — абзацы с огласовками) и 0/11 (деванагари). Это разные договорённости, а не разные глифы; ядру нужна именно семантика HarfBuzz — она монотонна и пригодна для курсора и выделения.

5.2 Настоящее расхождение 1: clig = 0 ломает AAT-шрифт деванагари

Devanagari MT — шрифт только AAT (morx без GSUB). В режиме ядра harfrust даёт 933 глифа вместо 871 у CoreText: часть конъюнктов и лигатур с матрами не собирается (например, य+े в «हाशिये» остаётся двумя глифами вместо одного), ширина строк в среднем на 0,2 % больше, в худшем абзаце — на 1,6 %. HarfBuzz C ведёт себя так же (11/11 совпадений с harfrust). Причина: harfrust переводит OpenType-свойство clig в селектор AAT «контекстные лигатуры» (тип 1, селектор 18/19), а в этом шрифте под этим селектором (флаг 0x40 цепочки morx) лежат обязательные конъюнкты. CoreText выключение clig (тегом OpenType) для AAT-шрифта не переводит, а kCTLigatureAttributeName = 0 оставляет «обязательные» лигатуры. Проверено hb-shape: -kern,-liga → 871 глиф, -kern,-liga,-clig → 933. Режим word-aat (для шрифтов с morx и без GSUB выключать только kern и liga) даёт 100 % совпадение глифов и положений с CoreText и на Devanagari MT, и на Geeza Pro. Для OpenType-шрифтов поправка ничего не меняет.

5.3 Настоящее расхождение 2: огласовка над лам-алифом (Arial, только default)

Один глиф из 20 540: фатха над лигатурой лам-алиф в «ظَلَامٌ» — harfrust ставит её в (1918, 294), CoreText (CTLine) — в (1248, 234), то есть привязывает к другому компоненту лигатуры (GPOS mark-to-ligature). Обёртка HarfBuzz над CoreText (--shapers=coretext) ставит знак так же, как harfrust, поэтому это особенность типографа CTLine, а не шрифта. В режиме ядра (word) расхождения нет.

5.4 Чего в CoreText нет у ядра (и не должно быть)

5.5 Что совпало без оговорок

Латиница и кириллица на всех четырёх шрифтах, оба режима: глифы, кластеры и положения идентичны, в том числе лигатуры Carlito (default: 1 394 вместо 1 418 глифов у обоих) и кернинг (−2,1…−2,8 % ширины строк набора ligkern у обоих). Это же подтверждает, что kCTKernAttributeName = 0 и kCTLigatureAttributeName = 0 действительно выключают кернинг и лигатуры CoreText. Иврит, арабский (включая AAT Geeza Pro с таблицей kern), Kohinoor Devanagari и китайский — глифы и положения идентичны.

6. Выводы

  1. На OpenType-шрифтах harfrust и CoreText дают одну и ту же раскладку. 225 из 225 абзацев в режиме ядра и 236 из 236 в режиме по умолчанию — одинаковые глифы; положения совпадают до 0,5 единицы шрифта у всех глифов, кроме одного (5.3). Системный шейпер не дал бы ядру ни одного «более правильного» глифа.
  2. harfrust полностью совпадает с HarfBuzz 14.3.1 (100 % глифов, 0 сдвигов во всех 49 сочетаниях «набор × режим»), то есть и с системным шейпингом Linux (Pango/HarfBuzz) при тех же шрифтах.
  3. AAT harfrust поддерживает сам (morx, kerx, kern, trak, mort): Geeza Pro и Devanagari MT шейпятся так же, как CoreText, — при условии поправки 5.2. Нынешний WORD_DEFAULT_OFF в shape.rs для AAT-шрифтов без GSUB неверен: выключение clig ломает конъюнкты.
  4. CoreText в 4–7 раз медленнее harfrust «как в ядре» в режиме ядра и в 1,7–4 раза — в режиме по умолчанию; с кэшем плана разрыв в режиме ядра ×6–18. Выключение свойств через атрибуты делает CoreText ещё в 1,3–3,7 раза медленнее его собственного режима по умолчанию. Холодный старт CoreText — 66–280 мкс на абзац против 4–31 у harfrust.
  5. В ядре есть дешёвый выигрыш ×1,2–3: shape_segment строит шейпер, набор свойств и план шейпинга на каждый отрезок; кэш Shaper на гарнитуру и ShapePlan на (письменность, направление, язык, свойства) снижает время с 3,82 до 2,63 мкс на абзац по всему корпусу (латиница — вдвое, китайский — втрое). harfrust с кэшем плана по корпусу быстрее цикла hb-shape (2,63 против 3,29 мкс; исключение — AAT Geeza Pro в режиме word, +17 %); без кэша — на 16 % медленнее HarfBuzz C по корпусу и до ×1,8 на латинице, где построение плана дороже самого шейпинга. Оценка «до 25 % медленнее HarfBuzz» из oss-text-render-lingo-math.md верна только для нынешнего вызова без кэша.
  6. Системный шейпинг нужен не для документов. Он оправдан только там, где цель — пиксель в пиксель как у приложения ОС (текст интерфейса оболочки — его и так шейпит Qt) или шрифты с неявной системной обработкой (opsz/trak скрытых шрифтов Apple). Для шрифтов Graphite у harfrust поддержки нет (HarfBuzz C — есть), но и CoreText Graphite не поддерживает; это отдельный риск (раздел 8), системный шейпер его не закрывает.

7. Решение (ADR-A8, 2026-10-04)

Контекст. Ядро обязано раскладывать документ одинаково на любой машине (§19 kernel-core.md); библия и архитектура уже говорят «шейпинг — всегда HarfBuzz», но называют устаревший rustybuzz, а про AAT и системные шейперы ничего не сказано.

Решение.

  1. harfrust остаётся единственным шейпером текста документов на всех ОС. CoreText, DirectWrite и Pango в раскладку документа не подключаем — ни основным путём, ни запасным. Платформенные API шрифтов — только для перечисления и чтения файлов шрифтов (fontique), шейпит всё равно harfrust.
  2. Поправка WORD_DEFAULT_OFF для AAT (задача в meridian-layout): если у шрифта есть morx и нет GSUB, выключать только kern и liga; clig не трогать. Тест — Devanagari MT на macOS-CI или любой свободный AAT-шрифт деванагари/арабицы в корпусе.
  3. Кэш шейпера и плана в FontFace: Shaper строится один раз, ShapePlan — по ключу (письменность, направление, язык, набор свойств); буфер UnicodeBuffer переиспользуется через GlyphBuffer::clear.
  4. Сверка с эталоном в CI: этот стенд (или его часть «harfrust ↔︎ HarfBuzz C») — ночной job на macOS при обновлении harfrust; сверка с CoreText — справочная, расхождения разбираются вручную, как в разделе 5.

Предложение в библию (docs/00-foundation.md, строка «Шейпинг текста»; docs/03-architecture.md §12 — заменить «HarfBuzz (rustybuzz)»):

Текст документов шейпит только harfrust закреплённой версии, одинаково на Windows, macOS и Linux; системные шейперы (CoreText, DirectWrite, Pango) в раскладку документа не допускаются — на OpenType-шрифтах они дают те же глифы и положения, но в 4–7 раз медленнее и зависят от версии ОС. Шрифты AAT (morx/kerx/trak) harfrust обрабатывает сам; для них режим Word выключает только kern и liga.

Последствия. Детерминизм вёрстки сохраняется без оговорок по платформам; на macOS документы с AAT-шрифтами шейпятся так же, как в Pages, после поправки 2; выигрыш скорости от поправки 3 — ×1,2–3 на шейпинге.

8. Риски и ограничения спайка

9. Как повторить

kernel/spikes/shaping-bench/run.sh results 50     # swiftc -O, cargo build --release, полный прогон (≈ 15 мин из-за hb-shape)
cd kernel/spikes/shaping-bench
./target/release/shaping-bench --reps 50 --no-hb-speed   # без замера hb-shape — несколько секунд
./target/release/shaping-bench --only hi --diffs diffs.txt   # один корпус и подробный разбор расхождений

Нужны macOS (CoreText, swiftc из Xcode Command Line Tools) и hb-shape из HarfBuzz по пути /opt/homebrew/bin/hb-shape. Результат — results.md (таблицы), results.jsonl (сырые строки agree/speed), results-diffs.txt (первое расхождение каждого абзаца с контекстом и кодами символов).