Спецификация: типограф русского языка на .NET
Документ фиксирует требования и архитектурные решения, согласованные до начала реализации. На него ссылается план реализации.
Имена файлов документации — латиницей (совместимость с DocFX, CI и URL), содержимое — на русском.
1. Цель
Библиотека, которая приводит русский текст к типографским нормам: кавычки-ёлочки с
правильной вложенностью, тире вместо дефисов там, где это тире, неразрывные пробелы
в местах, где перенос строки недопустим, корректные символы вместо суррогатов
((c), ->, 1/2, x).
Работает и с обычным текстом, и с HTML-фрагментами. Закрытый «Типограф» Артемия
Лебедева задаёт семантику параметров (entityType, useBr, useP, maxNobr),
но не форму API.
2. Приоритеты (в порядке убывания)
- Производительность
- Оптимальность и эффективность кода
- Developer experience
- Безопасность
- Структурность репозитория
- Полнота документации
- Документация на русском языке
Приоритеты — критерий разрешения споров: если удобство API требует аллокации в горячем пути, побеждает производительность. Гарантии раздела 8 размену не подлежат.
3. Источники истины
Приоритет при расхождении: ГОСТ Р 7.0.110-2025 ⇒ Мильчин и Чельцова ⇒ практика студии Лебедева ⇒ JS-typograf.
| Источник | Что берём |
|---|---|
| ГОСТ Р 7.0.110-2025 «Требования к типографическому оформлению» | Неразрывные пробелы (9.4–9.5), диапазоны (14.3), дефис и тире (16.1–16.2), кавычки и вложенность (16.3), знак номера (16.4), скобки (16.5) |
| Мильчин А. Э., Чельцова Л. К. «Справочник издателя и автора» | Сокращения, числа и знаки, даты и время, цитаты |
| type.today, «Справочник: кавычки» | Три уровня вложенности «» → „“ → ‘’, отличие кавычек от штрихов (дюйм, фут, минута, секунда) |
| typograf/typograf (MIT) | Имена и состав 107 правил, набор регрессионных случаев |
| typograf.artlebedev.ru | Семантика entityType/useBr/useP/maxNobr, поведение как эталон-оракул |
Каждое правило в коде несёт ссылку на пункт источника; ссылка попадает в генерируемый справочник правил.
Известное расхождение, зафиксированное намеренно: ГОСТ 14.3 требует диапазон без
отбивки (1941—1945), практика веба ставит неразрывный пробел (1941 — 1945).
RuleSet.Default следует ГОСТу, RuleSet.Lebedev — практике.
4. Публичный API
Пространство имён Typographer, и главный тип называется так же. Совпадение имени
типа с именем пространства имён намеренное: вызов Typographer.Html(text) читается
ровно как название пакета, и ради этого стоит потерпеть CA1724.
4.1 Точки входа
// Статический фасад — дефолтные настройки, ноль церемоний
public static class Typographer
{
public static string Html(string html);
public static string PlainText(string text);
}
// Настраиваемый, иммутабельный, потокобезопасный
public sealed class HtmlTypographer
{
public HtmlTypographer(HtmlOptions? options = null);
public static HtmlTypographer Default { get; }
public string Process(string html);
public void Process(ReadOnlySpan<char> html, IBufferWriter<char> destination);
}
public sealed class TextTypographer
{
public TextTypographer(TextOptions? options = null);
public static TextTypographer Default { get; }
public string Process(string text);
public void Process(ReadOnlySpan<char> text, IBufferWriter<char> destination);
}
Разделение на два типа — не стилистика: UseBr, UseP, MaxNobr и Entities
не имеют смысла вне HTML, и в TextOptions их физически нет. Невалидная
комбинация не компилируется.
4.2 Настройки
public sealed record TextOptions
{
public RuleSet Rules { get; init; } = RuleSet.Default;
/// <summary>Предел длины результата в символах. 0 — без ограничения.</summary>
public int MaxOutputLength { get; init; }
public static TextOptions Default { get; } = new();
}
public sealed record HtmlOptions
{
public RuleSet Rules { get; init; } = RuleSet.Default;
/// <summary>Как выводить типографские символы. Соответствует entityType веб-сервиса Лебедева.</summary>
public EntityMode Entities { get; init; } = EntityMode.Symbols;
/// <summary>Заменять перевод строки на br.</summary>
public bool UseBr { get; init; }
/// <summary>Оборачивать абзацы в p.</summary>
public bool UseP { get; init; }
/// <summary>Максимальное число слов, объединяемых в nobr. 0 — не объединять.</summary>
public int MaxNobr { get; init; }
public int MaxOutputLength { get; init; }
public static HtmlOptions Default { get; } = new();
}
public enum EntityMode
{
/// <summary>Символами: « » и U+00A0. Соответствует entityType = 0.</summary>
Symbols = 0,
/// <summary>Буквенными кодами: laquo, mdash, nbsp. entityType = 1.</summary>
Named = 1,
/// <summary>Числовыми кодами: 171, 8212, 160. entityType = 2.</summary>
Numeric = 2,
/// <summary>Смешанно: невидимые символы кодами, видимые — символами. entityType = 3.</summary>
Mixed = 3,
}
4.3 Правила
public readonly struct RuleId : IEquatable<RuleId>
{
/// <summary>Имя вида «ru/nbsp/abbr». Совпадает с именами JS-typograf.</summary>
public string Name { get; }
public static bool TryParse(string name, out RuleId rule);
public static class Common { public static class Nbsp { public static RuleId AfterShortWord { get; } } }
public static class Ru { public static class Nbsp { public static RuleId Abbr { get; } } }
}
public sealed class RuleSet : IReadOnlyCollection<RuleId>
{
public static RuleSet Default { get; } // безопасная типографика
public static RuleSet Lebedev { get; } // поведение веб-сервиса Лебедева
public static RuleSet Typograf { get; } // паритет дефолтов с JS-typograf
public static RuleSet Gost { get; } // строго по ГОСТ Р 7.0.110-2025
public static RuleSet All { get; }
public static RuleSet Minimal { get; } // кавычки, тире, многоточие
public static RuleSet None { get; }
public RuleSet With(params RuleId[] rules);
public RuleSet Without(params RuleId[] rules);
public bool Contains(RuleId rule);
}
RuleSet иммутабелен, внутри — битовая маска на 128 бит (два ulong), проверка
включённости правила стоит одну инструкцию и не аллоцирует.
Пример:
var typographer = new HtmlTypographer(new HtmlOptions
{
Entities = EntityMode.Named,
UseBr = true,
MaxNobr = 3,
Rules = RuleSet.Default
.Without(RuleId.Ru.Money.Ruble)
.With(RuleId.Ru.OptAlign.Quote),
});
string html = typographer.Process(source);
5. Конвейер: пять фаз
Порядок фаз жёсткий. Внутри фазы правила независимы: результат не зависит от порядка их применения, и это проверяется тестом на случайных перестановках.
Сегментация входа (разметка и защищённые зоны отделяются от текстовых узлов) не
отдельная фаза: MarkupScanner вызывается ВНУТРИ каждой из пяти фаз — фаза сама
проходит документ по сегментам, копирует разметку байт в байт и применяет свою
логику только к текстовым узлам.
| # | Фаза | Что делает |
|---|---|---|
| 1 | Prepare |
BOM, распознавание переводов строк (без нормализации — см. 5.4), табы, обрезка, декодирование типографских сущностей в символы |
| 2 | Scan |
Посимвольный проход: кавычки, тире, апостроф, многоточие, символы, числа, чистка пробелов и пунктуации |
| 3 | Bind |
Словарный проход по словам: неразрывные пробелы, интервалы, сокращения, инициалы, единицы |
| 4 | Layout |
nobr-группы, висячая пунктуация, br, p |
| 5 | Emit |
Кодирование по EntityMode, запись в приёмник |
Фазы 2–4 пишут в общий изменяемый буфер и имеют право патчить уже записанные позиции по сохранённому индексу. Это ключевое решение: оно позволяет правилам с длинным контекстом («неразрывный пробел перед последним коротким словом предложения») работать за один проход — кандидат запоминается как индекс, а решение принимается при встрече точки и записывается назад за O(1).
5.1 Защищённые зоны: что считается защищённой
Копируется байт-в-байт, типографика внутрь не применяется:
- содержимое
code,pre,script,style,textarea,kbd,samp; - любые теги целиком, включая значения атрибутов;
- комментарии, в том числе условные комментарии IE;
CDATA,DOCTYPE, инструкции обработки;- HTML-сущности, не входящие в таблицу типографских (например,
&,<).
Ломаный HTML не является ошибкой: незакрытый тег, > внутри значения атрибута,
одинокий < — всё это выводится как есть, обработка продолжается.
Отдельно защищён веб-адрес в тексте. Адрес — машинный идентификатор, а не текст:
правка меняет то, куда он ведёт, а при включённом common/html/url испорченный адрес
попадает ещё и в href. Внутри адреса не работает ни одно правило фаз Scan и Bind: точка
в ?v=1.5 не десятичный разделитель, /1/2 не половина, дефис в /2020-2026 не тире,
!= не знак неравенства, ( не начало (c), одиннадцать цифр в /tel/89991234567 не
телефон, /м2 не квадратный метр, /2018-10-10 не дата. Адрес и не токен: слово за ним
к его хвосту не привязывается.
- начало адреса — последовательность
://; - конец — пробельный символ,
<,>, прямая или закрывающая типографская кавычка (»,“,”) либо любой тег; правая одинарная кавычка между буквами — апостроф, адрес не завершает; - прямой апостроф адрес не завершает вовсе: он законен внутри адреса (RFC 3986), и
?q='x y'рвать нельзя; автоссылка отрезает с хвоста непарный апостроф как внешнюю кавычку ('http://a.ru'), но сохраняет закрывающий апостроф параметра (?q='hello'); - прямая кавычка завершает адрес и тогда, когда во входе она записана сущностью
": правилоcommon/html/quotраскодирует её в фазе Prepare, до защиты адреса, и?q="x"обрывается на первой кавычке. Кавычка в адресе недопустима (RFC 3986) и кодируется как%22; сущность внутри адреса переживает только выключенноеcommon/html/quot; - границы одни на три фазы — Scan, Bind и автоссылку
common/html/urlфазы Layout, — иначе кавычка, завершившая адрес для одной фазы, уходила вhrefу другой; - угловая скобка адрес завершает потому, что в тексте она неотличима от начала тега:
?a=1<=2защищённым не остаётся, и это осознанная цена гарантии 3.
5.2 Фаза Scan: кавычки
Стек уровней: «» → „“ → ‘’. Открывающая или закрывающая определяется
предыдущим символом: после пробела, начала текста или открывающей скобки —
открывающая, иначе закрывающая. Дополнительно:
"сразу после цифры при пустом стеке — дюйм или секунда, остаётся прямой (17",3' 25");'между буквами — апостроф’(д’Артаньян,O’Neil);'после цифры — фут или минута, остаётся прямой;- кавычки вокруг ссылки выносятся за тег;
- перед закрывающей кавычкой допустимы
?,!, многоточие и точка после сокращения (и т. д.).
5.3 Фаза Scan: тире, дефис, минус
- дефис между словами без пробелов остаётся дефисом (
из-за,по-русски,кто-то); - дефис, окружённый пробелами, между словами — длинное тире с неразрывным пробелом слева;
- дефис между числами — короткое тире без отбивки (
1941—1945вGost,1941 — 1945вLebedev); - дефис перед числом после пробела — минус (
от -5 до +5); - дефис в начале строки или абзаца — тире прямой речи.
5.4 Переводы строк: распознаются, а не нормализуются
Библиотека не переписывает \r\n на \n или наоборот: перезапись чужих байт нарушила бы
гарантию 3 (разметка и, шире, посимвольный состав входа не меняются без необходимости) и
гарантию 6 (тот же экземпляр строки на входе без правок). Вместо нормализации оба варианта
перевода строки распознаются там, где решение зависит от границы слова или абзаца:
- возврат каретки (
\r) — граница слова наравне с пробелом и переводом строки; - граница абзаца (для
HtmlOptions.UseP) — и Unix-форма ("\n\n"), и Windows-форма ("\r\n\r\n").
6. Состав правил
107 правил, имена совпадают с JS-typograf. Полный справочник генерируется из кода
в docs/rules.md; здесь — группы и дефолты.
| Группа | Кол-во | Фаза | В Default |
|---|---|---|---|
common/space/* — пробелы и пунктуационные пробелы |
20 | Scan | кроме нормализации* |
common/punctuation/* — кавычки, апостроф, многоточие, двойная пунктуация |
5 | Scan | да |
common/symbols/* — (c), (tm), стрелки, градусы |
3 | Scan | да |
common/number/* — дроби, знак умножения, неравенства, разряды |
4 | Scan | кроме digitGrouping† |
common/nbsp/* — неразрывные пробелы общего вида |
10 | Prepare / Bind | кроме replaceNbsp** |
common/html/* — сущности, br, p, автоссылки, экранирование |
8 | Prepare / Layout / Emit | только quot*** |
common/other/* — BOM, повтор слова |
2 | Prepare / Bind | только delBOM |
ru/dash/* — тире и дефисы русского языка |
18 | Scan | да |
ru/nbsp/* — сокращения, инициалы, адреса, единицы, номер, параграф |
16 | Bind | да |
ru/punctuation/* — двойные знаки, ?.., запятые перед «а» и «но» |
4 | Scan | кроме ano |
ru/number/* — десятичная запятая, порядковые |
2 | Scan | да |
ru/date/* — ISO-даты, дни недели |
2 | Bind | да |
ru/money/* — валюта, рубль |
2 | Bind | нет |
ru/space/* — пробелы, специфичные для русского |
2 | Scan | да |
ru/symbols/* — сдвоенный знак номера |
1 | Scan | да |
ru/optalign/* — висячая пунктуация |
3 | Layout | нет |
ru/other/* — ударения, телефоны |
2 | Bind | только телефоны |
ru/typo/* — раскладка клавиатуры |
1 | Bind | нет |
en-GB, en-US — тире |
2 | Scan | нет |
† common/number/digitGrouping — единственное правило реестра, работающее в ДВУХ фазах.
В фазе Scan оно разбивает длинное число по разрядам (1000000 → 1 000 000), а в фазе
Bind делает неразрывными пробелы, которые между разрядами уже стоят в тексте автора
(1 000 000, ГОСТ 9.5). Вторая половина в фазе Scan невыразима: пробел там разделяет два
токена, а токенов фаза Scan не знает. В реестре правило числится за фазой Scan — по своей
основной работе, — но проход фазы Bind включает тоже.
* Семь правил группы common/space/* — trimLeft, trimRight, delLeadingBlanks,
delTrailingBlanks, delRepeatN, replaceTab, insertFinalNewline — нормализуют
пробельное письмо, а не типографику: они обрезают края фрагмента, переписывают отступы и
разворачивают табы. Тот, кто вызвал Typographer.Html(text), такого не ожидает, поэтому в
Default их нет. Побочная выгода: пока ни одно из них не включено, документный проход
нормализации не запускается и не стоит ни буфера, ни копии документа.
** Правило common/nbsp/replaceNbsp снимает неразрывные пробелы ПЕРЕД типографированием,
чтобы правила расставили свои. Оно вне Default по той же причине, что и правила
нормализации: неразрывный пробел во входе поставлен руками и означает «здесь рвать нельзя», а
правило это решение стирает. Работает только вместе с правилами фазы Bind; включённое в
одиночку, оставляет текст вовсе без неразрывных пробелов.
*** Два правила группы common/html/* не реализуются, а не отложены: stripTags
(удаление тегов — это санитайзинг, а не типографика, и он противоречит гарантии 3) и
processingAttrs (типографирование значений атрибутов противоречит той же гарантии, а
реализация требует разбора значений внутри тега и повторного запуска конвейера на каждом из
них). Оставшиеся шесть работают; правила этой группы имеют смысл только в HTML-режиме, и
TextTypographer их не применяет.
Правило common/punctuation/quoteLink зарегистрировано, но НЕ РЕАЛИЗУЕТСЯ: оно выносит
кавычки за пределы ссылки, то есть переносит текст через границу тега вопреки гарантии 3.
Выключены в Default, но реализованы: ru/punctuation/ano (сам расставляет
запятые), common/other/repeatWord (удаляет повтор слова), ru/other/accent
(меняет запись слова), ru/money/* (меняет запись суммы),
common/nbsp/replaceNbsp (стирает авторский неразрывный пробел),
common/number/digitGrouping, ru/dash/to, ru/dash/kakto, ru/dash/de
(омонимичные частицы), en-GB/dash/main и en-US/dash/main, а также
common/html/url, common/html/e-mail, common/html/nbr, common/html/p,
common/html/escape и ru/optalign/* (делают тег из текста или преобразуют разметку).
Причина одна: правило, меняющее смысл или запись текста — а тем более создающее
разметку, — не должно срабатывать у того, кто просто вызвал Typographer.Html(text).
Правила, создающие или преобразующие разметку, перечислены в коде массивом
RuleId.Registry.MarkupChanging; тест стережёт, что ни одно из них не попало в Default.
Не реализованы всего три правила, и каждое — осознанный отказ с причиной в
XML-комментарии: common/punctuation/quoteLink, common/html/stripTags и
common/html/processingAttrs. Четвёртое, ru/typo/switchingKeyboardLayout,
зарегистрировано и отложено с первого плана: оно требует таблицы соответствия раскладок и
решения о том, когда латиница в русском тексте — опечатка, а когда намеренная вставка.
7. Локализация
Только русский язык. Латинские вставки обрабатываются общими правилами
(common/*); детектор языка не делается. Правила en-GB/dash/main и
en-US/dash/main реализованы и доступны через RuleSet, но в Default выключены.
8. Гарантии
Контракт, который документируется и проверяется тестами:
- Время O(n), память O(n). Один проход чтения, один буфер вывода из
ArrayPool<char>. - Не бросает исключений ни на каком входе, кроме
ArgumentNullExceptionнаnullиOutputTooLargeExceptionпри превышенииMaxOutputLength. - Разметка не меняется. Всё, что было тегом, атрибутом или защищённой зоной, выходит
байт-в-байт. Единственное исключение — правило
common/html/escape, которое существует ровно ради обратного: оно показывает разметку как текст. Оно внеDefault, и включает его только тот, кто просит именно этого. - Текст никогда не становится разметкой. Ни одно правило
Defaultне создаёт тег из текстового содержимого. - Идемпотентность:
f(f(x)) == f(x)для любого входа и любого набора правил. Проверяется не только на пресетах, но и на каждой из 5671 пары правил реестра (RulePairTests). Три пары нарушают гарантию и закреплены списком в тесте: правило, переписывающее токен, меняет его границы, а соседнее правило той же фазы их уже не пересматривает. В пресетах ни одна из трёх не проявляется. Исключение — опцииHtmlOptions.UseBrиMaxNobr: они рассчитаны на однократное применение к исходному тексту и оборачивают его в разметку (<br />,<nobr>). Прогон по СОБСТВЕННОМУ ВЫВОДУ типографа с той же опцией вкладывает разметку в саму себя (<nobr><nobr>текст</nobr></nobr>), потому что фазаProtectне отличает тег, поставленный этими опциями, от тега, который уже был во входе. Учить её такому распознаванию не стали намеренно: это эвристика уровня разбора структуры документа, которая не окупается ценой ради двух опций компоновки. Гарантия распространяется на правилаRuleSet— они патчат только символы, а не оборачивают текст в новые теги.UsePпод исключение не попадает: абзацы размечаются по документу целиком, а документ, в котором блочная разметка уже есть, второй раз не размечается. Второе исключение — правилоcommon/nbsp/replaceNbsp. Оно снимает неразрывные пробелы перед типографированием, а пробел, поставленный ОДНОКРАТНЫМ превращением (-в тире,руб.в знак рубля, разряды длинного числа), на втором прогоне восстановить уже нечем: исходной формы в тексте нет. Это свойство самого правила, а не дефект реализации, и поэтому оно внеDefault. Третье и четвёртое исключения — правилаcommon/html/nbrиcommon/html/escape. Первое делает то же, что опцияUseBr, и точно так же вкладывает теги переноса повторно. У второго неподвижной точки нет по определению: амперсанд, ставший&, на следующем прогоне станет&amp;. Оба внеDefault. - Если правок нет — возвращается исходный экземпляр строки (
ReferenceEquals(input, output)). - Битый UTF-16 (одинокие суррогаты) проходит насквозь без исключения и без потери символов.
- Регулярных выражений в горячем пути нет, поэтому катастрофический бэктрекинг невозможен.
9. Тестирование
Три уровня в CI плюс один вне CI.
- Unit — по тесту на правило, включая отрицательные случаи («не должно сработать»).
- Golden-корпус —
tests/Typographer.Corpus/, пары*.in.txtи*.out.txt. - Property — идемпотентность, целостность разметки, отсутствие изменений в тексте без целевых символов, устойчивость на случайных данных.
- Оракул (вне CI, помечен
[Trait("Category", "Oracle")]) — сверка сtypograf.artlebedev.ru, расхождения выводятся отчётом.
Корпус сложных случаев, обязательный к покрытию:
| Категория | Примеры |
|---|---|
| Вложенные кавычки | Эксперт уточнил: «в перечень включен закон „О стандартизации“» |
| Кавычки против штрихов | 17", 3' 25", 5'6", 30°15'20", диагональ 15,6" |
| Апостроф | д’Артаньян, O’Neil, Кот-д’Ивуар |
| Тире, дефис, минус | из-за, по-русски, -5 °C, от -5 до +5, - Привет, - сказал он., 1941-1945 |
| Числа-ловушки | 3.14 в 3,14, но не 1.2.3, не 192.168.0.1, не 01.01.2020 |
| Сокращения | А.С. Пушкин, в 3 г. IV в. до н. э., ул. Ленина, д. 5, кв. 7, 10 км/ч, 100 %, 25 °C |
| Многоточие и смайлы | ?.., !.., :-) не должен стать тире |
| HTML-ловушки | <code>a - b</code>, <a title="Не трогать - тире">, <b>сло</b>во, кавычки вокруг ссылки, условный комментарий IE |
| URL и почта | http://example.com/a-b?x=1&y=2 |
| Разрыв контекста тегом | $<b>100</b>, 5<sup>2</sup> м |
| Смешанный ru/en | Windows 10 - "лучшая" ОС |
| Юникод-мусор | BOM, одинокие суррогаты, zero-width, уже стоящие nbsp |
Как запускать:
| Уровень | Команда |
|---|---|
| Всё, что идёт в CI | dotnet test |
| Перегенерация golden-корпуса | TYPOGRAPHER_UPDATE_CORPUS=1 dotnet test tests/Typographer.Tests |
| Перегенерация списка расхождений | TYPOGRAPHER_UPDATE_ORACLE=1 dotnet test tests/Typographer.Tests |
| Живая сверка с оракулом | TYPOGRAPHER_ORACLE=1 dotnet test tests/Typographer.Oracle |
| Фаззинг | workflow Fuzz — по расписанию и вручную |
Оба режима перегенерации намеренно ЗАВАЛИВАЮТ тест: перезапись эталона проверкой не является, и зелёный прогон обманул бы того, кто запустил перегенерацию и забыл снять переменную.
Расхождения с оракулом Лебедева закреплены тестом OracleSnapshotTests: он идёт по
закоммиченному снимку docs/oracle/lebedev.md без сети и падает в обе стороны — и когда
совпадение сломалось, и когда записанное расхождение исчезло. Читаемая проекция списка —
docs/oracle/divergences.md. Живая сверка со СЛУЖБОЙ вынесена в tests/Typographer.Oracle
и отвечает на другой вопрос: не устарел ли сам снимок.
Каждое правило реестра проверяется ещё и в одиночку (EveryRuleTests): включённое одно,
без соседей по фазе, оно не должно падать, нарушать идемпотентность, менять разметку или
трогать текст, в котором его знаков нет. Порядок правил внутри фазы задан диспетчером и
снаружи не меняется, поэтому переставлять правила незачем — прогон в одиночку проверяет
то же свойство строже.
Фаззинг — SharpFuzz, отдельный workflow по расписанию; падение считается багом.
Бенчмарки — BenchmarkDotNet. Цель:
- пропускная способность не менее 50 млн символов в секунду в один поток на эталонной машине;
- ноль байт аллокаций в куче на пути
Process(ReadOnlySpan<char>, IBufferWriter<char>).
Замеры от 10 сентября 2026 — docs/perf.md. Второе выполнено. Первое нет: получается
27 млн символов в секунду при закрытом реестре из 107 правил, и попытка ускорения дала
6–13 %, а не двукратный рост.
Профиль по фазам объясняет, почему двукратного роста и не будет: цель в 50 млн символов
в секунду — это 444 мкс на эталонном входе, а пол конвейера БЕЗ ЕДИНОГО ПРАВИЛА — 253 мкс
плюс 193 мкс на машинерию фазы Bind. Дело не в медленных правилах, а в том, что фаз
пять и каждая проходит документ целиком. Измеренный потолок нынешней архитектуры — около
30 млн символов в секунду; взять цель можно только слиянием фаз, то есть разменом
гарантий 3 и 5 на скорость. Решение — менять цифру или перестраивать конвейер — за
владельцем.
Сравнение с string.Replace приводится в отчёте как ориентир масштаба, но
критерием не является: это векторизованный поиск подстроки, и шестифазный
конвейер со ста семью правилами не может быть с ним одного порядка.
10. Пакеты
Граница пакета — внешняя зависимость. Ядро зависимостей не имеет.
| Пакет | Содержимое | Зависимости | Платформы |
|---|---|---|---|
Typographer |
Ядро: HTML и plain text, правила, опции | нет на net8.0 и net10.0; на netstandard2.0 — один официальный полифил System.Memory |
netstandard2.0;net8.0;net10.0 |
Typographer.DependencyInjection |
AddTypographer() |
Microsoft.Extensions.DependencyInjection.Abstractions |
netstandard2.0;net8.0;net10.0 |
Typographer.AspNetCore |
TagHelper и IHtmlContent |
ASP.NET Core | net8.0;net10.0 |
Typographer.Markdig |
Типографика Markdown как расширение конвейера | Markdig |
netstandard2.0;net8.0;net10.0 |
dotnet-typographer |
CLI как dotnet tool | ядро | net10.0 |
Типографика Markdown применяется к дереву документа после разбора, а не к готовому HTML:
рендерер кодирует прямую кавычку в ", а типограф сущности разметки намеренно не
декодирует (5.1), и кавычки-ёлочки в отрендеренном HTML не появились бы вовсе.
Структура репозитория:
src/Typographer/ ядро
src/Typographer.DependencyInjection/
src/Typographer.AspNetCore/
src/Typographer.Markdig/
src/Typographer.Cli/
tests/Typographer.Tests/ unit и property
tests/Typographer.Corpus/ golden-файлы
tests/Typographer.Oracle/ сверка с сервисом Лебедева, вне CI
tests/Typographer.Integrations.Tests/ тесты пакетов интеграций
bench/Typographer.Bench/ BenchmarkDotNet
fuzz/Typographer.Fuzz/ цель libFuzzer
docs/ документация и DocFX
Целевые платформы ядра: netstandard2.0, net8.0, net10.0. У пакетов интеграций —
по таблице выше: их ограничивают собственные зависимости.
11. Документация
- XML-комментарии на русском на каждом публичном члене,
GenerateDocumentationFileвключён, предупреждения — ошибки. docs/на русском: быстрый старт, архитектура, рецепты, источники.docs/rules.mdгенерируется из метаданных правил, примеры берутся из реальных тестов. Тест в CI падает, если файл разошёлся с кодом.- Сайт DocFX публикуется на GitHub Pages при релизе.
12. Версионирование и релизы
MinVer, теги вида 1.2.3 без префикса. Публикация в NuGet начиная с 0.1.0.
Ветвление — git flow: master (релизы), develop (интеграция), feature/*,
release/*, hotfix/*.
13. Вне рамок
- Ёфикация и проверка орфографии.
- Расстановка переносов.
- Языки, кроме русского (английские правила тире — только как опция).
- Полный HTML5-парсер: работаем со фрагментами и не нормализуем чужую разметку.