Архитектура
Один проход, шесть фаз
Вход читается один раз. Порядок фаз жёсткий, внутри фазы правила независимы.
| # | Фаза | Что делает |
|---|---|---|
| 1 | Prepare |
метка порядка байт, табуляция, декодирование типографских сущностей |
| 2 | Protect |
вход делится на текстовые узлы, разметку и защищённые зоны |
| 3 | Scan |
посимвольный проход: кавычки, тире, апостроф, многоточие, символы, числа, пробелы |
| 4 | Bind |
словарный проход по словам: неразрывные пробелы, сокращения, инициалы, единицы |
| 5 | Layout |
неразрывные блоки, висячая пунктуация, переносы, абзацы |
| 6 | Emit |
кодирование по EntityMode и запись в приёмник |
Protect не отдельный проход по документу: сегментация вызывается ВНУТРИ каждой фазы —
фаза сама идёт по сегментам, копирует разметку байт в байт и применяет свою логику только
к текстовым узлам.
Правило попадает в ту фазу, в которой его результат успевают увидеть остальные. Замена
табуляции на пробелы, например, живёт в Prepare, а не среди правил пробелов: сделанная
позже, она оставила бы новые пробелы невидимыми для фаз Scan и Bind — и второй прогон
дал бы результат, отличный от первого.
Буфер, который можно править задним числом
Всё пишется в растущий буфер поверх ArrayPool<char>. Буфер разрешает менять уже
записанные позиции по индексу — за счёт этого правила с длинным контекстом работают без
второго прохода: правило, которому нужен взгляд назад, не откладывает решение, а
исправляет то, что уже записано.
Буфер — изменяемая структура, передаётся только по ссылке и никогда не боксируется: иначе обещание про ноль аллокаций не выполнялось бы.
Правила как биты
RuleSet — иммутабельная маска на 128 бит (два ulong). Проверка «включено ли правило»
стоит одну инструкцию и не аллоцирует, поэтому её можно делать в горячем цикле для
каждого символа. RuleId.Index — позиция бита; индекс 0 зарезервирован под
default(RuleId), чтобы значение по умолчанию не совпадало ни с одним правилом.
Реестр закрыт: 107 правил, имена совпадают с именами JS-typograf.
Гарантии
- Время работы линейно от длины входа.
- Исключений нет ни на каком входе, кроме объявленного
OutputTooLargeException. - Разметка копируется байт в байт. Исключение — правило
common/html/escape, которое для того и существует. RuleSet.Defaultникогда не превращает текст в разметку.- Повторный прогон по собственному выводу ничего не меняет. Исключения объявлены:
UseBr,MaxNobr,common/nbsp/replaceNbsp,common/html/nbr,common/html/escape. - Если правок нет,
Process(string)возвращает тот же экземпляр строки. - Ломаный UTF-16 проходит насквозь без исключения и без потери символов.
Гарантии проверяются тестами, а не декларируются: см. tests/Typographer.Tests/Guarantees.
Как это проверяется
| Уровень | Что ловит |
|---|---|
| Unit и property | правила поштучно и каждое правило реестра в одиночку |
| Golden-корпус | связный текст целиком, пары «вход — эталон» в файлах |
| Справочник правил | правило, добавленное в реестр мимо документации |
| Снимок оракула | расхождения с typograf.artlebedev.ru, закреплённые таблицей |
| Живая сверка | не устарел ли сам снимок (с сетью, вне CI) |
| Фаззинг | падение конвейера на произвольном входе |