35 KiB
Отчёт по аудиту и оптимизации
Проведён 9-10 сентября 2026. Незакрытые задачи — в TODO.md, команды ручной чистки git — в git-cleanup.md.
Ничего не закоммичено. Всё лежит в staging и рабочем дереве под ревью.
Итог одной таблицей
| Было | Стало | |
|---|---|---|
| Замер страницы из 5 абзацев | 80.06 мс | 18.08 мс |
Конструирований Font за проход |
19 | 0 |
Вызовов get_text_width за проход |
814 | 114 |
| Конвертация формулы | 13.29 мс | 0.66 мс |
| PlantUML, 4 диаграммы, PNG | 4.99 с | 1.25 с |
| PlantUML, 4 диаграммы, SVG | 9.02 с | 2.33 с |
| Запусков JVM на документ | N (2N в SVG) | 1 (2 в SVG) |
| Сквозная конвертация документа | — | на 10-23% быстрее |
| Тесты | 54 passed | 320 passed, 174 subtests |
Объём изменений: 34 файла правок (+1552 / −698), плюс 34 файла удалений (−8095 строк).
1. Исправлено три бага вывода
Работа шла строго по TDD: сначала три падающих теста, прогон с 9 падениями по ожидаемым причинам, затем правки. Каждый фикс привязан к пункту методички МИРЭА (docs/reference/method-mirea-2022.txt).
Гиперссылка дублировала свой текст
md2gost/renderable/paragraph.py, строка 44. В Link.add_run цикл по сегментам присваивал docx_run.text = text вместо part, поэтому ссылка с дефисом выводилась полным текстом столько раз, сколько в ней сегментов.
Красный тест показал это нагляднее всего на интервале: ['100', '150'] != ['100-150', '100-150']. На сложном слове: 'вопервых' != 'во-первыхво-первых'.
Опечатка при копировании из Paragraph.add_run, где на строке 104 тот же цикл написан правильно.
Обоснование: строки 373-376 методички — дефис не отделяется пробелами и используется в сложных словах («во-первых», «физико-математический») и интервалах («100-150 Мб»), поэтому слово не должно рваться при переносе. Ради этого и вставляется отдельный элемент w:noBreakHyphen вместо текстового дефиса.
Курсив в подсветке синтаксиса не доезжал до XML
md2gost/renderable/listing.py, строка 36. Выражение style["italic"] in style or None проверяло, есть ли значение True/False среди ключей словаря стиля. Всегда ложно, курсив не применялся никогда.
Здесь пришлось разбираться, что вообще считать правильным поведением. Табл. 4.1 методички (строки 716-721) требует для содержимого листинга Courier New / Начертание Обычный / 10 пт / Цвет Авто (чёрный). Стили в style_config.py:303-314 этому соответствуют, но DocxParagraphPygmentsFormatter перекрывает их на уровне run'а, навешивая полужирный и цвет. То есть подсветка как таковая противоречит методичке, а сломанный курсив лишь случайно оказывался ближе к требованию.
Решение в два шага: выражение исправлено на style.get("italic") or None, чтобы флаг делал то, что обещает, и добавлено правило listing.syntax_highlighting в checker — предупреждение по умолчанию, ошибка под --strict. Флаг выключен по умолчанию, так что это осознанное отклонение, но пользователь узнаёт о нём из отчёта, а не из замечаний преподавателя.
Заодно добавлен регрессионный страж: при выключенном флаге ни один run в стиле Code не должен задавать w:b, w:i или w:color.
Пустой заголовок ронял парсер
md2gost/extended_markdown/heading.py, строка 23. inline_body[0] == "*" давал IndexError на строке # без текста. Заменено на startswith.
Плюс правило heading.empty в checker'е: пустой заголовок дал бы пронумерованный безымянный раздел в автособираемом содержании. Обоснование — строка 156 методички: «разделы, подразделы, пункты имеют заголовки, четко и кратко отражающие их содержание».
Побочно выяснилось расхождение в постановке: я просил проверить форму #*, но такая строка регуляркой заголовка вообще не матчится — после # обязателен пробел или конец строки. Заголовком является только # *, эта форма и покрыта тестом.
2. Горячий путь измерения текста ускорен в 4.4 раза
Кэш объектов Font
Замер показал, что один Font() конструируется 2.62 мс: открывает FreeType Face, грузит ImageFont.truetype и аллоцирует PIL-канвас 1000×1000. При этом он создавался дважды за вызов calculate_height плюс по разу на каждый run.
Добавлен модульный _get_font(name, bold, italic, size_pt) под lru_cache(maxsize=256), все три места конструирования переведены на него. Сам класс Font оставлен публичным — существующие тесты создают его напрямую.
Скрытая мина в is_mono
Попутно нашлось то, чего в плане не было. Ветка get_text_width для моноширинных шрифтов читала self._face.glyph.advance.x — то есть тот глиф, который последним оставил в слоте load_char. Всегда оказывался "m", потому что единственные вызовы load_char живут в is_mono. Значение было верным, но верным по случайности: любое постороннее обращение к face молча его сломало бы.
is_mono заменён на свойство _metrics, которое снимает и флаг моноширинности, и advance за один проход. Результат теперь не зависит от порядка обращений к слоту глифа. Есть тест, который долбит load_char посторонними символами и проверяет, что ширина не поехала.
Квадратичный пересчёт ширины слова
count_lines пересчитывал ширину префикса слова после каждого символа, хотя результат читается только в ветке if c == " ". Вызов перенесён в две точки: на пробеле и один раз в конце каждого run.
Вторая точка — та, которую легко пропустить: слово может пересекать границу run'ов, а следующая итерация добавляет свежий слот в word_parts_widths, поэтому фрагмент нужно померить до этого.
Намеренно сохранено одно неочевидное поведение оригинала: word_part не сбрасывается после замера на пробеле, потому что исходный код сбрасывал его только внутри ветки if any(word_parts_widths), и фрагмент нулевой ширины продолжал накапливаться через пробел.
Синглтон XSLT
md2gost/latex_math.py парсил mml2omml с диска и компилировал etree.XSLT заново на каждую формулу. Вынесено в ленивый _mml2omml_transform(). Оставлено внутри try, чтобы битый или отсутствующий stylesheet по-прежнему давал тот же ValueError при каждом вызове.
Как доказана эквивалентность
Не чтением кода. Пять эталонных тестовых файлов прогнаны до и после, verbose-вывод сдиффен построчно — идентичен. Плюс tests/test_paragraph_sizer_cache.py содержит дословные копии дооптимизационных Font и count_lines и сверяет с ними новый код на 158 случаях: одиночные и многорановые абзацы, разрыв слова на границе run'а, пустые run'ы, w:noBreakHyphen, смешанные полужирный с курсивом, кириллица, длинные неразрывные слова, сдвоенные и хвостовые пробелы, три ширины колонки, оба значения is_mono.
Чтобы убедиться, что корпус не вырожденный, замер в конце run'а был временно отключён — тесты упали в 5 местах.
3. Диаграммы: одна JVM на документ вместо одной на диаграмму
Перед проектированием было проверено фактическое поведение jar: PlantUML принимает много входных файлов за запуск, но только один флаг -t<fmt> (передача -tpng -tsvg молча отдаёт только SVG), а относительный -o резолвится от каталога каждого входного файла.
Отсюда конструкция: каждый источник получает свой подкаталог NNNN/src.puml с -o out. Это заодно решает коллизию двух источников, объявляющих @startuml SameName.
DiagramFigure.__init__ регистрирует свой блок через register_pending_diagram(). Поскольку Converter.convert() полностью материализует list(self.parser.parse()) до рендера, к моменту первого вызова render_diagram() очередь содержит весь документ. Первый вызов запускает одноразовый предпроход: готовит все источники, отбрасывает попавшие в кэш, остальные рендерит одной JVM.
Всё, что упало в предпроходе, остаётся промахом кэша и идёт обычным путём, так что цепочка PlantUML jar → локальный Kroki → удалённый Kroki и режимы --diagram-fallback не тронуты. _render_plantuml_jar_one теперь обёртка на одну задачу поверх того же батч-раннера, чтобы одиночный и пакетный пути не разъехались.
Найденный попутно баг
Старый код писал diagram.puml и искал diagram.{fmt}. Любой источник с @startuml SomeName производил SomeName.png, считался провалом jar и молча уезжал на Kroki. Новая логика забирает то, что jar реально положил в выходной каталог этой диаграммы, и рендерит локально.
Остальное по диаграммам
- Версия jar добавлена в ключ кэша:
PLANTUML_CACHE_PREFIX = f"plantuml-{PLANTUML_VERSION}\n". Раньше для plantuml префикс был пустой, и обновление jar отдавало старые PNG. ensure_user_schemes()убран изrender_diagram— он мигрировалmd2gost.schemes.jsonна каждую диаграмму. Безопасно:load_merged_schemes()его и так вызывает.- Добавлен
shutdown_diagrams(), закрывающий сессию Playwright, вызывается из существующегоfinallyвpipeline.convert(). - Graphviz для DFD теперь отдаёт PNG и SVG из одного процесса
dot. Проверяет наличие и непустоту каждого выхода и повторяет недостающий формат старым путём, если сборка Graphviz не поддерживает пары.
Что проверено вживую: путь через jar целиком, включая батч, попадания в кэш, источники с @startuml Name, полный прогон pipeline.convert(). Graphviz тоже оказался установлен — комбинированный запуск реально произвёл оба файла, и на этом всплыло, что существующий DFD-тест тянулся мимо своего мока.
Что не проверено: локальный и удалённый Kroki (сервера нет, сетевых вызовов не делалось) — только моки; закрытие браузера в реальной mermaid-конвертации, потому что здешние тесты идут через QuickJS.
4. Двойная работа при рендере убрана
Все три места сначала обвешаны замерами, потом правлены. Доказательство для каждого — матрица из 4 документов на 7 наборов опций, 28 конвертаций, и побайтовое сравнение word/document.xml со снимком, снятым до любых правок. 28 из 28 идентичны. В матрице examples/example.md, examples/pis_custom.md, синтетический стресс-документ (многостраничная дробящаяся таблица со слитыми ячейками, многостраничный листинг, три фигуры) и документ с альбомной ориентацией, каждый в режимах продолжений off / legacy / caption / soft, плюс ручное содержание, тип PIS_custom и авто-нумерация заголовков.
Повторный рендер на границе страницы
md2gost/renderer.py. Раньше: отрендерить блок, померить первый RenderedInfo, и если не влезает — выбросить генератор и вызвать render() заново. Для таблицы или листинга это пересборка всего поддерева DOCX и повтор всех замеров.
Решение оказалось точнее, чем «разделить measure и emit», и это выяснилось именно замером. Повтор нужен только когда в self._to_new_page реально что-то стоит в очереди: отложенные фигуры вставляются первыми и двигают границу страницы, поэтому блок приходится раскладывать заново уже против оставленного ими состояния. Если очередь пуста, _flush_to_new_screen() — no-op, и повторный рендер строит то же поддерево против того же состояния.
Инструментирование показало, что во всех срабатываниях на всей матрице очередь была пуста, то есть второй проход был чистой потерей всегда. Триггером почти везде оказывались заголовки с page_break_before и одна фигура — ни разу таблица или листинг.
Условие дополнено проверкой and self._to_new_page. Структура render → measure → flush → re-render сохранена ровно для того случая, когда flush действительно меняет состояние.
Подпись рисунка мерилась дважды
md2gost/renderable/image.py. Предложенный в плане Image.estimate_height() не понадобился: замер показал, что геометрия считается дёшево, а реальная стоимость сухого прохода — построение свежего Caption и прогон сайзера по нему. Поэтому подпись создаётся один раз и переиспользуется (сбрасывается в set_number()), а сама структура «сухой проход плюс настоящий рендер» не тронута. Именно поэтому вывод и смог остаться идентичным.
Кэш замеров абзаца
Строки таблицы мерились до трёх раз. Теперь _peek_row_height и _build_docx_row идут через один мемоизированный замер, а выбрасываемый сухой Caption в _start_block_height убран — render() строит подпись один раз и передаёт её внутрь.
Общий хелпер measure_paragraph() в paragraph_sizer.py мемоизирует calculate_height() в разрезе владеющего renderable. Ключ собран так, чтобы это было безопасно в коде, решающем разрывы страниц:
- сериализованный XML измеряемого абзаца, а не идентичность объекта — правка между двумя вызовами тогда даёт промах кэша вместо устаревшей высоты;
- сериализованный
w:pPrпредшественника, а не он целиком — сайзер читает толькоpPr, а привязкаw:pк телу документа заставляет lxml поднять на него объявления пространств имён, из-за чего полная сериализация выглядела бы как правка и молча промахивалась; - ширина.
Сверх побайтового сравнения был прогнан «паранойяльный» режим, пересчитывающий значение на каждом попадании в кэш и сверяющий с сохранённым. Вся матрица прошла без единого устаревшего попадания. Строительные леса убраны, а tests/test_paragraph_sizer_cache.py получил семь регрессионных тестов: попадание равно свежему расчёту, инвалидация при правке текста и при правке интервалов, ширина и интервалы предшественника как части ключа, контракт приватной копии (вызывающий обнуляет .before на новой странице), случай с привязкой предшественника.
Попутно page_break_before = False перенесён из конца render() в __init__, чтобы оба замера подписи видели один и тот же абзац.
Сквозное время конвертации упало на 10-23% в зависимости от документа.
5. Удалено 6777 строк мёртвого кода
Больше моей исходной оценки в 5646. Для каждого файла искались импорты модуля, все top-level символы (извлечены через ast) и имя файла в *.spec, *.bat, *.toml, .github/workflows/, tests/ и всех живых пакетах.
| Файл | Строк | Чем был |
|---|---|---|
mm3.py |
2210 | Монолит + matplotlib-графики |
mm2.py |
1551 | Монолитный сборщик DOCX, версия 2 |
main12.py |
915 | Ранний MarkdownGostDocument |
font.py |
860 | Своя реализация Font API |
parser.py |
340 | Песочница на mistune |
asd.py |
317 | Эксперимент со стилями ГОСТ |
mm5.py |
284 | Прототип на mistune |
mm4.py |
241 | Построчный прототип |
main.py |
32 | Hello-world на python-docx |
_audit_tmp.py |
27 | Разовый инспектор DOCX |
Проверены две ловушки: корневой parser.py мёртв, а md2gost/parser_.py живой и импортируется в converter.py; корневой font.py мёртв, а md2gost/renderable/find_font.py живой. Единственное вхождение font в импортах живых пакетов — from docx.text.font import Font as DocxFont, то есть сторонняя библиотека.
Мёртвый код внутри пакета: убраны APPENDIX_RE в renderer.py (дублировал константу из label_pass.py), Listing._create_table, Converter._output_path, no-op ветка isinstance(r, Paragraph): pass в label_pass.py, _BrowserSession.render_svg(), _draw_purpose() в idef0.py, плюс осиротевший import os в paragraph_sizer.py.
Две поправки к моему списку. gui.main() оказался живым — это консольный скрипт md2gost-gui в pyproject.toml, я ошибочно записал его в мёртвые; оставлен как есть. И комментарий у Listing._create_table, утверждавший, что метод держат ради тестов, не подтвердился: тесты ссылаются на модульную функцию create_table, а не на метод.
У Converter._output_path заодно убран и параметр конструктора — оставлять параметр, который класс игнорирует, смысла нет. pipeline.py был единственным вызывающим, его вызов переведён на именованные аргументы, чтобы остальные позиционные не съехали.
6. Гигиена репозитория
Снято с отслеживания, файлы остались на диске: ReadMe.docx, md2gost/README.docx, tmp_*.docx, README.md1, examples/example_out.docx, examples/toc_*.docx, tests/_idef0_preview*, latex/mirea/Settings/FiraCode-Regular.otf (220 КБ, ни одной ссылки в .tex), каталог ad/, md2gost.user.json.
Перенесено с сохранением истории: _method_extract.txt → docs/reference/method-mirea-2022.txt, tests/extended_mardown/ → tests/extended_markdown/.
md2gost.user.json содержал реальные ФИО и группу. Убран из индекса, файл на диске остался рабочим, рядом появился md2gost.user.json.example с пустыми полями — структура сгенерирована из UserProfile() и DocumentMetadata(), так что совпадает с тем, что пишет save_user_profile.
Зависимости
Легаси-секции [tool.poetry], poetry.lock и requirements.txt удалены, канон — [project] плюс uv.lock.
При этом выяснилось, что matplotlib был объявлен только в удаляемой Poetry-секции, хотя импортируется в рантайме из find_font.py на всех платформах кроме Linux. Добавлено matplotlib>=3.7; sys_platform != 'linux' в [project].dependencies. Также поправлен pywin32 — он был добавлен без маркера платформы, что ломало бы pip install . на Linux-CI, теперь pywin32; sys_platform == 'win32'.
Поле readme исправлено с README.md на фактическое ReadMe.md — расхождение регистра сломало бы сборку колеса на Linux.
CI
example-generator.yml не запускался ни разу: триггер стоял на ветку main, а репозиторий на master. Исправлено в обоих триггерах. Артефакт грузился из example.docx, тогда как default_output_path кладёт файл рядом с входным — путь исправлен на examples/example.docx. Имя воркфлоу совпадало с pytest.yml, переименовано в Example document. Обновлены upload-artifact@v3 → v4, checkout@v3 → v4, setup-python@v3 → v5, добавлен apt-get update перед установкой шрифтов.
Тесты
Два метода с именем test_count_lines_courier7 затирали друг друга — второй переименован, расшадоуенный первый проходит, латентного бага не вскрылось. Голые return в двух win32-тестах test_pipeline.py заменены на @pytest.mark.skipif (раньше они проходили без единой проверки). Опечатка в имени каталога extended_mardown исправлена.
Кстати про нумерацию: test_count_lines_courier7 — это индекс 7 в серии от courier1 до courier10, а не размер 7 пт. Третьего размера шрифта не существует, есть только 10 в style_config и 12 в фикстуре и калибровке.
7. Дедупликация сиблинг-конвертеров
md2latex/emitter.py и md2fodt/emitter.py содержали примерно 150-200 строк почти идентичного обхода AST: диспетчер блоков, инлайн-обходчик, детектор строк библиографии, машина состояний pending_caption, словарь русских подписей ссылок.
Создан отдельный верхнеуровневый пакет mdexport — внутрь md2gost его класть было нельзя, а размещение в одном из экспортёров сделало бы один бэкенд зависимым от другого. Три модуля:
blocks.py—walk_blocks,walk_paragraph, ABCBlockVisitor, кортеж отложенной подписи иtake_caption(), детектор строк библиографии, парсинг заголовка картинки%id Подпись,is_contents_headinginline.py—render_inline, ABCInlineRenderer, таблица русских названий ссылокlabels.py—sanitize_label, параметризованный разделителем::для LaTeX,-для XMLNCName
escape_text остался в бэкендах — там разные задачи, карта символов LaTeX против saxutils для XML.
Эмиттеры сократились на 119 строк, сам общий пакет занял 309, из которых около 120 — новый интерфейсный каркас: объявления абстрактных методов, докстринги, реэкспорты. По суммарному LOC выигрыш скромный, реальный смысл в том, что перечень типов узлов и порядок элементов теперь существуют в одном месте. Раньше новый тип узла приходилось добавлять в две цепочки if/elif, которые уже разъехались: в md2latex жил мёртвый словарь cat, которого на стороне FODT не было вовсе.
Побайтовая сверка: шесть снимков (example.tex, example.fodt, example_emdash.fodt, pis_custom.tex, pis_custom.fodt, pis_custom_emdash.fodt) совпали по SHA-256. Но образцы не дотягиваются до курсива, полужирного, кодовых вставок, ссылок, разрывов строк, цитат, вложенных списков, отсутствующих файлов картинок и экранирования специальных символов LaTeX — поэтому дооптимизационные эмиттеры были дополнительно выкачены из HEAD в temp и прогнаны на стресс-документе, покрывающем эти пути. stress.tex, stress.fodt, stress_emdash.fodt тоже идентичны.
word2md/walker.py: функция _handle_paragraph на 207 строк превращена в тело из 27 строк — собирается неизменяемая запись _Para с четырьмя фактами, нужными всем ветвям, затем прогоняется кортеж из одиннадцати обработчиков до первого, вернувшего не None. Возврат None означает «не мой случай, идём дальше», что сохранило два исходных сквозных пути: подпись со стилем подписи, чей текст не парсится, и строку со стилем библиографии без маркера [n].
Тем же способом проверено и здесь: восемь конвертаций (синтетический DOCX с титульной частью, стилем и полем содержания, подписями продолжений, неразбираемой подписью, кодовыми абзацами, списками через numPr и через отступ, разрывами страниц, строкой библиографии без маркера, плюс полный круг example.md → DOCX → Markdown, каждая с переключением keep_toc_pages и emit_page_breaks) дают побайтово идентичный Markdown против дорефакторного обходчика.
Попутно убраны три мёртвых фрагмента: повторный вызов чистой функции при parsed is None and kind, no-op if kind and parsed.kind != kind: pass (из-за которого весь поиск caption_kind_from_style оказывался мёртвым, вместе с импортом), и try/except вокруг re.match по значению, которое всегда строка.
Два следствия, требующие внимания. mdexport/ остался untracked, при этом уже прописан в pyproject.toml и импортируется обоими конвертерами — это пункт P0 в TODO.md. И при разборе обнаружился реальный баг потери данных в обработчиках списков word2md, существовавший и до правок: он сохранён намеренно ради поведенческой нейтральности и описан в TODO.md с механизмом и порядком починки.
Спецификации PyInstaller править не пришлось — они идут по статическим импортам.
Как это делалось
Работа шла шестью параллельными исполнителями, разведёнными по владению файлами, чтобы не наступать друг другу на правки. Волна 1: фиксы багов, кэш горячего пути, диаграммы, чистка репозитория. Волна 2: двойной рендер вместе с мёртвым кодом внутри пакета, поскольку они делят renderer.py. Отдельным потоком шли сиблинг-конвертеры — единственная область, не пересекавшаяся ни с чем.
Три требования, заданных жёстко всем исполнителям: правки, меняющие раскладку страницы, доказываются побайтовым сравнением вывода, а не чтением кода; вопросы Фазы 1a не трогаются без решения владельца; коммиты и деструктивные git-команды запрещены.