# Отчёт по аудиту и оптимизации Проведён 9-10 сентября 2026. Незакрытые задачи — в [TODO.md](TODO.md), команды ручной чистки git — в [git-cleanup.md](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` (передача `-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`, ABC `BlockVisitor`, кортеж отложенной подписи и `take_caption()`, детектор строк библиографии, парсинг заголовка картинки `%id Подпись`, `is_contents_heading` - `inline.py` — `render_inline`, ABC `InlineRenderer`, таблица русских названий ссылок - `labels.py` — `sanitize_label`, параметризованный разделителем: `:` для LaTeX, `-` для XML `NCName` `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](TODO.md). И при разборе обнаружился реальный баг потери данных в обработчиках списков `word2md`, существовавший и до правок: он сохранён намеренно ради поведенческой нейтральности и описан в [TODO.md](TODO.md) с механизмом и порядком починки. Спецификации PyInstaller править не пришлось — они идут по статическим импортам. --- ## Как это делалось Работа шла шестью параллельными исполнителями, разведёнными по владению файлами, чтобы не наступать друг другу на правки. Волна 1: фиксы багов, кэш горячего пути, диаграммы, чистка репозитория. Волна 2: двойной рендер вместе с мёртвым кодом внутри пакета, поскольку они делят `renderer.py`. Отдельным потоком шли сиблинг-конвертеры — единственная область, не пересекавшаяся ни с чем. Три требования, заданных жёстко всем исполнителям: правки, меняющие раскладку страницы, доказываются побайтовым сравнением вывода, а не чтением кода; вопросы Фазы 1a не трогаются без решения владельца; коммиты и деструктивные git-команды запрещены.