v0.5.2
Python application / build (push) Waiting to run

Что то сделал
This commit is contained in:
Igor20264
2026-09-08 19:37:54 +03:00
parent 1a5b35eb54
commit 510f7e7adf
90 changed files with 11720 additions and 5547 deletions
+2
View File
@@ -0,0 +1,2 @@
*.bat text eol=crlf
*.cmd text eol=crlf
+5
View File
@@ -133,6 +133,11 @@ venv.bak/
# md2gost diagram PNG cache
.md2gost-cache/
md2gost/vendor/*.jar
md2gost/vendor/graphviz/
# Local ArchiMate / EA reference PDFs (licensed; not for the repo)
*.pdf
!docs/**/*.pdf
# LaTeX build artefacts
*_latex/
+1
View File
@@ -0,0 +1 @@
3.12
+71 -15
View File
@@ -14,7 +14,7 @@
- оформляет DOCX стилями Times New Roman, полями 30/10/20/20 мм, интервалом 1.5;
- нумерует рисунки, таблицы, листинги, формулы;
- собирает содержание и список источников;
- рисует UML / C4 / BPMN / Mermaid;
- рисует UML / C4 / Mermaid;
- проверяет структуру по ТЗ (`--check`);
- при необходимости режет длинные таблицы и листинги через Microsoft Word и пишет «Продолжение…».
@@ -29,6 +29,7 @@
| **md2gost** | `python -m md2gost` / `md2gost-gui` | `.docx` | основной путь, Word |
| **md2fodt** | `python -m md2fodt` | `.fodt` | LibreOffice, без Word |
| **md2latex** | `python -m md2latex` | каталог XeLaTeX (+ `--pdf`) | PDF через шаблон МИРЭА |
| **word2md** | `python -m word2md` / `md2gost файл.docx` | `.md` | импорт DOCX → диалект md2gost |
Общий диалект разметки: спецразделы `# *ВВЕДЕНИЕ`, метки `%id`, ссылки `@Рисунок:id`, таблицы с `^`/`>`, формулы `$$`, библиография `[n]: …`.
@@ -37,6 +38,7 @@
- **md2gost** — полный конвейер (стили, профили, диаграммы, checker, GUI, Word COM).
- **md2fodt** — v1: текст, заголовки, списки, таблицы с merge, картинки, листинги, простые ссылки, библиография. Нет PlantUML, нет «Продолжение таблицы», нет полного паритета PIS/АПИД.
- **md2latex** — тот же диалект → `content.tex` + копия `latex/mirea/`. Таблицы идут в `longtable` с настоящим «Продолжение таблицы N» (это умеет LaTeX, не Word).
- **word2md** — эвристический DOCX → диалект md2gost (стили Caption/Heading, подписи, таблицы, Code, формулы OMML). Не восстанавливает UML/Mermaid и исходные `%id`.
---
@@ -46,11 +48,12 @@
pip install -e .
python -m md2gost # GUI
python -m md2gost report.md -o report.docx --type coursework --check
python -m word2md report.docx -o report.md
python -m md2fodt report.md -o report.fodt
python -m md2latex report.md -o report_latex --pdf
```
Windows exe: `build-exe.bat``dist\md2gost.exe` (двойной клик — GUI; из консоли работает как CLI).
Windows exe: `build-exe.bat``dist\md2gost.exe` (двойной клик — GUI; из консоли работает как CLI; перед сборкой тянет plantuml.jar и portable Graphviz).
Быстрая сборка: `build-exe-fast.bat` + `md2gost.fast.spec`.
Для режима продолжения таблиц/листингов `word` (по умолчанию): Windows + Microsoft Word + `pip install pywin32` (опциональная группа `[word]`).
@@ -126,6 +129,10 @@ Windows exe: `build-exe.bat` → `dist\md2gost.exe` (двойной клик —
| `--emdash-to-hyphen` | выкл. | «—» → «-» |
| `--hr-pagebreak` | выкл. | `---` / `***` / `___` → разрыв страницы |
| `--title` / `--assignment` | | DOCX титула и бланка задания (склейка через docxcompose) |
| `--auto-title` / `--no-auto-title` | | автотитул из yaml/` ```title ` (по умолчанию выкл.) |
| `--student` / `--group` | | ФИО и группа для автотитула (иначе `md2gost.user.json`) |
| `--doc-author-from` / `--doc-author` | `os` / профиль | автор в свойствах DOCX: `os` (имя ОС), `student`, `custom` |
| `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by` | см. профиль | остальные поля File → сведения; пустые comments — без штампа md2gost |
| `--check` / `--check-only` / `--strict` | | проверка ТЗ; `--strict` → код выхода 1 при ошибках |
| `--check-pages` | | полупустые страницы через Word COM (эвристика, не влияет на `--strict`) |
| `--syntax-highlighting` | | Pygments в листингах |
@@ -150,6 +157,8 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
- зона Drop: перетаскивание `.md` (tkinterdnd2, иначе Win32 `WM_DROPFILES`, иначе клик → диалог);
- основные параметры: тип, нумерация заголовков, TOC, continuation таблиц/листингов, тире, `---` → разрыв, проверки ТЗ, вёрстка, открыть после сборки;
- **Настройки → Файлы**: шаблон, титул, задание, стили JSON, выходной путь;
- **Настройки → Студент…**: ФИО и группа (первый запуск);
- **Настройки → Метаданные…**: свойства DOCX (автор, название, тема, теги, примечание) в `md2gost.user.json`;
- **Настройки → Диаграммы**: jar, Kroki, fallback, формат, масштаб, статус движка, скачать PlantUML, сброс кэша includes;
- **Шаблоны UML**: CRUD схем (`md2gost.schemes.json`) — добавить / дублировать / удалить / сохранить / открыть JSON;
- **Справка**: Инструкция, Документация (вшитые `docs/*.md`), Схемы, Промпт для ИИ (каталог `prompts/` + кнопки схем);
@@ -170,7 +179,7 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
| Таблица | `%id Подпись` перед таблицей | подпись «Таблица N — …» |
| Merge ячеек | `^` rowspan, `>` colspan | OOXML `vMerge` / `gridSpan`; через разрыв страницы merge не переносится |
| Листинг | `%id` + code fence | Courier New, опционально Pygments |
| Диаграмма | `%id` + ` ```uml ` / `uml-c4` / `bpmn` / `mermaid` | Рисунок; флаги `+listing`, `+landscape` |
| Диаграмма | `%id` + ` ```uml ` / `uml-c4` / `mermaid` / `idef0` / `dfd` | Рисунок; флаги `+listing`, `+landscape` |
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` | номер только если есть ссылка |
| Инлайн-формула | `$…$` | OMML в абзаце |
| Ссылка на объект | `@Рисунок:id`, `@Таблица:id`, `@Листинг:id`, `@Формула:id` | подстановка номера |
@@ -187,7 +196,7 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
### 5.5. Схемы и диаграммы
Встроенные id (`md2gost/diagrams/schemes.json`): `c4`, `c4context`, `c4component`, `usecase`, `bpmn`.
Встроенные id (`md2gost/diagrams/schemes.json`): `c4`, `c4context`, `c4component`, `usecase`.
Слои загрузки (позже побеждает): встроенный шаблон → `md2gost.schemes.json` рядом с приложением (создаётся при первом запуске, не перезаписывается) → рядом с `.md``--schemes`.
@@ -197,7 +206,7 @@ Windowed exe: `_argv_needs_console()` + `_enable_windows_console()` — конс
2. локальный Kroki (`KROKI_URL` / `--kroki-url`);
3. `https://kroki.io` при `--diagram-fallback remote`.
Mermaid — только Kroki. **IDEF0 / DFD конвертер не рисует** — только готовый PNG.
Mermaid — локально (браузер / QuickJS) или Kroki. **IDEF0** — оградка ` ```idef0 `, локальный Pillow (рамка NIST). **DFD** — оградка ` ```dfd `, пакет [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/кэш/PATH Graphviz (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz») или Kroki.
Кэш картинок: `{каталог_md}/.md2gost-cache/`.
Кэш `!include` URL: `md2gost.include-cache.json` + `include-cache/`.
@@ -252,6 +261,10 @@ DOCX сам не умеет писать «Продолжение Таблицы
Если в документе нет TOC — номер ставится на единственную секцию.
**Автотитул** (по умолчанию **выкл.**; CLI `--auto-title` / галочка в GUI; явный `--title` отключает генератор): см. [`docs/title.md`](docs/title.md). Кратко: `md2gost/title_page.py` заполняет bundled `TitleTemplate.docx` из `info_conv.yaml` и/или блока ` ```title `, ФИО/группа — `md2gost.user.json` или `--student`/`--group`. GUI: первый запуск и **Настройки → Студент…**.
**Свойства DOCX** (после склейки титула, перед save): `doc_metadata.apply_document_metadata`. По умолчанию автор = `getuser()`, примечание «Создано при помощи md2gost (ТЗ МИРЭА)», created/modified = сейчас, revision = 1. GUI **Настройки → Метаданные…** / флаги `--doc-*`. Профиль — блок `metadata` в `md2gost.user.json`.
### 5.10. Приложения
- Буквы: А Б В Г Д Е Ж И К … (без Ё З Й О Ч Ь Ы Ъ) — `APPENDIX_LETTERS` в `numberer.py`.
@@ -265,14 +278,16 @@ DOCX сам не умеет писать «Продолжение Таблицы
| `prompts/generate-md.md` | диалект md2gost vs обычный MD |
| `prompts/generate-mirea-report.md` | полный отчёт ГОСТ / МИРЭА |
| `prompts/generate-pis-custom-report.md` | итоговый отчёт ПИС |
| `prompts/emulate-student.md` | голос: человечный текст, не отчёт ИИ |
В GUI: выбор промпта + чекбоксы схем → в конец дописываются `ai-prompt` и макросы из `md2gost.schemes.json` (`compose_prompt()`).
### 5.12. Сборка и поставка
- `pyproject.toml`: hatchling, пакеты `md2gost`, `md2latex`, `md2fodt`; в wheel вшиваются Template.docx, mml2omml, diagrams, latex/mirea, prompts, docs.
- `md2gost.spec` / `md2gost.fast.spec` — PyInstaller.
- `md2gost.spec` / `md2gost.fast.spec` — PyInstaller (вшивают `plantuml.jar` и при наличии `vendor/graphviz`).
- `scripts/fetch_plantuml.py` — скачать jar.
- `scripts/fetch_graphviz.py` — скачать portable Graphviz в `md2gost/vendor/graphviz` (для DFD в exe).
- `scripts/md2gost_exe.py` — обёртка exe.
- `scripts/pyi_rth_mplbackend.py` — runtime hook matplotlib для PyInstaller.
- `scripts/check_page_fill.bas` — макрос Word для той же эвристики вёрстки.
@@ -304,10 +319,21 @@ DOCX сам не умеет писать «Продолжение Таблицы
- `default_output_path(filename)`, `timestamped_output_path(path)`, `default_template_path()`.
- `open_document(path)` — macOS `open` / Windows `startfile` / `xdg-open`.
- `_fix_front_matter_after_compose(...)` — сброс PAGE на титуле/задании, сквозная нумерация.
- `convert(req, log=None)` — весь конвейер, ошибки в `ConvertResult`, не пробрасывает исключение наружу.
- `convert(req, log=None)` — весь конвейер, ошибки в `ConvertResult`, не пробрасывает исключение наружу. Перед save — `apply_document_metadata`.
- `should_launch_gui(filename, gui_flag)`.
- `_CallbackLogHandler` — логи md2gost в GUI/CLI.
**`md2gost/doc_metadata.py`**
- `DocumentMetadata`, `DEFAULT_DOC_COMMENTS`, `AUTHOR_SOURCES`.
- `os_user()`, `clip_core()` (лимит 255 символов python-docx).
- `resolve_document_metadata(...)` — профиль + CLI/request.
- `apply_document_metadata(document, meta)` — core properties + даты + revision=1.
**`md2gost/user_profile.py`**
- `UserProfile` (student, group, metadata), `load_user_profile` / `save_user_profile``md2gost.user.json`.
**`md2gost/converter.py`**
- класс `Converter`: читает md, применяет стили, парсит, нумерует, рендерит.
@@ -481,8 +507,13 @@ DOCX сам не умеет писать «Продолжение Таблицы
- `prepare_source`, `render_diagram`.
- `resolve_plantuml_jar`, `fetch_plantuml_jar`, `iter_plantuml_candidates`, `diagram_engine_status`.
- рендер jar / Kroki / пара png+svg; масштаб `scale` в исходник PlantUML.
- локальные типы `LOCAL_ONLY_TYPES` (`idef0`, `dfd`): Pillow, без jar/Kroki.
**`md2gost/diagrams/`** — `schemes.json`, `BPMN.puml`, C4 `*.puml`.
**`md2gost/idef0.py`** — DSL IDEF0 + Pillow/SVG.
**`md2gost/dfd.py`** — обёртка [pbauermeister/dfd](https://github.com/pbauermeister/dfd): DSL → DOT → vendor/system Graphviz / Kroki (`write_dfd_outputs`, `fetch_graphviz`).
**`md2gost/diagrams/`** — `schemes.json`, C4 `*.puml`.
### 6.8. Word COM и вёрстка
@@ -514,7 +545,7 @@ DOCX сам не умеет писать «Продолжение Таблицы
**`md2gost/gui.py`**
- `_make_root`, `_combo_values/_combo_key/_display`, `_Tooltip`, `_tip`.
- `Md2GostApp`: меню, окна файлов/диаграмм/схем/справки, браузер docs, сборка промпта, DnD, `_collect()``ConvertRequest`, `_convert`.
- `Md2GostApp`: меню, окна файлов/студента/метаданных/диаграмм/схем/справки, браузер docs, сборка промпта, DnD, `_collect()``ConvertRequest`, `_convert`.
- `run_gui(initial)`, `main()`.
**`md2gost/dnd.py`**
@@ -561,6 +592,27 @@ DOCX сам не умеет писать «Продолжение Таблицы
---
## 8.1. word2md — импорт DOCX
Пакет `word2md/`. Эвристика: стили md2gost, иначе ГОСТ-текст подписей/заголовков.
| Файл | Роль |
|------|------|
| `__main__.py` | CLI: `.docx`, `-o`, `--media-dir`, `--keep-toc-pages`, `--pagebreaks` |
| `pipeline.py` | `ImportRequest` / `ImportResult` / `convert_docx()` |
| `walker.py` | обход body, склейка Code/списков, `postprocess_blocks` (продолжения) |
| `classify.py` | Heading / Caption / Code / библио / спецразделы |
| `captions.py` | `Рисунок` / `Таблица` / `Листинг` + `parse_caption_text` из md2gost |
| `tables.py` | merge → `^`/`>`, детект формульной таблицы |
| `omml.py` | OMML → читаемая формула |
| `media.py` | выгрузка `word/media` |
| `emit.py` | IR → диалект md2gost |
| `refs.py` | `Рисунок 1.1``@Рисунок:fig1_1` |
Точки входа также: `python -m md2gost report.docx`, GUI «Импорт DOCX→MD».
---
## 9. Тесты — что покрыто
Каталог `tests/`, CI гоняет `pytest`.
@@ -575,9 +627,12 @@ DOCX сам не умеет писать «Продолжение Таблицы
| `test_landscape.py` | геометрия альбома, секции, картинки/таблицы/листинги, один section break |
| `test_layout_tracker.py` | страницы и остаток высоты |
| `test_paragraph.py` / `test_paragraph_sizer.py` | абзацы и измерение шрифта |
| `test_diagram_renderer.py` | схемы, BPMN, кэш, mermaid, svg, jar, `+listing`/`+landscape` |
| `test_diagram_renderer.py` | схемы, кэш, mermaid, svg, jar, `+listing`/`+landscape` |
| `test_idef0.py` | DSL IDEF0, PNG/SVG, интеграция `render_diagram` |
| `test_dfd.py` | DFD data-flow-diagram, mock Graphviz/Kroki, интеграция `render_diagram` |
| `test_md2fodt.py` | escape, каркас FODT, запись файла |
| `test_md2latex.py` | longtable continuation, генерация проекта |
| `test_word2md.py` | подписи, спецзаголовки, @ссылки, продолжения таблиц, DOCX→MD, round-trip |
| `extended_mardown/formula.py` | формулы (unittest) |
Нет полного e2e «открыть Word и сравнить каждую страницу» в Linux CI (шрифты ставятся, Word COM — нет).
@@ -594,11 +649,12 @@ DOCX сам не умеет писать «Продолжение Таблицы
| `docs/README.md` | оглавление docs |
| `docs/quickstart.md` | установка, exe, типы |
| `docs/markdown.md` | диалект |
| `docs/schemes.md` | UML/Mermaid/BPMN, JSON схем |
| `docs/schemes.md` | UML/Mermaid, JSON схем |
| `docs/types.md` | профили работ |
| `docs/cli-gui.md` | флаги ↔ GUI |
| `docs/styles.md` | JSON стилей |
| `docs/prompts.md` | ИИ-промпты |
| `docs/word2md.md` | импорт DOCX → Markdown |
| `md2gost/README.md` | справка пакета (подробнее про continuation и диаграммы) |
| `prompts/README.md` | как копировать промпты |
| `examples/example.md`, `examples/pis_custom.md` | образцы |
@@ -612,7 +668,7 @@ DOCX сам не умеет писать «Продолжение Таблицы
Опционально: pywin32 (Word COM), matplotlib (сборка exe / отладка), tkinterdnd2 (удобный DnD в GUI).
Внешние рантаймы: Java + plantuml.jar **или** Kroki; для PDF — XeLaTeX / latexmk и шрифт Times New Roman.
Внешние рантаймы: Java + plantuml.jar **или** Kroki; Graphviz (вшитый/кэш/PATH) для DFD; для PDF — XeLaTeX / latexmk и шрифт Times New Roman.
---
@@ -626,13 +682,13 @@ DOCX сам не умеет писать «Продолжение Таблицы
- `Other_Code/` — справка OOXML, docx-js, скрипты pack/unpack/validate (не импортируется md2gost);
- артефакты сборки `build-fast/`, кэши диаграмм.
`todo.txt` сейчас: «ожидаем BPMN в mermaid» и «оценки с текстом через сам Word, а не предугадывание» — второе частично закрыто режимом `word`.
`todo.txt` сейчас: «оценки с текстом через сам Word, а не предугадывание» — частично закрыто режимом `word`.
---
## 13. Чего нет / ограничения (честно)
- IDEF0 и DFD конвертер не рисует.
- DFD: пакет `data-flow-diagram` в зависимостях; картинка — вшитый/кэш/PATH Graphviz или Kroki; балансировка уровней не проверяется.
- FODT и LaTeX не догоняют md2gost по GUI, checker, схемам, профилям PIS/АПИД, Word COM.
- «Продолжение таблицы» в чистом DOCX без Word невозможно точно (нет longtable). Без Word — `off` или неточный `legacy`/`caption`.
- `--check-pages` даёт ложные срабатывания; это эвристика.
@@ -649,7 +705,7 @@ DOCX сам не умеет писать «Продолжение Таблицы
2. **ВКР:** раздельный список, ссылки `[1.5]`, приложение «Графический материал».
3. **ПИС:** `# Практическая работа №1. …``--type PIS_custom`, сквозные Рисунок 1, 2, 3.
4. **АПИД:** обязательные 2 главы и 2.1–2.4, источники 720.
5. **UML в отчёте:** `%id Подпись` + ` ```uml-c4 ` / ` ```bpmn ` — PNG/SVG в Word, опционально `+listing` и `+landscape`.
5. **UML в отчёте:** `%id Подпись` + ` ```uml-c4 ` — PNG/SVG в Word, опционально `+listing` и `+landscape`.
6. **Широкая таблица:** `%id … +landscape`.
7. **Длинная таблица на Windows:** режим `word` → COM режет и пишет «Продолжение Таблицы N».
8. **Свой вуз/кафедра:** `md2gost.styles.json` меняет поля и H1 без правки кода.
+4 -2
View File
@@ -1,6 +1,6 @@
# GhostEditor / md2gost
Конвертер **Markdown → DOCX** по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) и профилям ПИС / АПИД. Есть GUI, CLI и промпты для генерации `.md` через ИИ.
Конвертер **Markdown → DOCX** по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) и профилям ПИС / АПИД. Есть GUI, CLI, импорт **Word → Markdown** (`word2md`) и промпты для генерации `.md` через ИИ.
## Быстрый старт
@@ -8,6 +8,7 @@
pip install -e .
python -m md2gost # GUI
python -m md2gost report.md -o report.docx --type coursework --check
python -m word2md report.docx -o report.md
```
Windows: `build-exe.bat``dist\md2gost.exe` (двойной клик — GUI).
@@ -22,9 +23,10 @@ Windows: `build-exe.bat` → `dist\md2gost.exe` (двойной клик — GUI
|--------|------------|
| [Быстрый старт](docs/quickstart.md) | Установка, GUI, типы документов |
| [Синтаксис Markdown](docs/markdown.md) | Диалект md2gost vs обычный MD |
| [Схемы и диаграммы](docs/schemes.md) | PlantUML, Mermaid, `md2gost.schemes.json` |
| [Схемы и диаграммы](docs/schemes.md) | PlantUML, Mermaid, IDEF0, DFD (Graphviz вшитый/Kroki), `md2gost.schemes.json` |
| [Типы работ](docs/types.md) | coursework, practice, vkr, PIS, APID |
| [CLI и GUI](docs/cli-gui.md) | Флаги и где они в окне |
| [Импорт Word](docs/word2md.md) | DOCX → диалект md2gost |
| [Промпты для ИИ](docs/prompts.md) | Генерация MD, кнопки схем в GUI |
Краткая справка по пакету: [`md2gost/README.md`](md2gost/README.md).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

-3468
View File
File diff suppressed because it is too large Load Diff
+988
View File
@@ -0,0 +1,988 @@
УДК 378
ББК 65
К 43
Кириллина Ю.В. Требования к оформлению отчетов по практикам, курсовых работ и
ВКР бакалавров и магистров [Электронный ресурс]: Методические указания / Кириллина
Ю.В., Лагунова А.Д., Бергер Е.Г. — М.: МИРЭА – Российский технологический
университет, 2022. — 1 электрон. опт. диск (CD-ROM)
Методические указания знакомят студентов с требованиями по оформлению учебных
работ, таких как курсовые работы, отчеты по практикам, выпускная квалификационная
работа. Использование методических указаний позволит студентам грамотно оформлять
учебные работы, которые они выполняют согласно учебному плану в процессе обучения.
Данные методические указания основаны на ГОСТ 7.32 – 2017 Система стандартов по
информации, б иблиотечному и издательскому делу. Отчет о научно -исследовательской
работе. Структура и правила и оформления.
Предназначено для студентов по направлени ям подготовки 09.03.03 Прикладная
информатика, 09.0 4.03 Прикладная информатика , а также может быть использовано
студентами других направлений подготовки для получения навыков грамотного оформления
учебных работ.
Методические указания издаются в авторской редакции.
Авторский коллектив: Кириллина Юлия Владимировна, Лагунова Анна Дмитриевна, Бергер
Екатерина Геннадьевна
Рецензенты:
Одинцова Мария Александровна , к.т.н., доцент, доцент кафедры «Предметно -
ориентированные информационные системы» Института кибербезопасности и
цифровых технологий РТУ МИРЭА
Системные требования:
Наличие операционной системы Windows, поддерживаемой производителем.
Наличие свободного места в оперативной памяти не менее 128 Мб.
Наличие свободного места в памяти постоянного хранения (на жестком диске) не менее 30 Мб.
Наличие интерфейса ввода информации.
Дополнительные программные средства: программа для чтения pdf-файлов (Adobe Reader).
Подписано к использованию по решению Редакционно-издательского совета
МИРЭА — Российский технологический университет.
Обьем: 5.39 мб
Тираж: 10
© Кириллина Ю.В., Лагунова А.Д.,
Бергер Е.Г., 2022
© МИРЭА – Российский
технологический университет, 2022
3
СОДЕРЖАНИЕ
ПРЕДИСЛОВИЕ ...................................................................................................... 4
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ .................................................................... 5
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА ........................................................ 12
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В УЧЕБНОЙ РАБОТЕ ....... 17
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В УЧЕБНОЙ
РАБОТЕ .................................................................................................................. 27
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ В
УЧЕБНОЙ РАБОТЕ ............................................................................................... 30
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ .............................. 34
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ ...................................................... 37
ПРИЛОЖЕНИЕ А .................................................................................................. 38
ПРИЛОЖЕНИЕ Б................................................................................................... 39
ПРИЛОЖЕНИЕ В .................................................................................................. 41
ПРИЛОЖЕНИЕ Г................................................................................................... 42
ПРИЛОЖЕНИЕ Д .................................................................................................. 44
ПРИЛОЖЕНИЕ Е .................................................................................................. 45
4
ПРЕДИСЛОВИЕ
В процессе обучения студентам направлений подготовки 09.03.03, 09.04.03
Прикладная информатика необходимо писать и оформлять курсовые работы, от-
четы по различным видам и типам практик, а также выпускную квалификацион-
ную работу (далее по тексту — учебные работы). Данные методические указания
предназначены для студентов вышеуказанных направлений подготовки.
Целью данных методических указаний является поддержка грамотного
оформления текстовых документов студентами.
Задачами методических указаний являются:
• ознакомление студентов с общей структурой учебных работ, таких как
курсовая работа, отчет по практике, выпускная квалификационная ра-
бота (ВКР);
• ознакомление студентов с правилами оформления учебных работ.
Основой для создания методических указаний является ГОСТ 7.32 — 2017
Система стандартов по информации, библиотечному и издательскому делу.
Отчет о научно-исследовательской работе. Структура и правила и оформления.
Методические указания составлены в соответствии с:
• СМКО МИРЭА 7.5.1/03.П.67- 18 Положение о выпускной квалифика-
ционной работе студентов, обучающихся по образовательным про-
граммам подготовки бакалавров;
• СМКО МИРЭА 7.5.1/03.П.68- 18 Положение о выпускной квалифика-
ционной работе студентов, обучающихся по образовательным про-
граммам подготовки магистров;
• СМКО МИРЭА 7.5.1/04.И.05- 18 Инструкция по организации и прове-
дению курсового проектирования.
5
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ
Учебная работа студента должна иметь определенную структуру.
Структура конкретной учебной работы регламентируется методическими
указаниями по написанию курсовой работы, методическими указаниями по
написанию отчета по практике, методическими указаниями по написанию ВКР,
которые разрабатываются преподавателями выпускающей кафедры и издаются
редакционно-издательским отделом университета. В се изданные методически е
указания выпускающей кафедры студент может найти на сайте университет а в
разделе Студентам, далее Библиотека.
В тоже время общая структура учебной работы включает в себя:
• ый лист;
• ие;
• ие;
• ие;
• ием;
• ие;
• иков;
• ия.
Титульный лист учебной работы имеет утвержденную форму , выдается
преподавателем, который руководит курсовой работой/практикой/ВКР.
На титульном листе в обязательном порядке проставляется подпись
руководителя учебной работы и студента. В случае, если в кач естве учебной
работы выступает курсовая работа, то на не м необходима подпись второго
преподавателя, участвующего в процедуре защиты курсовой работы. В слу чае,
если в качестве учебной работы выступает ВКР, то на титульном листе
необходима подпись заведующего выпускающей кафедрой. Примеры титульных
листов различных учебных работ приведены в Приложении А .
му и выдается
преподавателем, который руководит курсовой работой/практикой/ВКР.
аботы) — т, в
яется абота). ие подписывается
аботы. совых
абот, а т а к ж е о т ч е т о в п о п р а к т и к е ие обязательно ждается
ой. абот
дополнительно согласуется с директор ом института. Факт принятия
студентом задания к работе подтверждается подписью студента на задании.
6
Кроме подписей в задании должны быть указаны дата утверждения задания
заведующим кафедрой, дата выдачи задания, дата принятия задания к
исполнению. Примеры заданий различных учебных работ приведены в
Приложении Б.
афической
частей в учебной работе устанавливаются методическими указаниями по
написанию курсовой работы, методическими указаниями по написанию отчета
по практике, методическими указаниями по написанию ВКР .
авливается методическими
указаниями по написанию курсовой работы, методическими указаниями по
написанию отчета по практике, методическими указаниями по написанию ВКР ,
а также положениями, приведенными в предисловии данного пособия.
Текст основной части учебной работы еобходимости а азделы,
азделы, пункты . кты имеют заголовки, четко и
ктов.
а листе, который расположен ого листа и
ия, азделов,
иц. иц
а быть
ая.
ия заголовков:
• аздела — я;
• аздела — я;
• ктов — ей.
та,
ые с абзацевого отступа.
аков
ия пишется заголовок.
а б ы т ь в
аздела,
ых точками.
Параметры оформления заголовков в учебной работе представлены в табл.
2.1. Важно обратить внимание на то, что интервалы между заголовками
выставляются в единицах измерения — мм, а не пт.
На рис. 2.1 представлен пример внешнего вида заголовков выпускной
квалификационной работы и курсовой работы с учетом установленных
параметров страниц для учебной работы.
7
Таблица 2.1. Параметры оформления заголовков в учебной работе
Параметр Заголовок
первого урﮦовнﮦя
Заголовок
второго уровня
Заголовок
третьего уровня
ифт
Шрﮦифт Times New Roman
Нﮦачерﮦтанﮦие полужирﮦнﮦый
Подчерﮦкиванﮦие нﮦет
Видоизменﮦенﮦие все прﮦописнﮦые нﮦет нﮦет
Рﮦазмерﮦ 18 пт 16 пт 14 пт
Цвет текста Авто (черﮦнﮦый)
Абзац
Вырﮦавнﮦиванﮦие По левому крﮦаю
Перﮦвая стрﮦока нﮦет
Отступ слева 1,25 см
Отступ спрﮦава 0 мм
Инﮦтерﮦвал перﮦед 0 мм 15 мм 15 мм
Инﮦтерﮦвал после 10 мм 10 мм 10 мм
Междустрﮦочнﮦый
инﮦтерﮦвал
Полуторﮦнﮦый
Положенﮦие нﮦа
ице
Не отрывать от
следующего
С новой страницы
Не отрывать от
следующего
Запрет висящих строк
Нﮦе отрывать от
следующего
Запрет висящих строк
Рисунок 2.1. Пример внешнего вида заголовков
8
азделы «СОДЕРЖАНИЕ», «ВВЕДЕНИЕ», «ЗАКЛЮЧЕНИЕ», «СПИСОК
ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ », «П РИЛОЖЕНИЕ»/«ПРИЛОЖЕНИЯ»
уются.
Разделы «СОДЕРЖАНИЕ» и «СПИСОК ИСПОЛЬЗОВАННЫХ
ИСТОЧНИКОВ» выравниваются по центру страницы.
ие
азделяют точкой.
е Word используется
вкладка «Стили» (рис. 2.2). ﮦ ов
о
ить азу
ия.
Рисунок 2.2. Использование вкладки «Стили» в текстовом редакторе Word
ить
а «Стили» ять
овые стили (рис. 2.3).
ые соответствующими стилями, позволяют
ие.
ктов должны
ии, и
соответственно наоборот. Для обеспечения этого содержание учебной работы
должно формироваться с помощью встроенной функции Оглавление.
ИЕ»,
ием по
у. Важно обратить внимание на значения интервала перед самим
заголовком и после него для того, чтобы обеспечить нужные интервалы между
заголовков и последующими пунктами самого содержания учебной работы.
ю
«Ссылки» → а ю и с
ителя строки «. . . . . . . .» ( рис. 2.4).
9
ить и
овать.
Рисунок 2.3. Редактирование заголовков в текстовом редакторе Word с
использование диалогового окна «Стили»
Рисунок 2.4. Формирование содержания через Автособираемое оглавление
иц,
ом
овить поле».
10
При создании содержания слово ОГЛАВЛЕНИЕ заменяется в ручном
режиме на СОДЕРЖАНИЕ с выставлением необходимых параметров,
характерных для заголовков первого уровня.
ой
текст» без отступа первой строки аю.
я,
ии
ыми буквами (рис. 2.5), аналогично тому, как они
оформлены в учебной работе за исключением того, что полужирное начертание
не применяется.
Рисунок 2.5. Пример оформления содержания в ВКР
11
ии текст попадает иц
кте отсутствует отточие ицы, то часть
очку.
12
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА
ты учебной работы ой
е листа.
В случае представления учебной работы в бумажном виде она а быть
мата А4.
Учебная работа ых
илами.
Учебная работа проверяется руководителем на соблюдение правил
оформления, в случае несоответствия работы она не допускается до защиты и
выставления оценки, и направляется на доработку.
Текст документа должен быть кратким, четким и не допускать различных
толкований. В докуме нтах должны применяться научно-технические те рмины,
обозначения и определен ия общеп ринятые в научно-технической лите ратуре
Стиль изложения текста в учебной работе строго нау чный, подстиль —
собственно-научный. е допускается:
• ицизмы,
ализмы;
• ия;
• усской
тами и
ая
овка;
• и
.
Текст учебной работы печатается, соблюдая следующие размеры полей:
левое — 30 мм, правое — 10 мм, верхнее и нижнее — 20 мм. Абзацный отступ
должен быть одинаковым по всему тексту и равен 1,25 см [1].
а листах в рамках еще
незаконченного раздела (см. Приложение В).
иц.
Не допускается размещать заголовки в кон це страницы, а основной текст
переносить на следующую страницу.
Текст ифтом
ого цвета , размер шрифта — 14 пт, тип шрифта для основного текста
учебной работы — Times NewтRoman.
ой текст» (табл. 2.1).
13
ифт»,
а знаков абзаца и других скрытых знаков
форматирования ца абзаца и
аки» (ая» →
ифт» → ¶).
Таблица 2.1. Параметры оформления основного текста учебной работы
Параметр Значение
ифт
Шрﮦифт Times New Roman
Нﮦачерﮦтанﮦие Обычнﮦый
Подчерﮦкиванﮦие нﮦет
Видоизменﮦенﮦие нﮦет
Рﮦазмерﮦ 14 пт
Цвет текста Авто (черный)
Абзац
Выравнивание По ширине
Первая строка 1,25 см.
Отступ слева нет
Отступ справа нﮦет
Интервал перﮦед нﮦет
Интервал после нет
Междустрочный интервал Полуторный
Положение на странице Запрет висящих строк
Пробел ставится после любого знака пунктуации, но не перед ним. При этом
не ставятся п робелы после отк рывающих скобок и кавычек, так же как и пе ред
закрывающими скобками и кавычками.
Кавычки в русском тексте (включая список использова нных источ ников)
имеют форму «…», а в английском — "…".
ая.
ицы учебной работы оставляемыми по
ифт — 12 пт).
ицы
е ставится:
• титульный лист;
• ие;
• ие.
е
аки, такие как
«+», «–», «=», «>», «<» и так далее. В тексте их следует писать словами: «плюс»,
ьше».
аки:
14
1. ак дефиса «-» обелами и используется:
• жащих дефисы (во-первых,
физико-математический, кто-либо);
• валов (100-150 Мб).
2. уса «–» ия.
3. е «—обелами и используется в соответствии с
ктуации (Классификаторﮦ —это
систематизированный …).
абские
талов, полугодий,
ами.
ами, ы
иями (… в начале XXI века).
ых
ий (… во 2 разделе, … характерно для середины 1980 годов).
ой и той же
его числа
(…составляет 200, 250, 300 руб.)ица
ﮦ е (… длиной 20-25 см).
иц
ﮦ ами, а числа без
ицы до девяти
— словами (провести испытан ия семи т руб, каждая дли ной 5 м) . ые
обей с ипользованием
запятой при написании (20,5 см).
ый и ый спис ки уются стилем
ой текст».
Таблица 2.2. Параметры оформления списков
Параметр Значение
Марﮦкирﮦованﮦнﮦый список –, ―, ●, ■, ○
Нﮦумерﮦованﮦнﮦый список 1.
а)
Положенﮦие марﮦкерﮦа (нﮦомерﮦа) Отступ 1,25 см
Положенﮦие текста Табуляция после 2,25 см
Отступ 2,25 см
а во всем
ькой
15
чивается — кт в
чивается точкой).
ой буквы и
чиваться точкой (рис. 2.1-2.3).
Рисунок 2.1. Пример 1 оформления списка в учебной работе
Рисунок 2.2. Пример 2 оформления списка в учебной работе
16
Рисунок 2.3. Пример 3 оформления списка в учебной работе
В случае необходимости применения для нумерованного списка строчных
букв русского алфавита, список начинают с буквы «а» (за исключением букв ё,
з, й, о, ч, ъ, ы, ь) [1].
Внутри списка не могут располагаться иллюстрации и таблицы.
При выполнении учебной работы необходимо соблюдать равномерную
плотность и четкость изображения по все й работе. Все линии, буквы, цифры и
знаки должны иметь одинаковую контрастность по всему тексту.
17
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В
УЧЕБНОЙ РАБОТЕ
Количество иллюстраций в учебной работе должно быть достаточ ным для
пояснения излагаемого текста, в некоторых случаях обязательные иллюстрации
регламентируются методическими указаниями по написанию курсовой работы,
методическими указаниями по написанию отчета по практике, методическими
указаниями по написанию ВКР.
о после
ице (по
возможности ближе к соответствующим частям текста учебной работы) с
у без абзацного отступа и первой строки.
ами.
аздела —
ﮦ ﮦ азделяются точкой.
ации
ации (табл. 3.1).
Таблица 3.1. Параметры оформления подписи иллюстраций
Параметр Значение
ифт
Шрифт Times New Roman
Начертание Полужирﮦнﮦый
Подчеркивание нет
Видоизменﮦение нет
Размер 12 пт
Цвет текста Авто (черﮦнﮦый)
Абзац
Выравнивание По центру
Первая строка 0 см
Отступ слева 0 см
Отступ спрﮦава нет
Интервал перﮦед 0 мм
Интервал после 6 мм
Междустрочный интервал Одинарный
Положение на странице Запрет висящих строк
ия
е ставится . Предложение, в котором автор ссылается на
рисунок, должно заканчиваться точкой, а не двоеточие м, т а к к а к э т о является
нарушением правил пунктуации. (рис. 3.1).
ием
а.
18
Рисунок 3.1. Пример оформления рисунка с учетом установленных параметров
страниц учебной работы
углых скобках или
:
• план проекта иллюстрирует диаграмма Ганта (Рисунок 1.1);
• дерево функций представлено на Рисунке 1.1.
о
ия по часовой
а 90 яется с
ую с помощью команды главного меню «Макет» . При
размещении рисунка на отдельной странице с альбомной ориентацией
необходимо установить в Параметрах страницы вертикальное выравнивание
по центру.
Страницы с альбомной ориентацией предназначены только для
рисунков и таблиц, нельзя размещать на них основной текст (Приложение Г,
рис. Г.1-Г.2).
19
Сначала устанавливаются разрывы страницы, начиная со следующей
страницы, а потом меняется ориентация с книжной на альбомную (рис. 3.2-3.3).
Рисунок 3.2. Переход от книжной ориентации к альбомной
Рисунок 3.3. Пример оформления рисунка на странице с альбомной ориентацией
20
В случае, если рисунок завершает раздел, подраздел или пункт
рекомендуется после него сформулиро вать заключительное предложение. Для
раздела это будет заключительное предложение, подводящее итоги по
выполненной работе в соответствующем разделе. Для подраздела или пункта это
будет завершающее предложение, обеспечивающее переход к следующему
подразделу или пункту (рис. 3.4-3.5).
Рисунок 3.4. Пример завершения раздела и переход к следующему
Все иллюстрации должны иметь границы четко по представляемому
рисунку, схеме и т.д., и не допускается пустая, неинформативная часть в
иллюстрации, занимающая место, предназначеннное для основного текста.
На рис. 3.6 представлена правильная и неправильная обрезка границ
иллюстрации.
Вне зависимости от того, каков формат представления учебной работы:
распечатанная бумажная версия или электронный файл, все иллюстрации
должны удовлетворять требованию их четкого воспроизведения, то есть должна
быть обеспечена читабельность иллюстраций без необходимости прибегать к
увеличению масштаба представленного материала.
21
Рисунок 3.5. Пример завершения подраздела и переход к следующему подразделу в
курсовой работе
Рисунок 3.6. Пример правильной обрезки границ иллюстрации (левая часть) и
неправильной обрезки границ илллюстрации (правая часть)
22
Кроме того, рисунки, представляющие собой чертежи, графики, диаграммы,
схемы, помещаемые в учебной работе , должны соответствовать требованиям
стандартов Единой системы конструкторской документации (ЕСКД) .
ия
показателей.
ие таблицы атким.
ад таблицей.
ие таблицы пишется без абзацного отступа и первой строки у
це
е ставится.
ами аздела —
ﮦ ﮦ азделяются точкой.
ом
еобходимости,
ту.
ие ячеек
е допускается.
ед
е Word).
Параметры оформления таблицы в учебной работе представлены в табл. 3.2.
Таблица 3.2. Параметры оформления таблицы
Параметр Надпись таблицы Текст таблицы
ифт
Шрифт Times New Roman
ие Курсив Обычный, полужирный,
курсив
Подчеркиванﮦие нет
Видоизменение нет
Размер 12 пт
Цвет текста Авто (черный)
Абзац
Выравнивание По левому краю По центру, по левому
краю
Первая стрﮦока 0 см
Отступ слева 0 см
Отступ справа нет
Интервал перед 6 мм 0 мм
Интервал после 0 мм
Междустрочный интервал Одинﮦарный
Положение нﮦа странице Запрет висящих строк
23
а все таблицы учебной работы ы ссылки в тексте.
углых скобках или включаются в
ие. Если ссылка на таблицу завершает предложение, то в конце
предложения ставится точка.
ием
углых скобках
:
• далее представлен планﮦпроекта (Таблица 3.1);
• планﮦпроекта представлен в Таблице 3.1 (рис. 3.7).
Рисунок 3.7. Пример представления таблицы
ие
ую
угими частями пишется
адпись таблицы»
(рис. 3.8).
Не допускаются таблицы в одну строчку, а также не допускается оставлять
на странице только заголовок к таблице или заголовок с заголовками граф
(Приложение Г, рис. Г.3).
ации
ед их
ием.
Все таблицы в учебной работе должны быть оформлены единообразно с
точки зрения применяемого стиля для оформления заголовков и подзаголовков
граф, а также строк таблицы.
24
Рисунок 3.8. Пример оформления переноса таблицы в учебной работе
ия,
ия
е
о под
мулой.
ием
мул Microsoft Equation или MathType.
а вкладке «Вставка» →«Уравнение» (рис.
3.9).
Рисунок 3.9. Возможности контекстного меню «Уравнение»
25
Не допускается формулы оформлять как основной текст, основным
шрифтом.
мулы, ивается по
у. а
ока.
а ака
ус ( –ия (×) или
ия яется.
ию.
тов следует
ости, в какой
муле.
ают без абзацного отступа со слова «где»
его.
Значение каждого символа и числового коэффицие нта следует давать с
новой строки, располагая символы один под другим (рис. 3.10).
Рисунок 3.10. Пример оформления последовательности формул в учебной работе
26
Если формула является концом предложения, то ставится точка, если после
формулы предложение продолжается — запятая.
Формулы нумеруются последовательно в пределах раздела арабскими
цифрами в круглых скобках, выровненных по правому краю. Нумеруются только
те формулы, на которые имеются ссылки в тексте.
Ссылки в тексте на номер формулы даются в круглых скобках.
Вне зависимости от способа выполнения учебной работы качество
напечатанного текста и оформления иллюстраций, таблиц, распечаток программ
должно удовлетворять требованию их четкого воспроизведения.
27
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В
УЧЕБНОЙ РАБОТЕ
В учебной работе могут содержаться отдельные фрагменты программ (ли-
стинг), разработанных студентом.
Название листинга пишется в одну строку с номером, через тире, с большой
буквы, перед листингом. Точка в конце не ставится. Пример, «Листинг 1.7 —
Процедура открытия программы».
При ссылке на отдельные строки программы они нумеруются в порядке воз-
растания с шагом 10. После ссылки на листинг в фигурных скобках привод ятся
номера строк. Пример «Листинг 3.2 {30, 50, 140}», «Листинг 3.2 {20- 80}».
Листинг оформляется в рамку (рис. 4.1-4.2).
Рисунок 4.1. Пример 1 оформления листинга
Рисунок 4.2. Пример 2 оформления листинга
28
Параметры оформления листинга представлены в табл. 4.1.
Таблица 4.1. Параметры оформления листинга
Листинг
Надпись листинга
Шрифт Шрифт
Шрифт Times New Roman
Начертание Курсив
Размер 12 пт
Цвет текста Авто (черный)
Подчеркивание нет
Видоизменение нет
Интервал
Масштаб 100%
Интервал Обычный
Смещение Нет
Анимация Вид Нет
Абзац Отступы и интервалы
Выравнивание По левому краю
Отступ слева 0 см
Отступ справа 0 см
Первая строка нет
Интервал перед 6 мм
Интервал после 0 мм
Междустрочный Одинарный
Положение на странице Разбивка на страницы Не отрывать от следующего
С новой страницы
Содержание листинга
Шрифт Шрифт
Шрифт Courier New
Начертание Обычный
Размер 10 пт
Цвет текста Авто (черный)
Подчеркивание Нет
Видоизменение нет
Интервал
Масштаб 100%
Интервал Обычный
Смещение Нет
Анимация Вид Нет
Абзац
Отступы и интервалы
Выравнивание По левому краю
Отступ слева 0 см
Отступ справа 0 см
Первая строка 0 см
Интервал перед 0 мм
Интервал после 0 мм
Междустрочный Одинарный
29
Основной текст (строка) после листинга печатается с интервалом перед 6
мм.
Весь текст программы целесообразно выносить в приложени е.
30
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ
ИСТОЧНИКОВ В УЧЕБНОЙ РАБОТЕ
Все заимствованные части учебной работы (автором которых не является
студент) должны быть сопровождены ссылкой на источник , тип ссылки —
затекстовая (см. ГОСТ Р 7.0.5- 2008). Список должен содержать сведения об
источниках, использованных при составлении учебной работы [1].
Список использованных источников должен включать библиографические
записи на документы, использованные при составлении учебной работы, ссылки
на которые оформляют арабскими цифрами в квадратных скобках.
Сведения об источниках следует располагать в порядке появления ссылок
на источники в тексте учебной работы и нумеровать арабскими цифрами с
точкой и печатать с абзацного отступа в 1,25 см.
При оформлении раздела СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
на первой строке листа печатается соответствующее наименование р аздела,
отформатированное по параметрам для заголовка первого уровня.
Для учебных работ вида курсовой работы или отчета по практике список
использованных источников делить на разделы не требуется. Требованиями к
источникам в данном случае являются следующие:
• количество источников от 5 до 7;
• год издания источника — за последние 5 лет к моменту написания и
представления учебной работы (Приложение Д, рис. Д.1).
В случае выпускной квалификационной рабо ты список использованных
источников делится на разделы, в каждом и которых должно быть представлено
не менее 10 источников, годом издания не больше, чем 5 лет к моменту защиты
выпускной квалификационной работы.
Параметры оформления заголовков разделов в списке использованных
источников представлены в табл. 5.1.
Внутри раздела источники распределяются в порядке упоминания их в
выпускной квалификационной работе и нум еруются (рис. 5.1). В данном случае
номер источника будет состоять из номера разд ела, к которому он относится, и
номера по порядку.
ики следует давать в виде
арабских цифр, заключенных в квадратные скобки, указывающих порядковый
номер источника по списку, например, [1], [2, 3], [1.5], [2.18].
В случае цитирования указываются страницы книги, статьи или другого
источника, из которых взята цитата, например, [2.18, c. 21-25].
31
Таблица 5.1. Параметры оформления заголовков разделов
в списке использованных источников
Параметр Значение
Шрифт
Шрифт Times New Roman
Начертание Обычный
Подчеркивание нет
Видоизменение Все прописные
Размер 14 пт
Цвет текста Авто (черный)
Абзац
Выравнивание По центру
Первая строка нет
Отступ слева 1,25 см.
Отступ справа нет
Интервал перед 6 мм
Интервал после 6 мм
Междустрочный интервал Полуторный
Положение на странице Не отрывать от следующего
Рисунок 5.1. Пример оформления списка использованных источников с делением на
разделы
32
На рис. 5. 2 приведен пример основного текста учебной работы со ссылкой
на источник.
Рисунок 5.2. Пример оформления ссылок в тексте учебной работы
На рис. 5. 3 приведен пример основного текста учебной работы со ссылкой
на источник при цитировании. При цитировании цитируемая фраза ставится в
кавычки.
Рисунок 5.3. Пример оформления ссылок в тексте при цитировании.
33
В основном тексте ВВЕДЕНИЯ и ЗАКЛЮЧЕНИЯ ссылки на
источники не указываются.
Более подробно порядок оформления различных источников представлен в
ГОСТ 7.32 — 2017 Система стандартов по информации, библиотечному и
издательскому делу. Отчет о научно -исследовательской работе. Структура и
правила и оформления. Приложение Е на странице 26 указанного документа.
34
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ
яющий текст учебной работы, допускается помещать в
ие учебной работы а
последующих ее листах.
иложения могут выноситься:
• ом, так и
ия;
• уемого объекта
ия;
• асчеты;
• сложные алгоритмы;
• аботы;
• тов;
• тов;
• экранные формы инормацинной системы;
• листинги программ;
• оизводство
ии).
мата А4. Допускается
мата A3 по ГОСТ 2.301.
В учебной работе приложение может быть одно или несколько.
В случае, если в учебной работе будет только одно приложение , т о н а
следующем листе после списка использованных источников заголовком первого
уровня оформляется заголовок ПРИЛОЖЕНИЕ. И на этом же листе размещается
необходимый дополняющий материал (рис. Д.1, Приложение Д).
В случае необходимости оформить несколько приложений в учебной работе
их а я с А , з а
ﮦ «Приложение А».
ь
ия в
ой текст» (рис.
6.1).
ием
ого стилем
я».
35
Рисунок 6.1. Пример оформления раздела ПРИЛОЖЕНИЯ
а
ﮦ «Приложение А.1», «Приложение А.2», если они связаны по
смыслу.
ый по
ой текст» (рис. 6.2).
Рисунок 6.2. Пример оформления отдельного приложения в составе раздела
ПРИЛОЖЕНИЯ
36
Таблицы и иллюст рации, соде ржащиеся в п риложении, нумеруются
аналогично таблицам и рисунк а м в о сновном тексте учебной работы.
Иллюстрации каждого пр иложения обоз начают отдель ной нумерацией
арабскими циф рами с добавлен ием пе ред циф рой обоз начения приложе ния,
например, «… представлено на Рисунке Б.1», «… отражен в таблице В.1».
В тексте докумен та на все п риложения должн ы быть да ны ссылки.
Приложения располагают в порядке ссылок на них в тексте учебной работы.
При ссылке следует писать слово «П риложение» с большой буквы с
указанием порядкового номера, например «…представлено в Приложении Б».
Перечень материала, оформляемого в виде приложения, определяется
обучающимся совместно с руководителем на основе методических
рекомендаций по курсовой работе, методических рекомендаций по подготовке
отчета по практике, методических рекомендаций по написанию ВКР.
Применительно к ВКР бакалавра или магистра обязательным приложением
к ней является приложение под названием Графический материал, который
представляет собой слайды презентации с основными результатами,
полученными в ходе написания ВКР.
37
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ
1. ГОСТ 7.32 — 2017 Система стандартов по информации,
библиотечному и издательскому делу. Отчет о научно- исследовательской
работе. Структура и правила и оформления — URL:
https://www.rea.ru/ru/org/managements/orgnirupr/Documents/gost_7.32 -2017.pdf
(дата обращения 20.11.2022)
38
ПРИЛОЖЕНИЕ А
Рисунок А.1. Общий вид титульного листа ВКР
Рисунок А.2. Общий вид титульного листа курсовой работы
39
ПРИЛОЖЕНИЕ Б
Рисунок Б.1. Общий вид одностороннего задания на курсовую работу
Рисунок Б.2. Общий вид двустороннего задания на практику
40
Рисунок Б.3. Общий вид двустороннего задания на ВКР (лицевая сторона)
Рисунок Б.4. Общий вид задания на ВКР (оборотная сторона и окончание задания )
41
ПРИЛОЖЕНИЕ В
Рисунок В.1. Пример недопустимой ситуация в учебной работе, полупустая страница при условии,
что подраздел еще не закончился
42
ПРИЛОЖЕНИЕ Г
Рисунок Г.1. Пример 1 неправильного использования страницы с альбомной
ориентацией
Рисунок Г.1. Пример 2 неправильного использования страницы с альбомной
ориентацией
43
Рисунок Г.3. Неверное представление таблицы в учебной работе
44
ПРИЛОЖЕНИЕ Д
Рисунок Д.1. Пример оформления списка использованных источников без деления на
разделы
45
ПРИЛОЖЕНИЕ Е
Рисунок Е.1. Оформление ПРИЛОЖЕНИЯ в учебной работе
46
СВЕДЕНИЯ ОБ АВТОРАХ
Бергер Екатерина Геннадьевна, к.э.н., доцент кафедры практической и при-
кладной информатики Института информационных технологий
Кириллина Юлия Владимировна, к.э.н., доцент, доцент кафедры практиче-
ской и прикладной информатики Института информационных технологий
Лагунова Анна Дмитриевна, к.э.н., доцент кафедры практической и при-
кладной информатики Института информационных технологий
+15 -17
View File
@@ -13,34 +13,37 @@ if /i "%~2"=="clean" set "PYI_CLEAN=--clean"
echo === md2gost: быстрая сборка exe, onefile, шрифты и plantuml.jar ===
echo Каталог: %CD%
echo Spec: md2gost.fast.spec
echo Python: 3.11 ^(ищем, не берём первый python из PATH^)
echo Кэш: build-fast\ без --clean, пока не передадите clean
echo.
where python >nul 2>&1
call "%~dp0build-find-python311.bat"
if errorlevel 1 goto :nopython
python -c "import sys; raise SystemExit(sys.hexversion < 0x030A0000)"
if errorlevel 1 goto :oldpython
if not defined PYEXE goto :nopython
echo [1/4] Зависимости ...
python -c "import PyInstaller, matplotlib, docx, marko, lxml, pygments, PIL, freetype, latex2mathml, requests, docxcompose" 1>nul 2>nul
"%PYEXE%" -c "import PyInstaller, matplotlib, docx, marko, lxml, pygments, PIL, freetype, latex2mathml, requests, docxcompose, tkinterdnd2" 1>nul 2>nul
if not errorlevel 1 goto :deps_ok
echo Ставлю проект + PyInstaller + matplotlib ...
python -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7"
echo Ставлю проект + PyInstaller + matplotlib + tkinterdnd2 ...
"%PYEXE%" -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7" "tkinterdnd2"
if errorlevel 1 goto :pipfail
:deps_ok
if not exist "md2gost\Template.docx" goto :notemplate
echo [2/4] PlantUML jar + Mermaid assets ...
python scripts\fetch_plantuml.py
echo [2/4] PlantUML jar + Graphviz + Mermaid assets ...
"%PYEXE%" scripts\fetch_plantuml.py
if not exist "md2gost\vendor\plantuml.jar" goto :nojar
"%PYEXE%" scripts\fetch_graphviz.py
if not exist "md2gost\vendor\graphviz\bin\dot.exe" (
echo Предупреждение: Graphviz нет — в exe DFD пойдёт через kroki.io / PATH.
)
if not exist "md2gost\diagrams\mermaid.min.js" (
python scripts\fetch_mermaid.py
"%PYEXE%" scripts\fetch_mermaid.py
)
echo [3/4] PyInstaller onefile, кэш build-fast ...
python -m PyInstaller --noconfirm %PYI_CLEAN% --workpath build-fast --distpath dist md2gost.fast.spec
"%PYEXE%" -m PyInstaller --noconfirm %PYI_CLEAN% --workpath build-fast --distpath dist md2gost.fast.spec
if errorlevel 1 goto :pyifail
if not exist "dist\md2gost.exe" goto :noexe
@@ -57,12 +60,7 @@ if "%DO_PAUSE%"=="1" pause
exit /b 0
:nopython
echo Python не найден в PATH. Установите Python 3.10+ и отметьте "Add python.exe to PATH".
goto :fail
:oldpython
echo Нужен Python 3.10 или новее.
python --version
echo Нужен именно Python 3.11. Через uv без системного 3.11: build-exe-uvx.bat
goto :fail
:pipfail
+128
View File
@@ -0,0 +1,128 @@
@echo off
setlocal EnableExtensions
cd /d "%~dp0"
chcp 65001 >nul 2>&1
set "DO_PAUSE=1"
set "WANT_CLEAN=0"
set "FAST=0"
:parse_args
if "%~1"=="" goto :args_done
if /i "%~1"=="nopause" set "DO_PAUSE=0"
if /i "%~1"=="clean" set "WANT_CLEAN=1"
if /i "%~1"=="fast" set "FAST=1"
shift
goto :parse_args
:args_done
set "PYI_SPEC=md2gost.spec"
set "PYI_WORK="
set "PYI_DIST="
set "PYI_CLEAN=--clean"
if "%FAST%"=="1" (
set "PYI_SPEC=md2gost.fast.spec"
set "PYI_WORK=--workpath build-fast"
set "PYI_DIST=--distpath dist"
set "PYI_CLEAN="
if "%WANT_CLEAN%"=="1" set "PYI_CLEAN=--clean"
)
if "%FAST%"=="1" (
echo === md2gost: сборка exe через uvx, Python 3.11, fast spec ===
echo Spec: md2gost.fast.spec
echo Кэш: build-fast\
) else (
echo === md2gost: сборка exe через uvx, Python 3.11 ===
echo Spec: md2gost.spec
)
echo Каталог: %CD%
echo uvx сам возьмёт CPython 3.11 ^(скачает, если ещё нет^).
echo.
set "UVX_BIN="
for /f "delims=" %%I in ('where uvx 2^>nul') do (
set "UVX_BIN=%%I"
goto :got_uvx
)
if exist "%USERPROFILE%\.local\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.local\bin\uvx.exe"
if defined UVX_BIN goto :got_uvx
if exist "%USERPROFILE%\.cargo\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.cargo\bin\uvx.exe"
if defined UVX_BIN goto :got_uvx
echo uvx не найден. Установите uv: https://docs.astral.sh/uv/getting-started/installation/
echo powershell: irm https://astral.sh/uv/install.ps1 ^| iex
goto :fail
:got_uvx
echo uvx: %UVX_BIN%
"%UVX_BIN%" --python 3.11 python -c "import sys; print('CPython', sys.version.split()[0], sys.executable)"
if errorlevel 1 goto :nopython
if not exist "md2gost\Template.docx" goto :notemplate
echo [1/4] PlantUML jar + Graphviz + Mermaid assets ...
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_plantuml.py
if "%FAST%"=="1" (
if not exist "md2gost\vendor\plantuml.jar" goto :nojar
) else (
if not exist "md2gost\vendor\plantuml.jar" (
echo Предупреждение: plantuml.jar нет — в exe UML пойдёт через kroki.io.
)
)
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_graphviz.py
if not exist "md2gost\vendor\graphviz\bin\dot.exe" (
echo Предупреждение: Graphviz нет — в exe DFD пойдёт через kroki.io / PATH.
)
if not exist "md2gost\diagrams\mermaid.min.js" (
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_mermaid.py
)
if "%FAST%"=="0" (
if not exist "md2gost\diagrams\mermaid.min.js" (
echo Предупреждение: mermaid.min.js нет — браузерный Mermaid в exe без assets.
)
)
echo [2/4] Эфемерное окружение: проект + matplotlib + PyInstaller ^(CPython 3.11^) ...
echo [3/4] PyInstaller, один файл ...
"%UVX_BIN%" --python 3.11 --isolated --from "pyinstaller>=6.0" --with-editable . --with "matplotlib>=3.7" --with "tkinterdnd2" -- pyinstaller --noconfirm %PYI_CLEAN% %PYI_WORK% %PYI_DIST% %PYI_SPEC%
if errorlevel 1 goto :pyifail
if not exist "dist\md2gost.exe" goto :noexe
echo.
echo [4/4] Готово:
echo %CD%\dist\md2gost.exe
echo.
echo Двойной клик — GUI. Из консоли: md2gost.exe report.md -o report.docx
echo Быстрый кэш: build-exe-uvx.bat fast
echo Полный пересбор fast: build-exe-uvx.bat fast clean
echo.
if "%DO_PAUSE%"=="1" pause
exit /b 0
:nopython
echo uvx не смог запустить Python 3.11.
goto :fail
:notemplate
echo Нет md2gost\Template.docx — сборка бессмысленна.
goto :fail
:nojar
echo Нет md2gost\vendor\plantuml.jar — portable exe без jar не собираем.
goto :fail
:pyifail
echo PyInstaller завершился с ошибкой.
goto :fail
:noexe
echo dist\md2gost.exe не появился.
goto :fail
:fail
echo.
echo Сборка не удалась.
if "%DO_PAUSE%"=="1" pause
exit /b 1
+13 -15
View File
@@ -5,34 +5,37 @@ chcp 65001 >nul 2>&1
echo === md2gost: сборка exe ===
echo Каталог: %CD%
echo Python: 3.11 ^(ищем, не берём первый python из PATH^)
echo.
where python >nul 2>&1
call "%~dp0build-find-python311.bat"
if errorlevel 1 goto :nopython
python -c "import sys; raise SystemExit(sys.hexversion < 0x030A0000)"
if errorlevel 1 goto :oldpython
if not defined PYEXE goto :nopython
echo [1/4] Зависимости проекта + PyInstaller + matplotlib ...
python -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7"
"%PYEXE%" -m pip install -q -e . "pyinstaller>=6.0" "matplotlib>=3.7" "tkinterdnd2"
if errorlevel 1 goto :pipfail
if not exist "md2gost\Template.docx" goto :notemplate
echo [2/4] PlantUML jar + Mermaid assets ...
python scripts\fetch_plantuml.py
echo [2/4] PlantUML jar + Graphviz + Mermaid assets ...
"%PYEXE%" scripts\fetch_plantuml.py
if not exist "md2gost\vendor\plantuml.jar" (
echo Предупреждение: plantuml.jar нет — в exe UML пойдёт через kroki.io.
)
"%PYEXE%" scripts\fetch_graphviz.py
if not exist "md2gost\vendor\graphviz\bin\dot.exe" (
echo Предупреждение: Graphviz нет — в exe DFD пойдёт через kroki.io / PATH.
)
if not exist "md2gost\diagrams\mermaid.min.js" (
python scripts\fetch_mermaid.py
"%PYEXE%" scripts\fetch_mermaid.py
)
if not exist "md2gost\diagrams\mermaid.min.js" (
echo Предупреждение: mermaid.min.js нет — браузерный Mermaid в exe без assets.
)
echo [3/4] PyInstaller, один файл, без консоли; CLI подцепит консоль сам ...
python -m PyInstaller --noconfirm --clean md2gost.spec
"%PYEXE%" -m PyInstaller --noconfirm --clean md2gost.spec
if errorlevel 1 goto :pyifail
if not exist "dist\md2gost.exe" goto :noexe
@@ -48,12 +51,7 @@ pause
exit /b 0
:nopython
echo Python не найден в PATH. Установите Python 3.10+ и отметьте "Add python.exe to PATH".
goto :fail
:oldpython
echo Нужен Python 3.10 или новее.
python --version
echo Нужен именно Python 3.11. Через uv без системного 3.11: build-exe-uvx.bat
goto :fail
:pipfail
+90
View File
@@ -0,0 +1,90 @@
@echo off
REM Вызывается через call из build-exe.bat / build-exe-fast.bat.
REM На успехе: PYEXE = полный путь к python.exe 3.11, errorlevel 0.
REM Если 3.11 нет: PYEXE пустой, errorlevel 1.
set "PYEXE="
where py >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%I in ('py -3.11 -c "import sys; print(sys.executable)" 2^>nul ^| findstr /i /c:"python.exe"') do call :consider "%%I"
)
if defined PYEXE goto :ok
where uv >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%I in ('uv python find 3.11 2^>nul') do call :consider "%%I"
)
if defined PYEXE goto :ok
where python3.11 >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%I in ('where python3.11 2^>nul') do call :consider "%%I"
)
if defined PYEXE goto :ok
where python >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%I in ('where python 2^>nul') do call :consider "%%I"
)
if defined PYEXE goto :ok
where python3 >nul 2>&1
if not errorlevel 1 (
for /f "delims=" %%I in ('where python3 2^>nul') do call :consider "%%I"
)
if defined PYEXE goto :ok
if defined LOCALAPPDATA (
for /d %%D in ("%LOCALAPPDATA%\Programs\Python\Python311*") do call :consider "%%D\python.exe"
for /d %%D in ("%LOCALAPPDATA%\uv\python\cpython-3.11.*-windows*") do call :consider "%%D\python.exe"
)
if defined PYEXE goto :ok
if defined APPDATA (
for /d %%D in ("%APPDATA%\uv\python\cpython-3.11.*-windows*") do call :consider "%%D\python.exe"
)
if defined PYEXE goto :ok
if defined USERPROFILE (
for /d %%D in ("%USERPROFILE%\.local\share\uv\python\cpython-3.11.*-windows*") do call :consider "%%D\python.exe"
)
if defined PYEXE goto :ok
call :consider "%ProgramFiles%\Python311\python.exe"
if defined PYEXE goto :ok
call :consider "%ProgramFiles(x86)%\Python311-32\python.exe"
if defined PYEXE goto :ok
call :consider "C:\Python311\python.exe"
if defined PYEXE goto :ok
where uv >nul 2>&1
if errorlevel 1 goto :miss
echo Python 3.11 не найден, ставлю через uv python install 3.11 ...
uv python install 3.11
if errorlevel 1 goto :miss
for /f "delims=" %%I in ('uv python find 3.11 2^>nul') do call :consider "%%I"
if defined PYEXE goto :ok
:miss
echo Python 3.11 не найден.
echo Поставьте CPython 3.11 ^(python.org / py -3.11^) либо uv ^(тогда: uv python install 3.11^).
echo Либо соберите через build-exe-uvx.bat — uvx сам подтянет 3.11.
set "PYEXE="
exit /b 1
:ok
echo Python 3.11: %PYEXE%
"%PYEXE%" --version
exit /b 0
:consider
if defined PYEXE goto :eof
if "%~1"=="" goto :eof
echo "%~1" | findstr /i "WindowsApps" >nul
if not errorlevel 1 goto :eof
if not exist "%~1" goto :eof
"%~1" -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1)" 1>nul 2>nul
if errorlevel 1 goto :eof
set "PYEXE=%~1"
goto :eof
+4
View File
@@ -0,0 +1,4 @@
tests/_idef0_preview.png
tests/_idef0_preview.svg
tests/_idef0_preview_a0.png
tests/_idef0_preview_a0.svg
+8 -5
View File
@@ -6,11 +6,14 @@
1. [Быстрый старт](quickstart.md) — установка, первое окно, exe
2. [Синтаксис Markdown](markdown.md) — чем диалект отличается от обычного MD
3. [Схемы и диаграммы](schemes.md) — UML, Mermaid, настраиваемые схемы
4. [Типы документов](types.md) — coursework, practice, vkr, PIS_custom, APID_coursework
5. [CLI и GUI](cli-gui.md) — флаги и соответствие в интерфейсе
6. [Кастомные стили (JSON)](styles.md) — оверлей полей и стилей абзацев
7. [Промпты для ИИ](prompts.md) — генерация `.md`, расширение промпта схемами
3. [Схемы и диаграммы](schemes.md) — UML, Mermaid, IDEF0, DFD (вшитый Graphviz / Kroki), настраиваемые схемы
4. [ArchiMate 3.2](archimate.md) — генерация схем (макросы PlantUML, связи, viewpoints)
5. [Типы документов](types.md) — coursework, practice, vkr, PIS_custom, APID_coursework
6. [CLI и GUI](cli-gui.md) — флаги и соответствие в интерфейсе
7. [Автотитульник](title.md) — `info_conv.yaml`, блок `title`, ФИО/группа
8. [Кастомные стили (JSON)](styles.md) — оверлей полей и стилей абзацев
9. [Промпты для ИИ](prompts.md) — генерация `.md`, расширение промпта схемами
10. [Импорт Word → Markdown](word2md.md) — DOCX в диалект md2gost (word2md)
Исходный код конвертера: [`md2gost/`](../md2gost/). Примеры: [`examples/`](../examples/).
+662
View File
@@ -0,0 +1,662 @@
# ArchiMate 3.2: гайд по генерации схем (PlantUML + md2gost)
Учебный гайд по **правильной генерации** диаграмм ArchiMate® 3.2 в PlantUML для отчётов md2gost.
Язык — **только 3.2** (документ C226). ArchiMate 4 не используется.
ArchiMate® — зарегистрированный товарный знак The Open Group. Текст гайда — учебный пересказ корпуса PDF и документации PlantUML, **не** копия спецификации.
## Содержание
1. [Как рисовать в md2gost](#1-как-рисовать-в-md2gost)
2. [Каркас языка 3.2](#2-каркас-языка-32)
3. [Каталог элементов → макросы PlantUML](#3-каталог-элементов--макросы-plantuml)
4. [Связи](#4-связи)
5. [Что с чем связывать](#5-что-с-чем-связывать)
6. [Viewpoints](#6-viewpoints)
7. [Синтаксис PlantUML](#7-синтаксис-plantuml)
8. [Чеклист для ИИ](#8-чеклист-для-ии)
9. [Готовые шаблоны](#9-готовые-шаблоны)
10. [Антипаттерны](#10-антипаттерны)
11. [Источники и границы корпуса](#11-источники-и-границы-корпуса)
---
## 1. Как рисовать в md2gost
Оградка схемы: `` ```uml-archimate `` или `` ```archimate ``.
В теле пишите **только макросы** Archimate-PlantUML. Без `@startuml`, без `@enduml`, без `!include` — схема `archimate` добавит их сама.
````markdown
%arch1 Слоистая архитектура портала +landscape
```uml-archimate
LAYOUT_TOP_DOWN()
Business_Actor(student, "Студент")
Business_Process(enroll, "Подача заявки")
Application_Service(portalSvc, "Портал заявок")
Application_Component(portal, "Web-портал")
Rel_Assignment(student, enroll)
Rel_Serving(portalSvc, enroll)
Rel_Realization(portal, portalSvc)
```
````
| Флаг у `%подписи` | Смысл |
|-------------------|--------|
| `+landscape` | Альбомная страница под широкий рисунок |
| `+listing` | Рядом показать исходник диаграммы |
Широкие Layered / Application Cooperation почти всегда с `+landscape`.
---
## 2. Каркас языка 3.2
### 2.1. Модель, концепт, элемент, связь
По sample C226 (§23):
- **Model** — набор концептов.
- **Concept** — либо **element**, либо **relationship**.
- **Element** — behavior, structure, motivation или composite (абстрактные классы; в модели не рисуются сами по себе).
- **Relationship** — связь source → target; класс: structural / dependency / dynamic / other.
- **Relationship connector (Junction)** — соединяет две или более связи **одного** типа.
Язык намеренно мал: «80 % практических случаев», без попытки покрыть всех пользователей сразу (§3.1).
### 2.2. Три core-слоя
| Слой | Что показывает |
|------|----------------|
| **Business** | Бизнес-сервисы для клиентов; процессы и акторы, которые их реализуют |
| **Application** | Прикладные сервисы и приложения, поддерживающие бизнес |
| **Technology** | ИТ и OT: обработка, хранение, сети; также физика (сооружения, оборудование, материалы, распределительные сети) |
Структура моделей на слоях похожа: те же виды элементов и связей, разная природа и гранулярность.
### 2.3. Аспекты (подлежащее — сказуемое — дополнение)
| Аспект | Роль | Примеры |
|--------|------|---------|
| **Active structure** | Кто/что действует | Actor, Role, Component, Node, Device |
| **Behavior** | Что делается | Process, Function, Interaction, Service, Event |
| **Passive structure** | Над чем работают | Business Object, Data Object, Artifact, Material |
| **Motivation** | Зачем | Stakeholder, Driver, Goal, Requirement… |
| **Composite** | Группировка / место | Grouping, Location, Product |
На листах Виерды (не стандарт Open Group): синий = активная структура, жёлтый = поведение, зелёный = пассивная. **Официальный ArchiMate бесцветен**; цвета — соглашение инструментов (в PlantUML — палитры слоёв `#Business`, `#Application`, …).
### 2.4. Full framework
К core добавляются (van den Berg §2.4 / full framework C226):
- слой **Strategy**;
- слой **Implementation & Migration**;
- аспект **Motivation**.
### 2.5. Два главных межслойных моста (§3.3)
1. **Serving** (раньше *used by*): сервис (или элемент) **предоставляет функциональность** другому. Сервис нижнего/соседнего слоя обслуживает потребителя. Сервис может обслуживать и элемент **того же** слоя.
2. **Realization**: более конкретное (часто ниже) **реализует** более абстрактное (часто выше): например приложение реализует прикладной сервис, сервис/процесс реализует требование или capability.
Каноническая цепочка внутри слоя:
```text
Active structure —Assignment→ internal behavior (Process/Function)
internal behavior —Realization→ Service (external behavior)
Service —Serving→ consumer (Role / Process / Component …)
behavior —Access→ passive structure
```
---
## 3. Каталог элементов → макросы PlantUML
Определения — **N221** (Reference Cards). Макросы — Archimate-PlantUML 3.2.1.
Формат: `Category_ElementName(alias, "Подпись")`.
Alias — латиница (`student`, `crm`). Не используйте `end`, `start`, `group` как alias.
### 3.1. Motivation
| Элемент | Макрос | Определение (N221) |
|---------|--------|-------------------|
| Stakeholder | `Motivation_Stakeholder` | Роль индивида, команды или организации, заинтересованной в эффектах архитектуры |
| Driver | `Motivation_Driver` | Внешнее/внутреннее условие, побуждающее задать цели и изменения |
| Assessment | `Motivation_Assessment` | Результат анализа положения дел относительно driver |
| Goal | `Motivation_Goal` | Высокоуровневое намерение / желаемое конечное состояние |
| Outcome | `Motivation_Outcome` | Конечный результат / эффект некоторого положения дел |
| Principle | `Motivation_Principle` | Общее свойство, применимое к любой системе в контексте |
| Requirement | `Motivation_Requirement` | Потребность: свойство конкретной системы в архитектуре |
| Constraint | `Motivation_Constraint` | Ограничение на архитектуру, реализацию или процесс внедрения |
| Meaning | `Motivation_Meaning` | Знание/интерпретация концепта в контексте |
| Value | `Motivation_Value` | Относительная ценность / полезность концепта |
### 3.2. Strategy
| Элемент | Макрос | Определение (N221) |
|---------|--------|-------------------|
| Resource | `Strategy_Resource` | Актив, которым владеют или управляют |
| Capability | `Strategy_Capability` | Способность, которой обладает активная структура |
| Value Stream | `Strategy_ValueStream` | Последовательность активностей, создающая результат для клиента/стейкхолдера |
| Course of Action | `Strategy_CourseOfAction` | Подход/план настройки capabilities и resources для достижения цели |
### 3.3. Business
| Элемент | Макрос | Аспект | Определение (N221) |
|---------|--------|--------|-------------------|
| Business Actor | `Business_Actor` | Active | Сущность, способная выполнять поведение |
| Business Role | `Business_Role` | Active | Ответственность за поведение; роль актора |
| Business Collaboration | `Business_Collaboration` | Active | Агрегат ≥2 внутренних active structure для коллективного поведения |
| Business Interface | `Business_Interface` | Active | Точка доступа, где бизнес-сервис доступен среде |
| Business Process | `Business_Process` | Behavior | Последовательность бизнес-поведения к конкретному результату |
| Business Function | `Business_Function` | Behavior | Набор поведения по критериям (ресурсы/компетенции) |
| Business Interaction | `Business_Interaction` | Behavior | Коллективное поведение ≥2 акторов/ролей/коллабораций |
| Business Event | `Business_Event` | Behavior | Изменение состояния на бизнес-уровне |
| Business Service | `Business_Service` | Behavior | Явно определённое поведение, доступное среде |
| Business Object | `Business_Object` | Passive | Концепт предметной области |
| Contract | `Business_Contract` | Passive | Соглашение провайдер–потребитель (права/обязанности, параметры) |
| Representation | `Business_Representation` | Passive | Воспринимаемая форма информации бизнес-объекта |
| Product | `Business_Product` | Composite | Согласованный набор сервисов/пассивных элементов + contract |
### 3.4. Application
| Элемент | Макрос | Аспект | Определение (N221) |
|---------|--------|--------|-------------------|
| Application Component | `Application_Component` | Active | Инкапсуляция прикладной функциональности, модульная и заменяемая |
| Application Collaboration | `Application_Collaboration` | Active | Агрегат ≥2 application internal active structure |
| Application Interface | `Application_Interface` | Active | Точка доступа к application services |
| Application Function | `Application_Function` | Behavior | Автоматизированное поведение компонента |
| Application Interaction | `Application_Interaction` | Behavior | Коллективное поведение ≥2 компонентов |
| Application Process | `Application_Process` | Behavior | Последовательность прикладного поведения к результату |
| Application Event | `Application_Event` | Behavior | Изменение состояния приложения |
| Application Service | `Application_Service` | Behavior | Явно определённое внешнее прикладное поведение |
| Data Object | `Application_DataObject` | Passive | Данные, структурированные для обработки |
### 3.5. Technology (+ Physical в гл. Technology 3.2)
| Элемент | Макрос | Определение (N221) |
|---------|--------|-------------------|
| Node | `Technology_Node` | Вычислительный/физический ресурс, хостящий другие ресурсы |
| Device | `Technology_Device` | Физический ИТ-ресурс для хранения/исполнения ПО и артефактов |
| System Software | `Technology_SystemSoftware` | ПО среды исполнения/хранения |
| Technology Collaboration | `Technology_Collaboration` | Агрегат ≥2 technology internal active structure |
| Technology Interface | `Technology_Interface` | Точка доступа к technology services |
| Path | `Technology_Path` | Связь между ≥2 technology internal active structure для обмена |
| Communication Network | `Technology_CommunicationNetwork` | Структуры/поведение для передачи данных |
| Technology Function | `Technology_Function` | Набор технологического поведения |
| Technology Process | `Technology_Process` | Последовательность технологического поведения |
| Technology Interaction | `Technology_Interaction` | Коллективное technology-поведение |
| Technology Event | `Technology_Event` | Изменение технологического состояния |
| Technology Service | `Technology_Service` | Явно определённое внешнее technology-поведение |
| Artifact | `Technology_Artifact` | Данные/файл, используемый или порождаемый разработкой/эксплуатацией |
| Equipment | `Physical_Equipment` | Физические машины/инструменты для материи |
| Facility | `Physical_Facility` | Физическая структура или окружение |
| Distribution Network | `Physical_DistributionNetwork` | Физическая сеть транспортировки материи/энергии |
| Material | `Physical_Material` | Осязаемая материя или энергия |
В 3.2 Device / System Software / Facility / Equipment — **не** подтипы Node, а technology internal active structure с composition/aggregation к Node (changelog C226).
### 3.6. Implementation & Migration
| Элемент | Макрос | Определение (N221) |
|---------|--------|-------------------|
| Work Package | `Implementation_WorkPackage` | Серия действий с результатом в сроках и ресурсах |
| Deliverable | `Implementation_Deliverable` | Точно определённый результат work package |
| Implementation Event | `Implementation_Event` | Изменение состояния, связанное с внедрением/миграцией |
| Plateau | `Implementation_Plateau` | Относительно стабильное состояние архитектуры на период |
| Gap | `Implementation_Gap` | Различие между двумя plateaus |
### 3.7. Composite и Junction
| Элемент | Макрос | Определение (N221) |
|---------|--------|-------------------|
| Grouping | `Grouping` / `Other_Grouping` / `Group` | Агрегирует/композирует концепты по общему признаку |
| Location | `Other_Location` / `Business_Location` | Место, где расположены или выполняются концепты |
| And Junction | `Junction_And` | Логическое И для связей одного типа |
| Or Junction | `Junction_Or` | Логическое ИЛИ для связей одного типа |
Grouping и Junction допустимы **в любом** viewpoint (спецификация).
### 3.8. Когда какой элемент поведения
| Нужно | Берите |
|-------|--------|
| Последовательность шагов к результату | `*_Process` |
| Группа поведения «изнутри» по компетенциям/ресурсам | `*_Function` |
| Поведение коллаборации (≥2 участников) | `*_Interaction` |
| Внешне видимое поведение (чёрный ящик) | `*_Service` |
| Момент / смена состояния | `*_Event` |
**Не смешивайте слои:** `Business_Process` ≠ `Application_Process` ≠ `Technology_Process`. Слой задаётся типом элемента, не подписью.
---
## 4. Связи
### 4.1. Одиннадцать типов (N221) + PlantUML
| Тип | Класс | Смысл (N221) | Макрос | Нотация PlantUML |
|-----|-------|--------------|--------|------------------|
| Composition | Structural | Элемент **состоит из** других; часть не существует без целого | `Rel_Composition` | `*--` |
| Aggregation | Structural | Элемент **объединяет** другие; части могут жить отдельно | `Rel_Aggregation` | `o--` |
| Assignment | Structural | Ответственность / выполнение поведения / хранение / исполнение | `Rel_Assignment` | `@-->>` |
| Realization | Structural | Критическая роль в создании/достижении более абстрактного | `Rel_Realization` | `~~|>` |
| Serving | Dependency | Предоставляет функциональность другому | `Rel_Serving` | `-->` |
| Access | Dependency | Поведение/active structure наблюдает или действует на passive | `Rel_Access` / `_r` / `_w` / `_rw` | `~~` / `<-~` / `~->` / `<~~>` |
| Influence | Dependency | Влияет на реализацию/достижение motivation-элемента (`+/-`) | `Rel_Influence` | `..>` |
| Triggering | Dynamic | Временная/причинная активация | `Rel_Triggering` | `-->>` |
| Flow | Dynamic | Передача чего-либо | `Rel_Flow` | `..>>` |
| Specialization | Other | Является разновидностью | `Rel_Specialization` | `--|>` |
| Association | Other | Неспецифичная связь (или нет другого типа) | `Rel_Association` / `_dir` | `--` / `--\` |
| Junction | Connector | Соединяет связи **одного** типа | `Junction_And` / `Junction_Or` | circle |
Направление на схеме: `Rel_Type_Up` / `_Down` / `_Left` / `_Right` (например `Rel_Serving_Up(svc, proc)`).
### 4.2. Направления (частые ошибки)
| Связь | Откуда → куда |
|-------|----------------|
| **Serving** | Поставщик (часто Service) → **потребитель** |
| **Realization** | Более конкретное → более абстрактное |
| **Composition / Aggregation** | Целое → часть |
| **Assignment** | Active structure → behavior (или ресурс хранения → artifact) |
| **Access** | Behavior (или active) → **passive**; тип: read / write / read-write |
| **Triggering** | Предшественник → следующий |
| **Flow** | Источник передачи → приёмник |
| **Influence** | Влияющий → motivation-элемент (сила `++`/`+`/`0`/`-`/`--` в подписи) |
| **Specialization** | Специализация → общий тип |
На листах: realization — «вверх» по абстракции; serving — к тому, кого обслуживают. Access всегда к объекту, независимо от отрисовки стрелки.
### 4.3. Правила с листов (метамодель ядра)
- Любой элемент может **compose/aggregate** элементы **того же класса** (на обзорных диаграммах часто не рисуют).
- Внутренние поведения одного слоя могут compose/aggregate друг друга (Function агрегирует Process).
- **Association** формально возможна между любыми классами — но на схеме используйте её только когда нет более точного типа.
- Collaboration ведёт себя как соответствующий internal active structure.
- Stakeholder — Association с любым элементом; элементы мотивации (кроме Stakeholder) — Influence друг на друга.
### 4.4. Чего нет в корпусе
Полная **Appendix B** (матрица всех допустимых пар и derivation) в sample PDF **обрезана**. Ниже — практические цепочки из §3.3, N221, листов и типичных viewpoint’ов. Не выдавайте их за полную нормативную матрицу B.
---
## 5. Что с чем связывать
### 5.1. Канонические цепочки
```text
# Внутри слоя
Business_Actor/Role Rel_Assignment Business_Process/Function
Business_Process Rel_Realization Business_Service
Business_Process Rel_Access_* Business_Object
Business_Event Rel_Triggering Business_Process
Application_Component Rel_Assignment Application_Function/Process
Application_Function Rel_Realization Application_Service
Application_Service Rel_Serving Business_Process/Role
Application_Component Rel_Access_* Application_DataObject
Application_Component Rel_Composition Application_Component
Technology_Node Rel_Composition Technology_Device / SystemSoftware
Technology_* Rel_Assignment Technology_Function/Process
Technology_Function Rel_Realization Technology_Service
Technology_Service Rel_Serving Application_Component
Technology_Artifact Rel_Realization Application_DataObject
Application_DataObject Rel_Realization Business_Object
# Стратегия / мотивация
Business_Process / Application_Service Rel_Realization Strategy_Capability
Application_Service / Component Rel_Realization Motivation_Requirement
Motivation_Driver Rel_Association Motivation_Stakeholder
Motivation_Assessment Rel_Association Motivation_Driver
Motivation_Goal Rel_Realization Motivation_Outcome
Motivation_Requirement Rel_Specialization Motivation_Constraint # или наоборот по смыслу «ограничение как вид требования» — лучше Constraint отдельно + Influence/Association
# Миграция
Implementation_WorkPackage Rel_Realization / Aggregation Implementation_Deliverable
Implementation_Plateau Rel_Aggregation core elements
Implementation_Gap Rel_Association between plateaus
```
### 5.2. Запреты для генерации
1. Не `Rel_Assignment` от Data Object / Business Object к процессу.
2. Не `Rel_Realization` от Component сразу к Process — нужен Function/Process того же слоя, затем Realization к Service.
3. Не `Rel_Serving` «потребитель → сервис» (перевёрнуто).
4. Не подменять Access потоком Flow «потому что данные».
5. Не рисовать `Business_Process` вместо `Application_Process`, если исполнитель — компонент.
6. Не ставить Association на все рёбра «на всякий случай».
7. Не считать Device подтипом Node без composition/aggregation (3.2).
---
## 6. Viewpoints
**Один рисунок = один viewpoint.** Не сваливайте мотивацию, бизнес, приложения и ЦОД на один холст без нужды (Layered — исключение, но всё равно ограничивайте число элементов).
Имена — из оглавления sample C226, Appendix C:
### 6.1. Basic (C.1)
| Viewpoint | Фокус | Типичные элементы |
|-----------|-------|-------------------|
| Organization | Структура орг. единиц | Actor, Role, Collaboration, Location, Interface |
| Application Structure | Внутренняя структура приложений | Component, Collaboration, Interface, Data Object |
| Information Structure | Данные/информация | Business Object, Representation, Data Object, Artifact, Meaning |
| Technology | Инфраструктура | Node, Device, System Software, Network, Path, Artifact… |
| Layered | Обзор слоёв | Набор из Business + Application + Technology + serving/realization |
| Physical | Физический мир | Equipment, Facility, Distribution Network, Material + tech |
| Product | Ценность продукта | Product, Contract, Services, Interfaces |
| Application Usage | Приложения в бизнес-контексте | Process/Function/Service бизнес + application |
| Technology Usage | Технологии для приложений | Application + Technology services/nodes |
| Business Process Cooperation | Связь процессов | Processes, Events, Services, Roles, supporting apps |
| Application Cooperation | Связь приложений | Components, Services, Interfaces, Data, Flow/Serving |
| Service Realization | Как сервисы реализованы | Service ← Process/Function ← Active structure |
| Implementation and Deployment | Развёртывание | Application + Artifact + Node/Device |
### 6.2. Motivation (C.2)
Stakeholder · Goal Realization · Requirements Realization · Motivation — Driver/Assessment/Goal/Outcome/Principle/Requirement/Constraint/Value/Meaning и Realization/Influence к core.
### 6.3. Strategy (C.3)
Strategy · Capability Map · Value Stream · Outcome Realization · Resource Map.
### 6.4. Implementation & Migration (C.4)
Project · Migration · Implementation and Migration — Work Package, Deliverable, Plateau, Gap, Event.
### 6.5. Макет
- Организация / структура: `LAYOUT_TOP_DOWN()` или вложенность `{ }`.
- Процессы слева направо: `LAYOUT_LEFT_RIGHT()` + `Rel_*_Right`.
- Layered: бизнес сверху, technology снизу; serving вверх (`Rel_Serving_Up`), realization вверх к абстракции.
- Широкие схемы: `%id … +landscape`.
Пример композиции (van den Berg): сначала **Motivation viewpoint** (риски/цели), затем отдельные схемы процессов с **Junction** на ветвлениях восстановления.
---
## 7. Синтаксис PlantUML
Документация: [plantuml.com/archimate-diagram](https://plantuml.com/archimate-diagram). Stdlib: Archimate-PlantUML **3.2.1**.
### 7.1. Рекомендуемый путь (схема md2gost)
```text
LAYOUT_TOP_DOWN() ' или LAYOUT_LEFT_RIGHT()
Business_Actor(a, "Имя")
Application_Service(s, "Сервис")
Rel_Serving_Up(s, a)
```
Include и `@startuml` добавляет схема `archimate`.
### 7.2. Низкоуровневый keyword (без макросов)
Только в `` ```uml ``, если stdlib недоступен:
```plantuml
@startuml
archimate #Business "Подача заявки" as p <<business-process>>
archimate #Application "Портал" as c <<application-component>>
c --> p
@enduml
```
Sprites: `<<$archimate/business-process>>` или `jar:archimate/...`.
**Не смешивайте** макросы и сырой `archimate` на одной схеме.
### 7.3. Темы
```plantuml
!theme archimate-standard from <archimate/themes>
' archimate-alternate | saturated | lowsaturation | handwriting
```
### 7.4. Nesting и special shapes
```plantuml
Business_Product(prod, "Тариф") {
Business_Service(s1, "Подключение")
Business_Contract(c1, "Оферта")
}
```
Для special shapes (`$ARCH_SPECIAL_SHAPES`) у Service/Actor/Value/ValueStream при вложении: `$nest=%true()`.
### 7.5. Layout helpers
`Lay_U` / `Lay_D` / `Lay_L` / `Lay_R` — скрытые связи для выравнивания.
`LAYOUT_AS_SKETCH()` — черновик (не для финального ГОСТ-рисунка).
### 7.6. Sequence с ArchiMate
Только если явно просят sequence: `!global $ARCH_SEQUENCE_SUPPORT = %true()` до include. Это **не** замена viewpoint.
### 7.7. List sprites
```plantuml
@startuml
listsprite
@enduml
```
---
## 8. Чеклист для ИИ
1. Версия языка: **ArchiMate 3.2**; макросы слоя (`Business_*`, `Application_*`, …).
2. Оградка `` ```uml-archimate ``; без `@startuml` / `!include`.
3. Выбрать **один** viewpoint; 8–18 элементов на рисунок.
4. Сначала все элементы, потом все `Rel_*`.
5. Alias латиницей; подписи по-русски в кавычках; длинные имена с `\n`.
6. Serving от сервиса к потребителю; Realization от конкретного к абстрактному.
7. Поведение того же слоя, что и исполнитель (Assignment).
8. Не UML class/component, не C4 `Person`, не BPMN `XOR`, не mxgraph.
9. Не Association «по умолчанию».
10. Для отчёта: `%id Название` (+ `+landscape` если широко).
---
## 9. Готовые шаблоны
Тела для `` ```uml-archimate `` (без обёртки).
### 9.1. Organization
```plantuml
LAYOUT_TOP_DOWN()
Business_Actor(uni, "Университет") {
Business_Actor(dean, "Деканат")
Business_Actor(it, "Управление ИТ")
}
Business_Role(student, "Студент")
Business_Role(clerk, "Методист")
Rel_Assignment(dean, clerk)
Rel_Assignment(uni, student)
```
### 9.2. Service Realization
```plantuml
LAYOUT_TOP_DOWN()
Business_Role(applicant, "Абитуриент")
Business_Process(apply, "Подача заявления")
Business_Service(applySvc, "Приём заявлений")
Application_Service(webSvc, "Веб-приёмная")
Application_Component(lk, "Личный кабинет")
Application_Function(submitFn, "Отправка формы")
Rel_Assignment(applicant, apply)
Rel_Realization(apply, applySvc)
Rel_Serving(webSvc, apply)
Rel_Assignment(lk, submitFn)
Rel_Realization(submitFn, webSvc)
```
### 9.3. Application Cooperation
```plantuml
LAYOUT_LEFT_RIGHT()
Application_Component(crm, "CRM")
Application_Component(billing, "Биллинг")
Application_Service(crmApi, "API клиентов")
Application_DataObject(client, "Карточка клиента")
Rel_Realization(crm, crmApi)
Rel_Serving(crmApi, billing)
Rel_Access_rw(crm, client)
Rel_Access_r(billing, client)
Rel_Flow(crm, billing, "событие оплаты")
```
### 9.4. Layered
```plantuml
LAYOUT_TOP_DOWN()
Business_Process(sale, "Оформление заказа")
Business_Object(order, "Заказ")
Application_Service(shopSvc, "Интернет-магазин")
Application_Component(shop, "ShopApp")
Technology_Service(dbSvc, "СУБД")
Technology_Node(dbHost, "DB Host")
Technology_SystemSoftware(pg, "PostgreSQL")
Rel_Access_w(sale, order)
Rel_Serving_Up(shopSvc, sale)
Rel_Realization(shop, shopSvc)
Rel_Serving_Up(dbSvc, shop)
Rel_Assignment(dbHost, dbSvc)
Rel_Composition(dbHost, pg)
```
### 9.5. Motivation / Requirements Realization
```plantuml
LAYOUT_TOP_DOWN()
Motivation_Stakeholder(rector, "Ректор")
Motivation_Driver(comp, "Конкуренция вузов")
Motivation_Goal(digital, "Цифровизация приёма")
Motivation_Requirement(online, "Заявление онлайн 24/7")
Motivation_Constraint(152fz, "152-ФЗ")
Application_Service(portal, "Портал абитуриента")
Rel_Association(rector, comp)
Rel_Association(comp, digital)
Rel_Realization(online, digital)
Rel_Influence(152fz, online, "-")
Rel_Realization(portal, online)
```
### 9.6. Implementation & Migration
```plantuml
LAYOUT_LEFT_RIGHT()
Implementation_Plateau(asIs, "AS-IS 2025")
Implementation_Plateau(toBe, "TO-BE 2026")
Implementation_Gap(gap1, "Нет единого ЛК")
Implementation_WorkPackage(wp, "Проект Единый ЛК")
Implementation_Deliverable(d1, "Релиз 1.0")
Implementation_Event(goLive, "Go-Live")
Rel_Association(gap1, asIs)
Rel_Association(gap1, toBe)
Rel_Realization(wp, d1)
Rel_Triggering(d1, goLive)
Rel_Triggering(goLive, toBe)
```
### 9.7. Technology Usage
```plantuml
LAYOUT_TOP_DOWN()
Application_Component(api, "API Gateway")
Technology_Node(k8s, "Kubernetes")
Technology_Device(node1, "Worker x86")
Technology_SystemSoftware(os, "Linux")
Technology_CommunicationNetwork(net, "Cluster CNI")
Technology_Artifact(image, "api:1.2.img")
Rel_Serving_Up(k8s, api)
Rel_Composition(k8s, node1)
Rel_Composition(node1, os)
Rel_Association(node1, net)
Rel_Assignment(node1, image)
```
### 9.8. Process с Junction (в духе van den Berg)
```plantuml
LAYOUT_LEFT_RIGHT()
Business_Event(incident, "Инцидент")
Business_Process(triage, "Триаж")
Junction_Or(j1, "")
Business_Process(restore, "Восстановление КФ")
Business_Process(investigate, "Расследование")
Business_Process(close, "Закрытие")
Rel_Triggering(incident, triage)
Rel_Triggering(triage, j1)
Rel_Triggering(j1, restore)
Rel_Triggering(j1, investigate)
Rel_Triggering(restore, close)
Rel_Triggering(investigate, close)
```
---
## 10. Антипаттерны
| Плохо | Почему | Как надо |
|-------|--------|----------|
| Все связи `Rel_Association` | Теряется семантика | Serving / Realization / Assignment / Access |
| `Application_Component` вместо `Business_Actor` | Разные аспекты и слои | Actor/Role для людей/орг. единиц |
| Business Process «внутри» компонента без Assignment | Слои смешаны | Process на бизнес-слое; Serving от Application Service |
| Serving от Role к Service | Направление наоборот | Service → Role/Process |
| Realization Component → Process | Пропущен внутренний behavior | Component → Function → Service |
| Flow вместо Access к Data Object | Flow — передача, Access — работа с объектом | `Rel_Access_r/w/rw` |
| Один Layered на 40+ элементов | Нечитаемо | Несколько viewpoint’ов |
| C4 `Person` / UML class в archimate-блоке | Другой язык | Только макросы ArchiMate |
| Generic «Process» без слоя | В 3.2 слоя обязательны | `Business_Process` / `Application_Process` / … |
---
## 11. Источники и границы корпуса
### Норматив (при конфликте — сверху вниз)
1. **n221p.pdf** — ArchiMate 3.2 Reference Cards (N221): определения элементов и связей.
2. **978940180955C_SMPL.pdf** (+ дубль SMPL-1) — sample C226: гл. 12, начало гл. 3, оглавление (в т.ч. список viewpoints Appendix C).
3. **archimate-sheets-ru-20230805-s.pdf** — листы метамодели 3.2 (Виерда / Ефремов): прямые связи, цвета аспектов на листах. Описания элементов на листах **не** от Open Group — для формулировок «что такое X» побеждает N221.
### Синтаксис рисунка
- [plantuml.com/archimate-diagram](https://plantuml.com/archimate-diagram)
- Stdlib Archimate-PlantUML 3.2.1 (`!include <archimate/Archimate>`)
### Примеры композиции (не словарь языка)
- hospital ZiRA preview (inkijkexemplaar) — reference architecture / C226.
- **vandenBerg_MA_EEMCS.pdf** — §2.4 framework, motivational viewpoint, Junction в процессах BCM/DR.
### Не правила ArchiMate
- Article_37 (FEM), paper_41 (OntoUML/COVO), FASD (UML атак), 419 (единое окно), **2659430** (Packt Practical Cybersecurity Architecture — процесс кибер-архитектуры, не спецификация 3.2).
### Ограничения
- Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5.
- PDF спецификации в git не хранятся.
- Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`.
- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются.
---
См. также: [schemes.md](schemes.md), меню **Шаблоны UML**, **Справка → Промпт для ИИ** (схема `archimate`).
+25 -1
View File
@@ -8,7 +8,9 @@
python -m md2gost # GUI
python -m md2gost --gui report.md
python -m md2gost report.md -o out.docx --type coursework --check --strict
python -m md2gost report.docx -o report.md # импорт Word → Markdown (word2md)
python -m md2gost report.docx --check-pages # только проверка вёрстки готового DOCX
python -m word2md report.docx -o report.md
```
Справка: `python -m md2gost -h`.
@@ -24,6 +26,10 @@ python -m md2gost report.docx --check-pages # только проверка в
| `--emdash-to-hyphen` | Галочка «—» → «-» | по умолчанию выкл. |
| `--hr-pagebreak` | Галочка --- → разрыв | по умолчанию `---` игнорируется |
| `--title` / `--assignment` / `-t` | Настройки → Файлы | титул, задание, шаблон |
| `--auto-title` / `--no-auto-title` | галочка «Автотитул» | автогенерация титула (по умолчанию выкл.) |
| `--student` / `--group` | Настройки → Студент… | ФИО и группа для автотитула |
| `--doc-author-from` / `--doc-author` | Настройки → Метаданные… | автор в свойствах DOCX (`os` / `student` / `custom`) |
| `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by` | Настройки → Метаданные… | остальные поля File → сведения |
| `--check` / `--check-only` / `--strict` | Галочки проверки ТЗ | замечания по структуре и источникам |
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
@@ -34,6 +40,7 @@ python -m md2gost report.docx --check-pages # только проверка в
| `--diagram-format` | Диаграммы | `png` / `svg` |
| `--diagram-scale` | Диаграммы | качество PNG PlantUML / локального Mermaid (по умолчанию 2) |
| `--page-start N` | Основные → Смещение страниц | начальный номер PAGE в теле; пусто = сквозной |
| (нет CLI) | Диаграммы → «Скачать Graphviz» | portable Graphviz для DFD (`vendor` или `%LOCALAPPDATA%\md2gost\graphviz`) |
| `--install-chromium` | Диаграммы → «Скачать headless Chromium» | Chromium для Mermaid в `%LOCALAPPDATA%\md2gost` |
| `--schemes` | файл схем / Шаблоны UML | путь к `md2gost.schemes.json` |
| `--styles` | Настройки → Файлы → Стили JSON | оверлей `md2gost.styles.json` (также рядом с `.md`); см. [styles.md](styles.md) |
@@ -47,14 +54,31 @@ DOCX не умеет сам писать «Продолжение Таблицы
## Меню GUI
- **Настройки → Файлы** — шаблон, титул, задание, стили JSON, выходной путь
- **Настройки → Диаграммы** — PlantUML, Mermaid (Chromium), Kroki, формат, кэш includes
- **Настройки → Студент…** — ФИО и группа (при первом запуске спрашивают сами)
- **Настройки → Метаданные…** — свойства DOCX (автор, название, тема, теги, примечание)
- **Настройки → Диаграммы** — PlantUML, Graphviz (DFD), Mermaid (Chromium), Kroki, формат, кэш includes
- **Шаблоны UML** — редактор `md2gost.schemes.json`
- **Справка → Инструкция / Документация / Схемы / Промпт для ИИ**
(Документация — встроенный просмотр `docs/*.md`, вшито в exe)
## Автотитульник
Кратко: **по умолчанию выкл.** Вкл. — `--auto-title` или галочка «Автотитул». Рядом с `.md``info_conv.yaml`, в MD — опциональный блок `title` (`number` / `year`), ФИО и группа — **Настройки → Студент…** или `--student` / `--group`. Явный `--title` отключает генератор.
Подробно (примеры, таблица полей, приоритеты, FAQ): **[title.md](title.md)**.
## Свойства файла Word
По умолчанию в DOCX пишутся **автор = имя пользователя Windows** и примечание «Создано при помощи md2gost (ТЗ МИРЭА)». Даты создания/изменения ставятся на момент сборки, revision = 1 (чтобы не утекали 2013 год и revision из шаблона).
Задать свои поля: **Настройки → Метаданные…** (сохраняется в `md2gost.user.json`) или флаги `--doc-author-from` / `--doc-author` / `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by`. Источник автора: `os` (по умолчанию), `student` (ФИО из профиля), `custom` (строка). Пустое `--doc-comments` / пустое примечание в GUI — не писать штамп md2gost.
## Другие форматы
```bash
python -m md2fodt report.md -o report.fodt # LibreOffice
python -m word2md report.docx -o report.md # Word → Markdown (диалект md2gost)
# PDF через LaTeX — см. md2latex/
```
Импорт DOCX подробнее: [word2md.md](word2md.md). В GUI — кнопка «Импорт DOCX→MD» или drop `.docx`.
+7 -1
View File
@@ -89,7 +89,7 @@ def f():
## Диаграмма → Рисунок
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, или id схемы (`c4`, `bpmn`, …).
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, `idef0`, `dfd`/`data-flow-diagram` (Graphviz вшитый/PATH или Kroki), или id схемы (`c4`, `usecase`, …).
- `+listing` — ещё и Листинг с исходником
- `+landscape` — альбомная страница под широкий рисунок/таблицу
@@ -144,6 +144,12 @@ $$
- Тире в предложениях: «—» (по умолчанию сохраняется). Замена на «-»: `--emdash-to-hyphen`.
- Строка `---` / `***` / `___` по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI.
## Автотитульник (метаданные в MD)
В начале файла можно указать блок с оградой `title` (номер работы, год, оверрайды полей). Рядом с файлом — `info_conv.yaml` с институтом, кафедрой, дисциплиной и преподавателем. ФИО и группа студента задаются в GUI / CLI, не в yaml. Автогенерация титула **по умолчанию выкл.** — включите `--auto-title` или галочку «Автотитул».
Подробно: [title.md](title.md).
## Запрещено в отчёте
- «рис.», «табл.»
+4 -3
View File
@@ -9,11 +9,12 @@
| [`generate-md.md`](../prompts/generate-md.md) | Базовый: диалект md2gost, чем отличается от обычного MD; UML/Mermaid рисует конвертер |
| [`generate-mirea-report.md`](../prompts/generate-mirea-report.md) | Полный отчёт ГОСТ / МИРЭА (`coursework`, `practice`, `vkr`) |
| [`generate-pis-custom-report.md`](../prompts/generate-pis-custom-report.md) | Итоговый отчёт ПИС (`PIS_custom`) |
| [`emulate-student.md`](../prompts/emulate-student.md) | После любого generate-*: текст про предмет, не отчёт модели о сдаче ТЗ |
## Как пользоваться
1. Откройте промпт (файл или GUI).
2. В GUI включите нужные схемы (C4, BPMN, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`.
1. Откройте промпт (файл или GUI). При необходимости добавьте `emulate-student` после базового generate-*.
2. В GUI включите нужные схемы (C4, usecase, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`.
3. Скопируйте собранный текст в ChatGPT / Claude / Cursor / Copilot.
4. Следующим сообщением: тип работы, тема, черновик / требования.
5. Сохраните ответ как `.md` и конвертируйте:
@@ -24,7 +25,7 @@ python -m md2gost report.md -o report.docx --type coursework --check --strict
## Зачем кнопки схем
Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, Start/XOR/Flow для BPMN и т.д.) — в том числе пользовательских из «Шаблоны UML».
Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, actor/usecase и т.д.) — в том числе пользовательских из «Шаблоны UML».
## Подробнее о синтаксисе
+4 -1
View File
@@ -38,11 +38,13 @@ md2gost-gui # то же GUI (entry point)
1. Перетащите `.md` в верхнюю область (или кликните по ней).
2. Выберите тип документа (`practice` по умолчанию).
3. При необходимости: Настройки → Файлы (титул, задание), Настройки → Диаграммы, меню «Шаблоны UML».
3. При необходимости: Настройки → Файлы (титул, задание), **Настройки → Студент…** (ФИО и группа для автотитула), **Настройки → Метаданные…** (свойства DOCX), Настройки → Диаграммы, меню «Шаблоны UML».
4. Нажмите «Конвертировать».
Справка в меню: **Инструкция**, **Схемы**, **Промпт для ИИ**.
Автотитул (`info_conv.yaml` + блок `title`): [title.md](title.md).
## Типы документов (кратко)
| `--type` | Назначение |
@@ -57,5 +59,6 @@ md2gost-gui # то же GUI (entry point)
## Следующие шаги
- Написать отчёт в диалекте md2gost: [markdown.md](markdown.md)
- Автотитульник: [title.md](title.md)
- Вставить диаграммы: [schemes.md](schemes.md)
- Сгенерировать `.md` через ИИ: [prompts.md](prompts.md)
+158 -12
View File
@@ -1,6 +1,6 @@
# Схемы и диаграммы
В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki) или локальный Mermaid (браузер / QuickJS), иначе Kroki.
В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki), локальный Mermaid (браузер / QuickJS), локальный IDEF0 (Pillow) или DFD через [data-flow-diagram](https://github.com/pbauermeister/dfd) (вшитый / системный Graphviz или Kroki).
## Синтаксис в markdown
@@ -32,13 +32,12 @@ Rel(user, app, "логин")
| `` ```uml `` / `` ```plantuml `` | Сырой PlantUML (`@startuml`…`@enduml`) |
| `` ```uml-<id> `` / `` ```<id> `` | Схема из `md2gost.schemes.json` (обёртка и `!include` добавляются сами) |
| `` ```mermaid `` / `` ```mmd `` | Mermaid: браузер / QuickJS / Kroki |
| `` ```bpmn `` / `` ```uml-bpmn `` | BPMN 2.0 (макросы библиотеки md2gost) |
**IDEF0 / DFD** конвертер не рисует — вставляйте готовый PNG как обычный Рисунок.
| `` ```idef0 `` / `` ```uml-idef0 `` | IDEF0 (FIPS 183): локальный рендер, без PlantUML / Kroki |
| `` ```dfd `` / `` ```uml-dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): вшитый Graphviz / PATH / Kroki |
## Встроенные схемы
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением (существующий файл не перезаписывается).
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки.
| id | Название | Оградка |
|----|----------|---------|
@@ -46,9 +45,9 @@ Rel(user, app, "логин")
| `c4context` | C4 Context | `` ```uml-c4context `` |
| `c4component` | C4 Component | `` ```uml-c4component `` |
| `usecase` | Прецеденты | `` ```uml-usecase `` / `` ```usecase `` |
| `bpmn` | BPMN 2.0 | `` ```bpmn `` / `` ```uml-bpmn `` |
| `archimate` | ArchiMate 3.2 | `` ```uml-archimate `` / `` ```archimate `` |
Для схем в теле пишите **только макросы** (Person, Start, XOR, …) — без `@startuml` и без `!include`, если схема сама их добавляет.
Для схем в теле пишите **только макросы** (Person, Rel, actor, Business_Actor, …) — без `@startuml` и без `!include`, если схема сама их добавляет.
### C4 (пример макросов)
@@ -58,14 +57,151 @@ Rel(user, app, "логин")
`actor`, `usecase`, связи `-->`, `<<include>>`, `<<extend>>`.
### BPMN
### ArchiMate 3.2
`Pool` / `Lane`, `Start` / `StartMessage` / `End`, `UserTask` / `ServiceTask`, `XOR` / `AND` / `OR`, `Flow` / `CondFlow` / `DefaultFlow` / `MessageFlow`, …
Sequence Flow только внутри пула; между пулами — `MessageFlow`.
`Business_Actor`, `Application_Service`, `Technology_Node`, `Rel_Serving` / `Rel_Realization` / `Rel_Assignment`, …
Подробно: [archimate.md](archimate.md).
Полные шпаргалки — поля `docs` и `ai-prompt` в JSON; в GUI: **Шаблоны UML** и кнопки схем в **Справка → Промпт для ИИ**.
## IDEF0 (FIPS 183)
Оградка `` ```idef0 `` (то же: `` ```uml-idef0 ``). Текст — не XML: шапка формы, боксы, стрелки ICOM (Input слева, Control сверху, Output справа, Mechanism снизу). Контекст A-0 — один бокс; декомпозиция — несколько боксов «лесенкой» и связи между ними. Рендер локальный (Pillow), без Java и Kroki.
````markdown
%a0 Контекстная диаграмма A-0
```idef0
title Распорядиться товаром
node A-0
number 1
author Кинзябулатов Рамиль
project Разработка универсальной модели торгового предприятия
date 08.02.2022
rev 31.03.2022
status PUBLICATION
context TOP
[A0] Распорядиться товаром
<- Спрос
^ Нормативная документация
-> Товар
v Персонал
```
````
Шапка/подвал NIST-формы (необязательные поля): `title`, `node`, `number`, `author`, `project`, `date`, `rev`, `status` (`WORKING` / `DRAFT` / `RECOMMENDED` / `PUBLICATION`), `context` (для A-0 — `TOP`), `used_at`, `reader`, `reader_date`, `notes`, `purpose`, `viewpoint`, `page`, `form` (`kit` по умолчанию, `plain` — без рамки).
Бокс: `[A0] Имя` или `box A0 Имя`. Стрелки к последнему боксу: `<-` / `in` вход, `^` / `control` управление, `->` / `out` выход, `v` / `mech` механизм. Явно: `A0 <- Спрос`.
Декомпозиция A0 (3 бокса, развилка, O→C, ICOM на рамке):
````markdown
%a0 Декомпозиция A0
```idef0
title Распорядиться товаром
node A0
number 2
author Кинзябулатов Рамиль
project Разработка универсальной модели торгового предприятия
date 08.02.2022
rev 31.03.2022
status PUBLICATION
context A-0
purpose Обеспечить наличие товара и его отпуск покупателю
viewpoint Руководитель торгового предприятия
[A1] Принять и оценить спрос
[A2] Закупить и хранить
[A3] Отгрузить товар
A1 <- Спрос [I1]
A2 <- Предложения поставщиков [I2]
A1, A2, A3 ^ Нормативная документация [C1]
A1 ^ Ассортиментная политика [C2]
A3 ^ Условия поставки [C3]
A1 -> A2 : план закупок
A1 -> A2.C : график поставок
A2 -> A3 : товар к отгрузке
A3 -> Товар [O1]
A3 -> Акт отгрузки [O2]
A1, A2, A3 v Персонал [M1]
A2 v Склад [M2]
```
````
Подписи к стрелкам — существительные рядом со стволом, не на линии; если связь не очевидна, рисуется squiggle (FIPS 183). Длинные фразы переносятся на 2–3 строки.
Короткий каркас:
```
[A1] Принять заказ
[A2] Отгрузить
A1 <- Заявка [I1]
A1 ^ Правила [C1]
A1 -> A2 : заказ
A1 -> A2, A3 : данные
A1, A2 -> A3 : сводка
A2 -> Товар [O1]
A2 v Склад [M1]
```
Туннель (скобки IDEF0): `(in Секрет` — на границе листа, `in) Локальный` — у бокса. Вызов другой модели: `call ИмяМодели`. Сторона порта: `A1.O -> A2.C : условие`.
## DFD (data-flow-diagram / SA/RT)
Оградка `` ```dfd `` (то же: `` ```uml-dfd ``, `` ```data-flow-diagram ``, `` ```yourdon ``). Синтаксис — пакет [pbauermeister/dfd](https://github.com/pbauermeister/dfd) (ставится с md2gost, Python 3.11+). Рендер: Graphviz (`dot` / `neato`) или Kroki (`graphviz`), не Pillow.
Порядок поиска Graphviz (как у `plantuml.jar`):
1. Вшитый `md2gost/vendor/graphviz` (exe-сборка / `python scripts/fetch_graphviz.py`)
2. Кэш `%LOCALAPPDATA%\md2gost\graphviz` (GUI: **Настройки → Диаграммы → «Скачать Graphviz»**)
3. `dot` / `neato` в `PATH`
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
````markdown
%dfd0 Контекстная DFD
```dfd
style context
entity Client Клиент
entity Warehouse Склад
process System Система учёта заявок
Client --> System заявка
System --> Client статус
System --> Warehouse накладная
```
````
Ключевые слова узлов: `process`, `entity`, `store`, `control`, `channel`. Потоки: `-->`, `->>`, `<->`, сигналы `::>` / `<::`. Контекст: `style context`. Полный синтаксис — [документация Upstream](https://github.com/pbauermeister/dfd/blob/main/doc/README.md).
Декомпозиция (уровень 0):
````markdown
%dfd1 DFD уровня 0
```dfd
entity Client Клиент
entity Warehouse Склад
process Accept Принять заявку
process Register Зарегистрировать
process Ship Сформировать накладную
store Orders Заявки
Client --> Accept заявка
Accept --> Orders новая заявка
Orders --> Register данные заявки
Register --> Ship подтверждённая заявка
Ship --> Warehouse накладная
Ship --> Client уведомление
```
````
Зависимости: `data-flow-diagram` идёт с md2gost (Python ≥3.11). Без Graphviz офлайн DFD не нарисуется — либо кнопка «Скачать Graphviz» / `scripts/fetch_graphviz.py`, либо `--diagram-fallback remote` → kroki.io.
## Файл схем: поля
```json
@@ -114,7 +250,15 @@ id схемы: латиница, цифры, `_`; начинается с бук
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
4. Локальный Kroki → `https://kroki.io` (если `--diagram-fallback remote`)
Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб растра PlantUML / локального Mermaid: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1).
**IDEF0:** сразу Pillow (PNG, при `--diagram-format svg` ещё SVG). Java/Kroki не используются.
**DFD:**
1. [data-flow-diagram](https://github.com/pbauermeister/dfd) компилирует DSL → DOT
2. Вшитый / кэш / PATH Graphviz (`dot`, для `style context``neato`)
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб растра PlantUML / локального Mermaid / IDEF0: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1).
Кэш картинок: `{каталог_md}/.md2gost-cache/`.
Кэш includes: `md2gost.include-cache.json` + папка `include-cache/` (сброс в GUI: Настройки → Диаграммы / Шаблоны UML).
@@ -124,3 +268,5 @@ id схемы: латиница, цифры, `_`; начинается с бук
1. Меню **Шаблоны UML** — добавить / править / сохранить.
2. Или править `md2gost.schemes.json` вручную («Открыть JSON»).
3. CLI: `--schemes путь.json`.
Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы).
+187
View File
@@ -0,0 +1,187 @@
# Автотитульник
md2gost умеет **сам собрать титульный лист** из вшитого шаблона `TitleTemplate.docx`, подставив институт, кафедру, дисциплину, преподавателя, номер работы, ФИО и группу.
Готовый чужой DOCX титула (`--title` / «Настройки → Файлы → Титульный лист») по-прежнему работает и **полностью отключает** автогенерацию.
---
## Когда титул генерируется
Автотитул **по умолчанию выключен**. Включается явно:
- CLI: `--auto-title`
- GUI: галочка **«Автотитул»**
При включённом автотитуле он собирается, если одновременно:
1. **Не задан** явный путь к титульному DOCX (`--title` пуст, в GUI поле «Титульный лист» пустое).
2. Рядом с `.md` есть файл **`info_conv.yaml`** *или* в начале Markdown есть блок **\`\`\`title**.
Если автотитул выключен или нет ни yaml, ни блока `title` — титул не вставляется (готовый DOCX по `--title` по-прежнему можно передать отдельно).
ФИО и группа **обязательны** для генерации: из профиля GUI / `md2gost.user.json` или из `--student` / `--group`. Без них конвертация с `--auto-title` завершится ошибкой с подсказкой.
---
## Быстрый сценарий (GUI)
1. Первый запуск → окно **«Данные студента»**: введите **ФИО** и **группу**, сохраните.
Позже: **Настройки → Студент…**.
2. В папке с отчётом создайте `info_conv.yaml` (см. ниже).
3. В начале `.md` при необходимости добавьте блок \`\`\`title с номером работы и годом.
4. Включите галочку **«Автотитул»** на главном окне.
5. **Не** указывайте готовый титул в «Настройки → Файлы», если хотите автогенерацию.
6. Конвертируйте как обычно — перед телом отчёта появится титул.
Профиль студента хранится в `md2gost.user.json` рядом с приложением (рядом с exe или в текущей папке при `python -m md2gost`) — тот же каталог, что и `md2gost.schemes.json`. В том же файле блок `metadata` — свойства DOCX (автор/название/примечание); GUI: **Настройки → Метаданные…**, CLI: `--doc-*`. Это не поля титульного листа.
---
## `info_conv.yaml` (данные курса / кафедры)
Файл кладётся **в тот же каталог**, что и конвертируемый `.md`. Имя строго: `info_conv.yaml`.
Пример:
```yaml
Институт: Институт информационных технологий (ИИТ)
Кафедра: Кафедра цифровой трансформации (ЦТ)
Дисциплина: Проектирование архитектуры цифровой организации
Преподаватель: Иванов Иван Иванович
Должность: старший преподаватель, кафедра ЦТ
```
Формат — плоский список `ключ: значение` (UTF-8). Вложенный YAML и списки не нужны. Строки с `#` в начале — комментарии; хвост ` # …` в значении отрезается.
Один `info_conv.yaml` удобно держать на весь семестр/дисциплину и не копировать институт с кафедрой в каждый отчёт.
---
## Блок `title` в Markdown
Опциональный блок в **начале** файла (до `# *СОДЕРЖАНИЕ`), ограда языка `title`. Перед конвертацией он **вырезается** из тела отчёта и не попадает в DOCX как код.
Типичное использование — номер работы и год:
````markdown
```title
number: 1
year: 2026
```
# *СОДЕРЖАНИЕ
[TOC]
````
Можно переопределить любые академические поля прямо в блоке (они сильнее yaml):
````markdown
```title
number: 3
year: 2026
discipline: Другая формулировка дисциплины
teacher: Петров П. П.
```
````
Английские ключи тоже принимаются: `institute`, `department`, `discipline`, `teacher`, `teacher_position`, `number`, `year`.
---
## Поля: куда попадают и откуда берутся
| Поле на титуле | Ключи (любой из вариантов) | Источник |
|----------------|----------------------------|----------|
| Институт | `Институт`, `Institute`, `institute`; опечатка `Инститиут` | yaml / \`\`\`title |
| Кафедра | `Кафедра`, `Department`, `department` | yaml / \`\`\`title |
| Дисциплина (в «…») | `Дисциплина`, `Discipline`, `discipline` | yaml / \`\`\`title |
| № практической работы | `number`, `номер`, `№`, `n` | \`\`\`title (иначе `1`) |
| Группа («студент группы …») | — | профиль GUI / `--group` |
| ФИО студента | — | профиль GUI / `--student` |
| Должность преподавателя | `Должность`, `должность преподавателя`, `teacher_position` | yaml / \`\`\`title |
| ФИО преподавателя | `Преподаватель`, `Преподователь`, `Teacher`, `teacher` | yaml / \`\`\`title |
| Год («Москва … г.») | `year`, `год` | \`\`\`title (иначе текущий календарный год) |
Строка «ОТЧЁТ ПО ПРАКТИЧЕСКОЙ РАБОТЕ» и шапка МИРЭА в шаблоне **не** заполняются из переменных — это фиксированный текст шаблона.
ФИО/`группа` из yaml или \`\`\`title **игнорируются** (даже если написать `Студент:` / `Группа:`) — студент всегда из профиля или CLI.
---
## Приоритет слияния
```
Академические поля: info_conv.yaml < ```title (блок сильнее)
Студент / группа: md2gost.user.json < --student / --group
Явный титул DOCX: --title / путь в GUI → генератор не вызывается
```
`year` без значения → текущий год. `number` без значения → `1`.
---
## CLI
```bash
# автотитул выкл. по умолчанию — только тело отчёта
python -m md2gost report.md -o report.docx
# включить автотитул (yaml + профиль из md2gost.user.json)
python -m md2gost report.md -o report.docx --auto-title
# ФИО и группа на один прогон
python -m md2gost report.md --auto-title --student "Тарасов Игорь Алексеевич" --group ИНБО-31-23
# свой готовый титул — автогенерация не используется
python -m md2gost report.md --title title.docx
```
---
## Пример структуры папки
```
Отчет_практики/
info_conv.yaml ← институт, кафедра, дисциплина, препод
report_pr1.md ← в начале ```title с number/year
figures/
```
`report_pr1.md`:
````markdown
```title
number: 1
year: 2026
```
# *СОДЕРЖАНИЕ
[TOC]
# *ВВЕДЕНИЕ
````
---
## Частые вопросы
**Титул не появился.**
Проверьте: включён ли `--auto-title` / галочка «Автотитул»; нет ли пути в «Титульный лист» / `--title`; есть ли `info_conv.yaml` или \`\`\`title; заполнены ли ФИО и группа.
**Не тот номер работы.**
Задайте `number:` в \`\`\`title — из yaml номер не читается (только из блока или дефолт `1`).
**Нужен другой шаблон вёрстки.**
Сейчас слоты привязаны к bundled `TitleTemplate.docx`. Чужой макет — собирайте титул вручную и передавайте через `--title`.
**Задание (бланк).**
Автотитул не заменяет бланк задания: по-прежнему `--assignment` / «Бланк задания» в GUI.
**Где лежит профиль.**
`md2gost.user.json` рядом с приложением (см. каталог схем). Не коммитьте его с чужими ФИО в общие репозитории отчётов — это локальные данные студента.
+46
View File
@@ -0,0 +1,46 @@
# Импорт Word → Markdown (word2md)
Обратный пайплайн: **DOCX → Markdown** в диалекте md2gost. Удобно, если отчёт уже набран в Word (или собран md2gost раньше), а дальше править удобнее в `.md`.
Это **эвристический** импорт, не lossless round-trip: исходник UML/Mermaid, `%id` из исходного md и язык fence у листинга из DOCX не восстанавливаются.
## Запуск
```bash
python -m word2md report.docx -o report.md
word2md report.docx
python -m md2gost report.docx -o report.md # тот же импорт через md2gost
```
В GUI: перетащите `.docx` или кнопка **«Импорт DOCX→MD»**.
Картинки пишутся в каталог `<stem>_media/` рядом с `.md` (или `--media-dir`).
| Флаг | Смысл |
|------|--------|
| `-o` / `--output` | путь к `.md` |
| `--media-dir` | каталог картинок |
| `--keep-toc-pages` | не выкидывать строки содержания Word (по умолчанию только `[TOC]`) |
| `--pagebreaks` | писать `---` на разрывах страниц |
## Что распознаётся
- заголовки `Heading N` / спецразделы → `# *ВВЕДЕНИЕ`, `# 1 …`
- содержание → `# *СОДЕРЖАНИЕ` + `[TOC]` (поле TOC и строки `toc N` отбрасываются)
- подписи `Рисунок N — …`, `Таблица N`, `Листинг N` (+ «Продолжение…» склеивается)
- таблицы с merge → `^` / `>`
- листинги стиля `Code`
- формулы (таблица Formula / OMML) → `$$…$$`
- библиография `[1]: …`, ссылки `[1]` в тексте
- второй проход: `Рисунок 1.1``@Рисунок:fig1_1` (если номер известен по подписи)
Титул и бланк задания до первого H1/спецраздела пропускаются. Колонтитулы не импортируются.
## Ограничения
- Нет восстановления PlantUML / Mermaid (только PNG/JPEG из файла)
- OMML → формула — best-effort, не идеальный LaTeX
- Надписи, SmartArt, сноски, плавающие рисунки — пропуск с записью в лог
- Титул, свёрстанный большой таблицей, может попасть в поток, если walker уже «стартовал» — обычно титул до первого Heading пропускается
После импорта имеет смысл прогнать `python -m md2gost report.md --check`.
+27 -6
View File
@@ -1,11 +1,13 @@
# -*- mode: python ; coding: utf-8 -*-
"""Faster onefile freeze: same portable exe (fonts + plantuml.jar + onefile).
"""Faster onefile freeze: fonts + plantuml.jar + optional Graphviz + onefile.
Keeps matplotlib (system font lookup) and bundles plantuml.jar.
Graphviz (vendor/) is included when present for offline DFD.
Speeds Analysis by pinning the Agg backend, skipping duplicate pygments
collection (the stock hook already pulls all lexers), and not probing Qt/Gtk.
"""
import os
import sys
from pathlib import Path
from PyInstaller.utils.hooks import (
@@ -18,9 +20,14 @@ from PyInstaller.utils.hooks import (
os.environ["MPLBACKEND"] = "Agg"
ROOT = Path(SPECPATH).resolve()
sys.path.insert(0, str(ROOT / "scripts"))
from pyi_bundle import collect_tkinterdnd2, prepare_tcltk_env, sanitize_analysis # noqa: E402
prepare_tcltk_env()
datas = [
(str(ROOT / "md2gost" / "Template.docx"), "md2gost"),
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
(str(ROOT / "prompts"), "prompts"),
@@ -33,9 +40,20 @@ if not _plantuml.is_file():
"сначала scripts/fetch_plantuml.py (jar обязателен в portable exe)"
)
datas.append((str(_plantuml), "md2gost/vendor"))
_graphviz = ROOT / "md2gost" / "vendor" / "graphviz"
if (_graphviz / "bin" / "dot.exe").is_file() or (_graphviz / "bin" / "dot").is_file():
from md2gost.dfd import strip_graphviz_tcl_libs
strip_graphviz_tcl_libs(_graphviz)
datas.append((str(_graphviz), "md2gost/vendor/graphviz"))
binaries = []
hiddenimports = collect_submodules("md2gost")
hiddenimports += collect_submodules("word2md")
try:
hiddenimports += collect_submodules("data_flow_diagram")
except Exception:
pass
datas += collect_data_files("certifi")
datas += collect_data_files("lxml")
@@ -95,10 +113,9 @@ hiddenimports += [
]
# Do NOT collect_submodules("pygments.lexers") here — hook-pygments.py already
# collects all lexers/formatters/styles once during Analysis.
try:
hiddenimports += collect_submodules("tkinterdnd2")
except Exception:
pass
_dnd_datas, _dnd_hidden = collect_tkinterdnd2()
datas += _dnd_datas
hiddenimports += _dnd_hidden
excludes = [
"pytest",
@@ -150,10 +167,14 @@ a = Analysis(
hooksconfig={
"matplotlib": {"backends": "Agg"},
},
runtime_hooks=[str(ROOT / "scripts" / "pyi_rth_mplbackend.py")],
runtime_hooks=[
str(ROOT / "scripts" / "pyi_rth_tcltk.py"),
str(ROOT / "scripts" / "pyi_rth_mplbackend.py"),
],
excludes=excludes,
noarchive=False,
)
sanitize_analysis(a)
pyz = PYZ(a.pure)
+22 -5
View File
@@ -1,4 +1,5 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
from pathlib import Path
from PyInstaller.utils.hooks import (
@@ -8,9 +9,14 @@ from PyInstaller.utils.hooks import (
)
ROOT = Path(SPECPATH).resolve()
sys.path.insert(0, str(ROOT / "scripts"))
from pyi_bundle import collect_tkinterdnd2, prepare_tcltk_env, sanitize_analysis # noqa: E402
prepare_tcltk_env()
datas = [
(str(ROOT / "md2gost" / "Template.docx"), "md2gost"),
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
(str(ROOT / "prompts"), "prompts"),
@@ -19,8 +25,19 @@ datas = [
_plantuml = ROOT / "md2gost" / "vendor" / "plantuml.jar"
if _plantuml.is_file():
datas.append((str(_plantuml), "md2gost/vendor"))
_graphviz = ROOT / "md2gost" / "vendor" / "graphviz"
if (_graphviz / "bin" / "dot.exe").is_file() or (_graphviz / "bin" / "dot").is_file():
from md2gost.dfd import strip_graphviz_tcl_libs
strip_graphviz_tcl_libs(_graphviz)
datas.append((str(_graphviz), "md2gost/vendor/graphviz"))
binaries = []
hiddenimports = collect_submodules("md2gost")
hiddenimports += collect_submodules("word2md")
try:
hiddenimports += collect_submodules("data_flow_diagram")
except Exception:
pass
datas += collect_data_files("certifi")
datas += collect_data_files("lxml")
@@ -78,10 +95,9 @@ hiddenimports += [
]
hiddenimports += collect_submodules("pygments.lexers")
hiddenimports += collect_submodules("marko")
try:
hiddenimports += collect_submodules("tkinterdnd2")
except Exception:
pass
_dnd_datas, _dnd_hidden = collect_tkinterdnd2()
datas += _dnd_datas
hiddenimports += _dnd_hidden
excludes = [
"pytest",
@@ -111,10 +127,11 @@ a = Analysis(
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=[str(ROOT / "scripts" / "pyi_rth_tcltk.py")],
excludes=excludes,
noarchive=False,
)
sanitize_analysis(a)
pyz = PYZ(a.pure)
+14
View File
@@ -0,0 +1,14 @@
{
"student": "Тарасов Игорь Алексеевич",
"group": "ИНБО-31-23",
"metadata": {
"author_source": "student",
"author": "",
"last_modified_by": "",
"title": "",
"subject": "",
"keywords": "",
"comments": "Создано при помощи md2gost (ТЗ МИРЭА)",
"category": ""
}
}
+10 -30
View File
@@ -102,7 +102,7 @@ python -m md2gost report.md -o report.docx --toc manual
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
| Листинг | `%id Подпись` перед code fence |
| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет |
| Диаграмма UML / Mermaid / IDEF0 / DFD / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````mermaid` / ````idef0` / ````dfd` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json` |
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
| Источник | `[1]` в тексте (кликабельная ссылка на пункт списка); `[1]: …` в списке |
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
@@ -136,7 +136,7 @@ Word COM режет по реальной пагинации. Без Word — у
| Режим | Поведение |
|--------|-----------|
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`) |
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
@@ -208,34 +208,7 @@ Rel(user, app, "логин")
```
````
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **BPMN 2.0** — оградка ````bpmn` / ````uml-bpmn` (макросы `Start`, `UserTask`, `XOR`, `Flow`, `Pool`…; библиотека `diagrams/BPMN.puml`). **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** конвертер не рисует — вставляйте готовый PNG.
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw, "ок?")
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
````markdown
%bpmn1 Процесс заявки
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw, "ок?")
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
````
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** — ````idef0`: локальный Pillow (рамка NIST, стрелки ICOM). **DFD** — ````dfd`: [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/системный Graphviz или Kroki (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz»).
Обычному пользователю jar/Kroki указывать не нужно. Порядок для UML:
@@ -250,6 +223,12 @@ Mermaid:
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
DFD (````dfd`):
1. Пакет `data-flow-diagram` (зависимость md2gost) → DOT
2. Graphviz: вшитый `md2gost/vendor/graphviz` (сборка exe / `scripts/fetch_graphviz.py`) → `%LOCALAPPDATA%\md2gost\graphviz` (GUI «Скачать Graphviz») → `PATH`
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML и локальный Mermaid с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
@@ -260,6 +239,7 @@ Mermaid:
```bash
python scripts/fetch_plantuml.py
python scripts/fetch_graphviz.py
python scripts/fetch_mermaid.py
python -m md2gost --install-chromium
python -m md2gost report.md -o report.docx --diagram-fallback local
Binary file not shown.
+82 -2
View File
@@ -2,6 +2,7 @@
from argparse import ArgumentParser, BooleanOptionalAction
import sys
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
from .pipeline import ConvertRequest, convert, should_launch_gui
from .profiles import (
DEFAULT_HEADING_NUMBERING,
@@ -23,12 +24,13 @@ def build_parser() -> ArgumentParser:
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
"Без файла или с --gui открывается окно. "
"DOCX → Markdown: python -m word2md файл.docx (или md2gost файл.docx). "
"FODT: python -m md2fodt …"
),
)
parser.add_argument(
"filename", nargs="?",
help="Путь до .md (или .docx с --check-pages). Без файла открывается GUI",
help="Путь до .md (сборка DOCX), .docx (импорт в Markdown) или .docx с --check-pages",
)
parser.add_argument(
"--gui", action="store_true",
@@ -116,8 +118,75 @@ def build_parser() -> ArgumentParser:
"По умолчанию такие строки игнорируются; включить: --hr-pagebreak."
),
)
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)")
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом; "
"отключает автогенерацию)")
parser.add_argument(
"--auto-title",
action=BooleanOptionalAction,
default=False,
help=(
"Сгенерировать титул из info_conv.yaml / блока ```title (нужны ФИО и группа). "
"По умолчанию выкл.; включить: --auto-title. Явный --title отключает генератор."
),
)
parser.add_argument("--assignment", help="DOCX бланка задания")
parser.add_argument(
"--student",
help="ФИО студента для автотитула (иначе md2gost.user.json)",
)
parser.add_argument(
"--group",
help="Учебная группа для автотитула (иначе md2gost.user.json)",
)
parser.add_argument(
"--doc-author-from",
choices=AUTHOR_SOURCES,
default=None,
help=(
"Автор в свойствах DOCX: os — имя пользователя ОС; "
"student — ФИО из профиля; custom — --doc-author / md2gost.user.json. "
"Иначе берётся из Настройки → Метаданные (по умолчанию os)."
),
)
parser.add_argument(
"--doc-author",
default=None,
help="Автор в свойствах DOCX (перебивает --doc-author-from)",
)
parser.add_argument(
"--doc-last-modified-by",
default=None,
help="Поле «Кем изменён» в свойствах DOCX; пусто — как автор",
)
parser.add_argument(
"--doc-title",
default=None,
help="Название в свойствах DOCX (File → Info)",
)
parser.add_argument(
"--doc-subject",
default=None,
help="Тема в свойствах DOCX",
)
parser.add_argument(
"--doc-keywords",
default=None,
help="Ключевые слова (теги) в свойствах DOCX",
)
parser.add_argument(
"--doc-comments",
default=None,
help=(
"Примечание в свойствах DOCX. "
f"По умолчанию: «{DEFAULT_DOC_COMMENTS}». "
"Пустая строка — не писать примечание."
),
)
parser.add_argument(
"--doc-category",
default=None,
help="Категория в свойствах DOCX",
)
parser.add_argument("--check", help="Проверить markdown по ТЗ и вывести отчёт",
action="store_true")
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
@@ -215,6 +284,17 @@ def request_from_args(args) -> ConvertRequest:
hr_pagebreak=args.hr_pagebreak,
title=args.title,
assignment=args.assignment,
student=getattr(args, "student", None),
group=getattr(args, "group", None),
doc_author_source=getattr(args, "doc_author_from", None),
doc_author=getattr(args, "doc_author", None),
doc_last_modified_by=getattr(args, "doc_last_modified_by", None),
doc_title=getattr(args, "doc_title", None),
doc_subject=getattr(args, "doc_subject", None),
doc_keywords=getattr(args, "doc_keywords", None),
doc_comments=getattr(args, "doc_comments", None),
doc_category=getattr(args, "doc_category", None),
auto_title=bool(getattr(args, "auto_title", False)),
check=args.check,
check_only=args.check_only,
check_pages=bool(getattr(args, "check_pages", False)),
+17 -2
View File
@@ -39,6 +39,21 @@ def entries_from_text(text: str) -> list[BiblioEntry]:
return entries
# After citation linking, `[5]: …` often becomes `[]: …` in paragraph.text
# (the digits live in a hyperlink). Still a biblio source line to drop.
BIBLIO_RESIDUE_RE = re.compile(r"^\[\d*(?:\.\d+)?\]:\s*\S")
def is_biblio_source_paragraph(text: str) -> bool:
"""True if paragraph is a [n]: / []: bibliography source line (or empty)."""
stripped = (text or "").strip()
if not stripped:
return True
if entries_from_text(stripped):
return True
return bool(BIBLIO_RESIDUE_RE.match(stripped))
def extract_bibliography_from_markdown(md: str) -> list[BiblioEntry] | list[tuple[str, list[BiblioEntry]]]:
"""
Parse bibliography from raw markdown (reliable even if Marko merges lines).
@@ -126,8 +141,8 @@ def fold_bibliography(renderables: list[Renderable], parent,
consumed += 1
continue
if isinstance(item, Paragraph):
text = (item._docx_paragraph.text or "").strip()
if entries_from_text(text) or (not text):
text = item._docx_paragraph.text or ""
if is_biblio_source_paragraph(text):
consumed += 1
continue
break
+8 -3
View File
@@ -46,7 +46,8 @@ class Converter:
table_continuation: str = DEFAULT_TABLE_CONTINUATION,
listing_continuation: str = DEFAULT_LISTING_CONTINUATION,
hr_pagebreak: bool = False,
styles_path: str | None = None):
styles_path: str | None = None,
markdown_text: str | None = None):
if heading_numbering not in HEADING_NUMBERING_MODES:
raise ValueError(
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
@@ -79,6 +80,7 @@ class Converter:
self._document: Document = docx.Document(template_path)
self._document._body.clear_content()
md_dir = os.path.dirname(os.path.abspath(input_path)) or "."
os.environ["WORKING_DIR"] = md_dir
self._style_config = apply_document_styles(
self._document,
self._profile.style_preset,
@@ -86,8 +88,11 @@ class Converter:
styles_path=styles_path,
)
self._debugger = Debugger(self._document) if debug else None
with open(input_path, encoding="utf-8") as f:
raw = f.read()
if markdown_text is not None:
raw = markdown_text
else:
with open(input_path, encoding="utf-8") as f:
raw = f.read()
self._raw_markdown = raw
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
self.parser = Parser(self._document, self._preprocessed, hr_pagebreak=hr_pagebreak)
+477
View File
@@ -0,0 +1,477 @@
"""DFD via pbauermeister/data-flow-diagram (Graphviz).
Fence: ```dfd (aliases ```uml-dfd, ```data-flow-diagram, ```yourdon, …).
DSL: https://github.com/pbauermeister/dfd
Pipeline:
1. Compile DSL → Graphviz DOT (`data-flow-diagram` Python API).
2. Rasterize: bundled/system Graphviz (`dot`/`neato`), else Kroki `graphviz`.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import requests
from . import package_dir
_log = logging.getLogger(__name__)
CACHE_KEY_PREFIX = "dfd-v3-pbauermeister\n"
# Context diagrams use neato; everything else uses dot (matches upstream templates).
_ENGINE_CONTEXT = "neato"
_ENGINE_DEFAULT = "dot"
# Official Windows portable build (same pattern as plantuml.jar vendor).
GRAPHVIZ_VERSION = "16.1.0"
GRAPHVIZ_ZIP_URL = (
"https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/"
f"{GRAPHVIZ_VERSION}/windows_10_cmake_Release_Graphviz-{GRAPHVIZ_VERSION}-win64.zip"
)
# Portable Graphviz ships Tcl 8.6.10 as tcl86t.dll. CPython 3.11's tkinter needs
# exactly 8.6.12 — if both land in the onefile extract, GUI dies on Tk().
# `dot` does not need the Tcl bindings.
GRAPHVIZ_TCL_DLL_NAMES = frozenset(
{
"tcl86t.dll",
"tk86t.dll",
"tcl86.dll",
"tk86.dll",
"tcldot.dll",
"tcldot_builtin.dll",
"tclplan.dll",
"gdtclft.dll",
}
)
class DfdParseError(ValueError):
"""Invalid DFD DSL or render tooling failure surfaced as parse/render error."""
SAMPLE_CONTEXT = """\
style context
entity Client Клиент
entity Warehouse Склад
process System Система учёта заявок
Client --> System заявка
System --> Client статус
System --> Warehouse накладная
"""
SAMPLE_LEVEL0 = """\
entity Client Клиент
entity Warehouse Склад
process Accept Принять заявку
process Register Зарегистрировать
process Ship Сформировать накладную
store Orders Заявки
Client --> Accept заявка
Accept --> Orders новая заявка
Orders --> Register данные заявки
Register --> Ship подтверждённая заявка
Ship --> Warehouse накладная
Ship --> Client уведомление
"""
def vendor_graphviz_root() -> Path:
return Path(package_dir()) / "vendor" / "graphviz"
def cached_graphviz_root() -> Path:
if os.name == "nt":
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
else:
root = Path.home() / ".md2gost"
return root / "graphviz"
def _graphviz_bin_candidates() -> list[Path]:
roots: list[Path] = [vendor_graphviz_root()]
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
roots.append(Path(mei) / "md2gost" / "vendor" / "graphviz")
roots.append(Path(mei) / "vendor" / "graphviz")
roots.append(Path(sys.executable).resolve().parent / "graphviz")
roots.append(cached_graphviz_root())
bins: list[Path] = []
for root in roots:
bins.append(root / "bin")
bins.append(root)
return bins
def _engine_in_bin(bin_dir: Path, context: bool) -> Path | None:
if not bin_dir.is_dir():
return None
names = (
(_ENGINE_CONTEXT, _ENGINE_DEFAULT)
if context
else (_ENGINE_DEFAULT, _ENGINE_CONTEXT)
)
for name in names:
for cand in (bin_dir / f"{name}.exe", bin_dir / name):
if cand.is_file():
return cand
return None
def resolve_graphviz_engine(context: bool = False) -> str | None:
"""Path to Graphviz layout engine: vendor → cache → PATH."""
for bin_dir in _graphviz_bin_candidates():
hit = _engine_in_bin(bin_dir, context)
if hit is not None:
return str(hit)
if context:
return _which_many(
_ENGINE_CONTEXT, f"{_ENGINE_CONTEXT}.exe", _ENGINE_DEFAULT, "dot.exe",
)
return _which_many(_ENGINE_DEFAULT, "dot.exe", _ENGINE_CONTEXT, "neato.exe")
def _which_many(*names: str) -> str | None:
for name in names:
hit = shutil.which(name)
if hit:
return hit
return None
def _normalize_extracted_graphviz(extract_dir: Path, dest_root: Path) -> bool:
"""Find bin/dot(.exe) under extract_dir and copy tree into dest_root."""
dot = None
for path in extract_dir.rglob("dot.exe"):
if path.is_file():
dot = path
break
if dot is None:
for path in extract_dir.rglob("dot"):
if path.is_file() and os.access(path, os.X_OK):
dot = path
break
if dot is None:
return False
src_bin = dot.parent
src_root = src_bin.parent if src_bin.name.lower() == "bin" else src_bin
if dest_root.exists():
shutil.rmtree(dest_root)
dest_root.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_root, dest_root)
strip_graphviz_tcl_libs(dest_root)
return (
_engine_in_bin(dest_root / "bin", False) is not None
or _engine_in_bin(dest_root, False) is not None
)
def strip_graphviz_tcl_libs(root: Path) -> int:
"""Remove Graphviz Tcl/Tk DLLs so they cannot shadow CPython's tkinter."""
root = Path(root)
if not root.is_dir():
return 0
removed = 0
bins = [root / "bin", root]
seen: set[Path] = set()
for bin_dir in bins:
if not bin_dir.is_dir():
continue
resolved = bin_dir.resolve()
if resolved in seen:
continue
seen.add(resolved)
for name in GRAPHVIZ_TCL_DLL_NAMES:
path = bin_dir / name
if path.is_file():
path.unlink()
removed += 1
tcl_mod = root / "lib" / "graphviz" / "tcl"
if tcl_mod.is_dir():
shutil.rmtree(tcl_mod)
removed += 1
return removed
def fetch_graphviz(dest_root: Path | None = None, url: str = GRAPHVIZ_ZIP_URL) -> bool:
"""Download official Graphviz win64 ZIP into dest_root (default: vendor)."""
dest = Path(dest_root) if dest_root is not None else vendor_graphviz_root()
if _engine_in_bin(dest / "bin", False) or _engine_in_bin(dest, False):
strip_graphviz_tcl_libs(dest)
return True
try:
_log.info("Downloading Graphviz %s", GRAPHVIZ_VERSION)
with requests.get(url, stream=True, timeout=180) as resp:
resp.raise_for_status()
with tempfile.TemporaryDirectory(prefix="md2gost-gv-") as tmp:
zpath = Path(tmp) / "graphviz.zip"
with zpath.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=1024 * 256):
if chunk:
fh.write(chunk)
extract_dir = Path(tmp) / "out"
extract_dir.mkdir()
with zipfile.ZipFile(zpath) as zf:
zf.extractall(extract_dir)
if not _normalize_extracted_graphviz(extract_dir, dest):
_log.warning("Graphviz ZIP распакован, но dot.exe не найден")
return False
return True
except Exception as exc:
_log.warning("Не удалось скачать Graphviz: %s", exc)
return False
def resolve_dfd_cli() -> list[str] | None:
"""Argv prefix that compiles DSL → DOT without needing Graphviz for compile."""
py = _which_many("py", "py.exe")
if py:
for ver in ("-3.14", "-3.13", "-3.12", "-3.11"):
try:
probe = subprocess.run(
[py, ver, "-c", "import data_flow_diagram"],
capture_output=True,
timeout=20,
)
if probe.returncode == 0:
return [py, ver, "-c", _API_DOT_RUNNER]
except (OSError, subprocess.TimeoutExpired):
continue
exe = _which_many("data-flow-diagram", "data-flow-diagram.exe")
if exe:
return [exe]
return None
_API_DOT_RUNNER = r"""
import sys
from data_flow_diagram.dfd import build
from data_flow_diagram import model
src_path = sys.argv[1]
text = open(src_path, encoding="utf-8").read()
root = model.SourceLine("", "<md2gost>", None, 0)
opts = model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = build(root, text, "", opts)
sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
sys.stdout.write(dot)
sys.stderr.write("\n__MD2GOST_DFD_CONTEXT__=%s\n" % int(bool(graph_options.is_context)))
"""
def _is_context_diagram(source: str) -> bool:
return bool(re.search(r"(?im)^\s*style\s+context\b", source))
def _compile_dot(source: str) -> tuple[str, bool]:
"""Return (dot_text, is_context). Raises DfdParseError."""
text = (source or "").strip()
if not text:
raise DfdParseError("пустой DFD")
try:
from data_flow_diagram.dfd import build as dfd_build
from data_flow_diagram import model as dfd_model
root = dfd_model.SourceLine("", "<md2gost>", None, 0)
opts = dfd_model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = dfd_build(root, text, "", opts)
return dot, bool(graph_options.is_context)
except ImportError:
pass
except Exception as exc:
raise DfdParseError(str(exc)) from exc
cli = resolve_dfd_cli()
if not cli:
raise DfdParseError(
"Пакет data-flow-diagram не найден. "
"Установите зависимости проекта (Python 3.11+) — "
"https://github.com/pbauermeister/dfd"
)
with tempfile.TemporaryDirectory(prefix="md2gost-dfd-") as tmp:
src_path = Path(tmp) / "diagram.dfd"
src_path.write_text(text + "\n", encoding="utf-8")
if cli[-1] == _API_DOT_RUNNER or (len(cli) >= 2 and cli[-2] == "-c"):
cmd = list(cli) + [str(src_path)]
elif len(cli) == 1:
cmd = cli + [str(src_path), "-f", "dot", "-o", "-", "--no-graph-title"]
else:
cmd = list(cli) + [str(src_path)]
try:
proc = subprocess.run(
cmd,
capture_output=True,
timeout=120,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError as exc:
raise DfdParseError(f"Не удалось запустить data-flow-diagram: {exc}") from exc
except subprocess.TimeoutExpired as exc:
raise DfdParseError("Таймаут data-flow-diagram") from exc
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip() or f"код {proc.returncode}"
raise DfdParseError(f"DFD: {err}")
dot = proc.stdout or ""
if not dot.strip():
raise DfdParseError("data-flow-diagram вернул пустой DOT")
is_ctx = _is_context_diagram(text)
m = re.search(r"__MD2GOST_DFD_CONTEXT__=(\d+)", proc.stderr or "")
if m:
is_ctx = bool(int(m.group(1)))
return dot, is_ctx
def _graphviz_run_env(engine: str) -> dict[str, str]:
env = os.environ.copy()
bin_dir = str(Path(engine).resolve().parent)
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
env["GVBINDIR"] = bin_dir
return env
def _readable_dot(dot: str) -> str:
"""Make Graphviz DFD easier to read in printed Word reports."""
text = dot
text = re.sub(
r'edge\[color=gray fontname="times-italic" fontsize=\d+\]',
'edge[color=black fontname="helvetica" fontsize=13]',
text,
)
text = re.sub(
r'node\[fontname="helvetica" fontsize=\d+\]',
'node[fontname="helvetica" fontsize=13]',
text,
)
if "dpi=" not in text:
text = text.replace("digraph D {", 'digraph D {\n graph[dpi=180 nodesep=0.55 ranksep=0.7]', 1)
return text
def _render_dot_local(dot: str, out_path: Path, fmt: str, *, context: bool) -> bool:
engine = resolve_graphviz_engine(context=context)
if not engine:
return False
try:
subprocess.run(
[engine, f"-T{fmt}", f"-o{out_path}"],
input=_readable_dot(dot),
encoding="utf-8",
check=True,
capture_output=True,
timeout=120,
env=_graphviz_run_env(engine),
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
_log.warning("Graphviz %s failed: %s", engine, exc)
return False
return out_path.is_file() and out_path.stat().st_size > 0
def _render_dot_kroki(dot: str, out_path: Path, fmt: str) -> bool:
try:
from .diagram_renderer import (
DEFAULT_KROKI_URL,
REMOTE_KROKI_URL,
_CONFIG,
_render_kroki,
)
except Exception:
return False
local = (_CONFIG.kroki_url or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL).rstrip("/")
if _render_kroki(dot, "graphviz", local, out_path, fmt):
return True
if _CONFIG.fallback == "remote":
return _render_kroki(dot, "graphviz", REMOTE_KROKI_URL, out_path, fmt)
return False
def dfd_engine_status() -> str:
cli_ok = False
try:
import data_flow_diagram # noqa: F401
cli_ok = True
except ImportError:
cli_ok = resolve_dfd_cli() is not None
gv = resolve_graphviz_engine(False)
parts = ["DFD (pbauermeister/data-flow-diagram), оградка ```dfd"]
parts.append(
"компилятор: data-flow-diagram OK"
if cli_ok
else "компилятор: не найден (зависимость md2gost, Python 3.11+)"
)
if gv:
parts.append(f"Graphviz: {gv}")
else:
parts.append(
"Graphviz: нет — «Скачать Graphviz» / scripts/fetch_graphviz.py или Kroki"
)
return "\n".join(parts)
def write_dfd_outputs(
source: str,
out_png: Path,
out_svg: Path | None,
*,
scale: float = 2.0,
) -> bool:
"""Compile DSL and write PNG (+ optional SVG). Raises DfdParseError."""
del scale
dot, is_context = _compile_dot(source)
out_png = Path(out_png)
out_png.parent.mkdir(parents=True, exist_ok=True)
ok_png = _render_dot_local(dot, out_png, "png", context=is_context)
if not ok_png:
ok_png = _render_dot_kroki(dot, out_png, "png")
if not ok_png:
raise DfdParseError(
"Не удалось отрисовать DFD: скачайте Graphviz "
"(GUI: Диаграммы → Скачать Graphviz, или scripts/fetch_graphviz.py) "
"или включите Kroki (--diagram-fallback remote). "
"DSL: https://github.com/pbauermeister/dfd"
)
if out_svg is not None:
out_svg = Path(out_svg)
ok_svg = _render_dot_local(dot, out_svg, "svg", context=is_context)
if not ok_svg:
_render_dot_kroki(dot, out_svg, "svg")
_log.info("DFD ok → %s", out_png.name)
return True
+50 -5
View File
@@ -33,11 +33,16 @@ DIAGRAM_LANGS = frozenset(
"c4context",
"c4component",
"usecase",
"bpmn",
"mermaid",
"mmd",
"idef0",
"dfd",
}
)
# Native Pillow renderers — never jar / Kroki.
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd"})
# Local hi-res path uses a scale-specific cache key.
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd"})
FallbackMode = Literal["local", "remote", "off"]
DiagramFormat = Literal["png", "svg"]
@@ -255,6 +260,13 @@ def diagram_engine_status(explicit_jar: str | None = None) -> str:
lines = lines + "\n" + mermaid_engine_status()
except Exception as exc:
lines = lines + f"\nMermaid: статус недоступен ({exc})"
lines = lines + "\nIDEF0: локальный рендер (Pillow), оградка ```idef0"
try:
from .dfd import dfd_engine_status
lines = lines + "\n" + dfd_engine_status()
except Exception as exc:
lines = lines + f"\nDFD: статус недоступен ({exc})"
return lines
@@ -424,14 +436,23 @@ def render_diagram(
cache_prefix = CACHE_KEY_PREFIX
except Exception:
cache_prefix = "mmd-local-v4\n"
elif kroki_type == "idef0":
from .idef0 import CACHE_KEY_PREFIX as IDEF0_CACHE_PREFIX
# Mermaid local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
cache_prefix = IDEF0_CACHE_PREFIX
elif kroki_type == "dfd":
from .dfd import CACHE_KEY_PREFIX as DFD_CACHE_PREFIX
cache_prefix = DFD_CACHE_PREFIX
# Local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
local_prefix = cache_prefix
if kroki_type == "mermaid" and scale > 1:
if kroki_type in SCALED_LOCAL_TYPES and scale > 1:
local_prefix = f"{cache_prefix}scale={scale:g}\n"
out_png, out_svg_path = _cache_paths(
prepared, cache_dir, cache_prefix=local_prefix if kroki_type == "mermaid" else cache_prefix,
prepared, cache_dir,
cache_prefix=local_prefix if kroki_type in SCALED_LOCAL_TYPES else cache_prefix,
)
# Kroki / remote paths (mermaid): never share the scaled local key.
kroki_png, kroki_svg_path = out_png, out_svg_path
@@ -453,7 +474,7 @@ def render_diagram(
has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0
if not want_svg or has_svg:
hit_scale = pixel_scale
if kroki_type == "mermaid" and scale > 1 and local_prefix != cache_prefix:
if kroki_type in SCALED_LOCAL_TYPES and scale > 1 and local_prefix != cache_prefix:
hit_scale = scale
return DiagramRenderResult(
png_path=str(out_png),
@@ -505,6 +526,30 @@ def render_diagram(
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
return _result()
# 1a) Native Pillow (IDEF0 / DFD) — never Kroki / PlantUML
if kroki_type in LOCAL_ONLY_TYPES:
if kroki_type == "idef0":
from .idef0 import Idef0ParseError, write_idef0_outputs
try:
if write_idef0_outputs(prepared, out_png, svg_target, scale=scale):
return _result(pscale=scale if scale > 1 else 1.0)
except Idef0ParseError as exc:
raise RuntimeError(str(exc)) from exc
raise RuntimeError("Не удалось отрендерить IDEF0-диаграмму")
if kroki_type == "dfd":
from .dfd import DfdParseError, write_dfd_outputs
try:
if write_dfd_outputs(prepared, out_png, svg_target, scale=scale):
return _result(pscale=scale if scale > 1 else 1.0)
except DfdParseError as exc:
raise RuntimeError(str(exc)) from exc
raise RuntimeError("Не удалось отрендерить DFD-диаграмму")
raise RuntimeError(f"Неизвестный локальный тип диаграммы: {kroki_type}")
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
if kroki_type == "mermaid":
try:
+142 -15
View File
@@ -21,10 +21,33 @@ _log = logging.getLogger(__name__)
SCHEMES_FILENAME = "md2gost.schemes.json"
SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
APP_SCHEME_AUTHOR = "md2gost"
BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"})
# Rendered via Kroki only (not PlantUML jar / schemes).
KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"})
KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"}
# Local native renderers (not PlantUML / Kroki / schemes.json).
NATIVE_DIAGRAM_LANGS = {
"idef0": "idef0",
"uml-idef0": "idef0",
"dfd": "dfd",
"uml-dfd": "dfd",
"data-flow-diagram": "dfd",
"yourdon": "dfd",
"uml-yourdon": "dfd",
}
@dataclass
class SchemesMigrationResult:
added: list[str] = field(default_factory=list)
updated: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
written: bool = False
@property
def changed(self) -> bool:
return bool(self.added or self.updated or self.removed)
@dataclass
@@ -82,22 +105,84 @@ def user_schemes_path(base: Path | None = None) -> Path:
return (base or app_dir()) / SCHEMES_FILENAME
def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
"""True if scheme is managed by the app template (author=md2gost)."""
return scheme.author.strip().lower() == APP_SCHEME_AUTHOR
def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]:
return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult:
"""
Sync app-owned schemes in the user file with the bundled template.
- Bundled ids: always present; overwrite user entries with author=md2gost.
- User overrides of bundled ids (empty/other author): kept as-is.
- Orphaned author=md2gost ids no longer in the template: removed.
- Other custom schemes: kept.
"""
result = SchemesMigrationResult()
path = user_schemes_path(base)
if not path.is_file():
return result
bundled = _load_schemes_file(bundled_schemes_path())
user = _load_schemes_file(path)
out: dict[str, DiagramScheme] = {}
for sid, scheme in user.items():
if sid in bundled:
if is_app_owned_scheme(scheme):
continue # step below writes bundled
out[sid] = scheme
continue
if is_app_owned_scheme(scheme):
result.removed.append(sid)
_log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid)
continue
out[sid] = scheme
for sid, scheme in bundled.items():
if sid in out:
continue # user-owned override
prev = user.get(sid)
if prev is None:
result.added.append(sid)
_log.info("Миграция схем: добавлена «%s»", sid)
elif prev.to_dict() != scheme.to_dict():
result.updated.append(sid)
_log.info("Миграция схем: обновлена «%s»", sid)
out[sid] = scheme
if _schemes_payload(out) == _schemes_payload(user):
result.added.clear()
result.updated.clear()
result.removed.clear()
return result
save_schemes(out, path)
result.written = True
return result
def ensure_user_schemes(base: Path | None = None) -> Path:
"""
On first run copy bundled template next to the app.
Never overwrite an existing user file.
Ensure md2gost.schemes.json exists next to the app, then sync app-owned
schemes from the bundled template (add / update / remove orphans).
"""
dest = user_schemes_path(base)
if dest.is_file():
return dest
src = bundled_schemes_path()
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copyfile(src, dest)
_log.info("Создан файл схем: %s", dest)
else:
dest.write_text("{}\n", encoding="utf-8")
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
if not dest.is_file():
src = bundled_schemes_path()
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copyfile(src, dest)
_log.info("Создан файл схем: %s", dest)
else:
dest.write_text("{}\n", encoding="utf-8")
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
migrate_user_schemes(base)
return dest
@@ -129,7 +214,7 @@ def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
dest = path or user_schemes_path()
payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
payload = _schemes_payload(schemes)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
@@ -138,12 +223,50 @@ def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) ->
return dest
def claim_user_scheme_if_diverged(
scheme: DiagramScheme,
*,
bundled: dict[str, DiagramScheme] | None = None,
) -> DiagramScheme:
"""
If scheme id is in the app template and content differs while still
author=md2gost, clear author so migration will not overwrite user edits.
"""
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
scheme.id
)
if stock is None:
return scheme
if not is_app_owned_scheme(scheme):
return scheme
if scheme.to_dict() == stock.to_dict():
return scheme
return DiagramScheme(
id=scheme.id,
title=scheme.title,
version=scheme.version,
author="",
docs=scheme.docs,
ai_prompt=scheme.ai_prompt,
includes=list(scheme.includes),
prefix=scheme.prefix,
postfix=scheme.postfix,
theme=scheme.theme,
)
def native_diagram_type(lang: str) -> str | None:
"""Kroki/PlantUML-independent diagram type, or None."""
lang = (lang or "").lower().strip()
return NATIVE_DIAGRAM_LANGS.get(lang)
def scheme_id_from_lang(lang: str) -> str | None:
"""Normalize fence lang to scheme id, or None for bare uml/plantuml."""
lang = (lang or "").lower().strip()
if not lang:
return None
if lang in BUILTIN_DIAGRAM_LANGS:
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
return None
if lang.startswith("uml-"):
sid = lang[4:]
@@ -224,7 +347,7 @@ def get_scheme(scheme_id: str) -> DiagramScheme | None:
def is_diagram_lang(lang: str) -> bool:
"""True if fence should render as a diagram (not a plain listing)."""
lang = (lang or "").lower().strip()
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS:
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
return True
if lang.startswith("uml-"):
# Force diagram path so missing scheme becomes an error, not a listing.
@@ -316,6 +439,10 @@ def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) ->
if lang in KROKI_DIAGRAM_LANGS:
return text, KROKI_TYPE_BY_LANG[lang]
native = native_diagram_type(lang)
if native:
return text, native
sid = scheme_id_from_lang(lang)
if sid is None:
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -45,16 +45,14 @@
"prefix": "@startuml\nleft to right direction\nskinparam actorStyle awesome\n",
"postfix": "\n@enduml"
},
"bpmn": {
"title": "BPMN 2.0",
"archimate": {
"title": "ArchiMate 3.2",
"version": "1.0.0",
"author": "md2gost",
"docs": "Pool(alias, \"Участник\") { Lane(alias, \"Роль\") { … } }\nStart / StartMessage / StartTimer / StartSignal\nCatchMessage / ThrowMessage / CatchTimer / CatchError\nEnd / EndMessage / EndError / EndTerminate\nTask(alias, \"Имя\") / UserTask / ServiceTask / ScriptTask / ManualTask / SendTask / ReceiveTask / BusinessRuleTask\nSubProcess / SubProcessExpanded { … } / CallActivity\nXOR / AND / OR / EventBased / ComplexGW\nFlow / Flow_R/L/U/D / CondFlow(from, to, \"условие\") / DefaultFlow / MessageFlow\nDataObject / DataStore / Annotation\nBoundaryError + Attach(task, event)\nBPMN_VERTICAL() / BPMN_LEGEND()",
"ai-prompt": "Рисуй BPMN 2.0 (OMG). В блоке ```uml-bpmn (или ```bpmn) только макросы библиотеки md2gost. Без @startuml и без !include.\n\nПравила: Sequence Flow (Flow) только внутри одного пула. Между пулами — MessageFlow. У процесса есть Start и хотя бы один End. Развилка и слияние — шлюзы (XOR/AND/OR), не рисуй ветвление со стрелок задачи без шлюза, если это решение. Подпись условия — на CondFlow/Flow от шлюза. DefaultFlow — ветка «иначе». Не используй activity-syntax (start/:task/;), mxgraph и чистый UML.\n\nМакросы:\nПул: Pool(alias, \"Имя\") { Lane(alias, \"Роль\") { ... } }\nСобытия: Start, StartMessage, StartTimer, StartSignal, StartCondition, Intermediate, CatchMessage, ThrowMessage, CatchTimer, CatchSignal, ThrowSignal, CatchError, End, EndMessage, EndError, EndTerminate, EndSignal\nЗадачи: Task, UserTask, ServiceTask, ScriptTask, ManualTask, SendTask, ReceiveTask, BusinessRuleTask, SubProcess, SubProcessExpanded { ... }, CallActivity\nШлюзы: Exclusive/XOR, Parallel/AND, Inclusive/OR, EventBased, ComplexGW\nПотоки: Flow, Flow_R, Flow_L, Flow_U, Flow_D, CondFlow(from,to,\"условие\"), DefaultFlow, MessageFlow, Assoc, DataAssoc\nДанные: DataObject, DataStore, Annotation\nГраница: BoundaryError/Timer/Message + Attach(task, event)\nМакет: Lay_R/L/U/D, BPMN_VERTICAL(), BPMN_LEGEND()\nПсевдонимы элементов — латиница (s, t1, gw, e_ok). Не называй alias зарезервированными словами end/start/group.",
"includes": [
"BPMN.puml"
],
"prefix": "@startuml\nleft to right direction\n",
"docs": "LAYOUT_TOP_DOWN() / LAYOUT_LEFT_RIGHT()\nMotivation_Stakeholder/Driver/Assessment/Goal/Outcome/Principle/Requirement/Constraint/Meaning/Value\nStrategy_Resource/Capability/ValueStream/CourseOfAction\nBusiness_Actor/Role/Collaboration/Interface/Process/Function/Interaction/Event/Service/Object/Contract/Representation/Product\nApplication_Component/Collaboration/Interface/Function/Interaction/Process/Event/Service/DataObject\nTechnology_Node/Device/SystemSoftware/Collaboration/Interface/Path/CommunicationNetwork/Function/Process/Interaction/Event/Service/Artifact\nPhysical_Equipment/Facility/DistributionNetwork/Material\nImplementation_WorkPackage/Deliverable/Event/Plateau/Gap\nGrouping / Other_Grouping / Group / Other_Location / Junction_And / Junction_Or\nRel_Composition/Aggregation/Assignment/Realization/Serving/Access(_r|_w|_rw)/Influence/Triggering/Flow/Specialization/Association(_dir)\nRel_Type_Up/Down/Left/Right(from, to, \"label?\")\nLay_U/D/L/R — скрытое выравнивание",
"ai-prompt": "Рисуй ArchiMate 3.2 (Open Group C226). В блоке ```uml-archimate (или ```archimate) только макросы Archimate-PlantUML. Без @startuml и без !include — схема добавит сама.\n\nПравила: один рисунок = один viewpoint. Поведение по слоям (Business_Process ≠ Application_Process ≠ Technology_Process). Цепочка: Active —Assignment→ Process/Function —Realization→ Service —Serving→ потребитель; Access к пассивному объекту. Serving от поставщика к потребителю; Realization от конкретного к абстрактному; Composition/Aggregation целое→часть. Не Association «на всё». Не UML/C4/BPMN. Alias латиница (не end/start/group). 818 элементов. Сначала элементы, потом Rel_*. Подписи по-русски.\n\nМакросы: Motivation_*, Strategy_*, Business_*, Application_*, Technology_*, Physical_*, Implementation_*, Grouping, Junction_And/Or. Связи: Rel_Composition/Aggregation/Assignment/Realization/Serving/Access_r|w|rw/Influence/Triggering/Flow/Specialization/Association + _Up/_Down/_Left/_Right. Макет: LAYOUT_TOP_DOWN() или LAYOUT_LEFT_RIGHT(). Полный гайд: docs/archimate.md.",
"includes": [],
"prefix": "@startuml\n!include <archimate/Archimate>\n!theme archimate-standard from <archimate/themes>\n",
"postfix": "\n@enduml"
}
}
+7
View File
@@ -65,6 +65,13 @@ def first_markdown(paths: list[str]) -> str | None:
return None
def first_docx(paths: list[str]) -> str | None:
for path in paths:
if path.lower().endswith(".docx") and os.path.isfile(path):
return path
return None
def enable_file_drop(widget, callback: DropCallback) -> str:
"""Enable dropping files onto widget. Returns backend name: tkdnd | win32 | none."""
def _deliver(items: list[str] | str) -> None:
+148
View File
@@ -0,0 +1,148 @@
"""DOCX core properties (File → Info) for generated reports."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from getpass import getuser
from typing import Any
CORE_PROP_MAX = 255
AUTHOR_SOURCES = ("os", "student", "custom")
DEFAULT_AUTHOR_SOURCE = "os"
DEFAULT_DOC_COMMENTS = "Создано при помощи md2gost (ТЗ МИРЭА)"
def os_user() -> str:
try:
return (getuser() or "").strip()
except Exception:
return ""
def clip_core(value: str | None) -> str:
text = "" if value is None else str(value)
if len(text) > CORE_PROP_MAX:
return text[:CORE_PROP_MAX]
return text
def _normalize_source(value: str | None) -> str:
src = (value or DEFAULT_AUTHOR_SOURCE).strip().lower()
return src if src in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
@dataclass
class DocumentMetadata:
author_source: str = DEFAULT_AUTHOR_SOURCE
author: str = ""
last_modified_by: str = ""
title: str = ""
subject: str = ""
keywords: str = ""
comments: str = DEFAULT_DOC_COMMENTS
category: str = ""
def to_dict(self) -> dict[str, str]:
return {
"author_source": _normalize_source(self.author_source),
"author": (self.author or "").strip(),
"last_modified_by": (self.last_modified_by or "").strip(),
"title": (self.title or "").strip(),
"subject": (self.subject or "").strip(),
"keywords": (self.keywords or "").strip(),
"comments": "" if self.comments is None else str(self.comments).strip(),
"category": (self.category or "").strip(),
}
@classmethod
def from_dict(cls, raw: object) -> DocumentMetadata:
if not isinstance(raw, dict):
return cls()
comments = raw.get("comments")
if comments is None:
comments_s = DEFAULT_DOC_COMMENTS
else:
comments_s = str(comments).strip()
return cls(
author_source=_normalize_source(str(raw.get("author_source") or "")),
author=str(raw.get("author") or "").strip(),
last_modified_by=str(raw.get("last_modified_by") or "").strip(),
title=str(raw.get("title") or "").strip(),
subject=str(raw.get("subject") or "").strip(),
keywords=str(raw.get("keywords") or "").strip(),
comments=comments_s,
category=str(raw.get("category") or "").strip(),
)
def _pick(override: str | None, fallback: str) -> str:
if override is not None:
return str(override)
return fallback
def resolve_document_metadata(
*,
stored: DocumentMetadata | None = None,
student: str | None = None,
author_source: str | None = None,
author: str | None = None,
last_modified_by: str | None = None,
title: str | None = None,
subject: str | None = None,
keywords: str | None = None,
comments: str | None = None,
category: str | None = None,
) -> DocumentMetadata:
"""Merge CLI/request overrides over the saved profile into concrete core properties."""
base = stored or DocumentMetadata()
src = _normalize_source(author_source or base.author_source)
if author is not None:
resolved_author = str(author).strip()
elif src == "custom":
resolved_author = (base.author or "").strip()
elif src == "student":
resolved_author = (student or "").strip()
else:
resolved_author = ""
if not resolved_author:
resolved_author = os_user()
last_mod = _pick(last_modified_by, base.last_modified_by).strip()
if not last_mod:
last_mod = resolved_author
return DocumentMetadata(
author_source=src,
author=resolved_author,
last_modified_by=last_mod,
title=_pick(title, base.title).strip(),
subject=_pick(subject, base.subject).strip(),
keywords=_pick(keywords, base.keywords).strip(),
comments=_pick(comments, base.comments).strip(),
category=_pick(category, base.category).strip(),
)
def apply_document_metadata(
document: Any,
meta: DocumentMetadata,
*,
now: datetime | None = None,
) -> None:
stamp = now or datetime.utcnow()
if stamp.tzinfo is not None:
stamp = stamp.replace(tzinfo=None)
cp = document.core_properties
cp.author = clip_core(meta.author)
cp.last_modified_by = clip_core(meta.last_modified_by or meta.author)
cp.title = clip_core(meta.title)
cp.subject = clip_core(meta.subject)
cp.keywords = clip_core(meta.keywords)
cp.comments = clip_core(meta.comments)
cp.category = clip_core(meta.category)
cp.created = stamp
cp.modified = stamp
cp.revision = 1
+395 -20
View File
@@ -13,6 +13,7 @@ from .diagram_renderer import diagram_engine_status
from .diagram_schemes import (
DiagramScheme,
SCHEME_ID_RE,
claim_user_scheme_if_diverged,
ensure_user_schemes,
get_schemes,
load_schemes_from_path,
@@ -20,7 +21,7 @@ from .diagram_schemes import (
save_schemes,
user_schemes_path,
)
from .dnd import enable_file_drop, first_markdown
from .dnd import enable_file_drop, first_docx, first_markdown
from .help_content import (
SCHEMES_HELP,
USAGE_HELP,
@@ -200,6 +201,8 @@ class Md2GostApp:
self._win_files: tk.Toplevel | None = None
self._win_diagrams: tk.Toplevel | None = None
self._win_schemes: tk.Toplevel | None = None
self._win_student: tk.Toplevel | None = None
self._win_meta: tk.Toplevel | None = None
self._win_help: dict[str, tk.Toplevel] = {}
ensure_user_schemes()
@@ -209,6 +212,7 @@ class Md2GostApp:
self._build()
self._apply_request(self.req)
self.root.after(300, self._setup_dnd)
self.root.after(400, self._ensure_student_profile)
if self.req.filename:
self._set_markdown(self.req.filename, from_user_output=bool(self.req.output))
@@ -229,6 +233,7 @@ class Md2GostApp:
style.configure("Title.TLabel", font=font_title, foreground=NAVY, background=PAPER)
style.configure("Hint.TLabel", foreground=MUTED, background=PAPER)
style.configure("TCheckbutton", background=PAPER, font=font)
style.configure("TRadiobutton", background=PAPER, foreground=INK, font=font)
style.configure("TButton", font=font, padding=(10, 4))
style.configure("TEntry", padding=3)
style.configure("TCombobox", padding=3)
@@ -242,6 +247,7 @@ class Md2GostApp:
self.listing_var = tk.StringVar()
self.emdash_var = tk.BooleanVar(value=False)
self.hr_pagebreak_var = tk.BooleanVar(value=False)
self.auto_title_var = tk.BooleanVar(value=False)
self.open_var = tk.BooleanVar(value=True)
self.check_var = tk.BooleanVar(value=False)
self.check_only_var = tk.BooleanVar(value=False)
@@ -268,6 +274,8 @@ class Md2GostApp:
settings = tk.Menu(menubar, tearoff=0)
settings.add_command(label="Файлы…", command=self._open_files_window)
settings.add_command(label="Студент…", command=self._open_student_window)
settings.add_command(label="Метаданные…", command=self._open_metadata_window)
settings.add_command(label="Диаграммы…", command=self._open_diagrams_window)
menubar.add_cascade(label="Настройки", menu=settings)
@@ -340,6 +348,8 @@ class Md2GostApp:
hint = ttk.Label(
frame,
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.\n"
"Автотитул (info_conv.yaml / ```title) — галочка «Автотитул» на главном окне "
"(по умолчанию выкл.; ФИО/группа — Настройки → Студент…).\n"
"Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.",
style="Hint.TLabel",
wraplength=560,
@@ -348,6 +358,225 @@ class Md2GostApp:
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files"))
return win
def _ensure_student_profile(self) -> None:
from .user_profile import load_user_profile
if load_user_profile().is_complete():
return
self._open_student_window(first_run=True)
def _open_student_window(self, first_run: bool = False) -> None:
existing = getattr(self, "_win_student", None)
if existing is not None and existing.winfo_exists():
existing.lift()
existing.focus_force()
return
self._win_student = self._create_student_window(first_run=first_run)
def _create_student_window(self, *, first_run: bool = False) -> tk.Toplevel:
from .user_profile import load_user_profile, save_user_profile
profile = load_user_profile()
win = tk.Toplevel(self.root)
win.title("Данные студента" if first_run else "Настройки — Студент")
win.configure(bg=PAPER)
win.minsize(420, 180)
win.geometry("480x200")
win.transient(self.root)
if first_run:
win.grab_set()
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
ttk.Label(
frame,
text="ФИО и группа нужны для автотитульника (info_conv.yaml / ```title).",
style="Hint.TLabel",
wraplength=440,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
student_var = tk.StringVar(value=profile.student)
group_var = tk.StringVar(value=profile.group)
ttk.Label(frame, text="ФИО").grid(row=1, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=student_var).grid(row=1, column=1, sticky="ew", pady=4, padx=(8, 0))
ttk.Label(frame, text="Группа").grid(row=2, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=group_var).grid(row=2, column=1, sticky="ew", pady=4, padx=(8, 0))
def _save(close: bool = True) -> None:
student = student_var.get().strip()
group = group_var.get().strip()
if not student or not group:
messagebox.showwarning(
"Данные студента",
"Заполните ФИО и группу.",
parent=win,
)
return
current = load_user_profile()
current.student = student
current.group = group
save_user_profile(current)
if close:
self._close_toplevel(win, "_win_student")
btns = ttk.Frame(frame)
btns.grid(row=3, column=0, columnspan=2, sticky="e", pady=(12, 0))
ttk.Button(btns, text="Сохранить", command=lambda: _save(True)).pack(side=tk.RIGHT)
def _on_close() -> None:
if first_run and not load_user_profile().is_complete():
# Allow closing only after save; remind once
if not student_var.get().strip() or not group_var.get().strip():
messagebox.showinfo(
"Данные студента",
"Можно заполнить позже: Настройки → Студент…\n"
"Без ФИО/группы автотитул не соберётся.",
parent=win,
)
self._close_toplevel(win, "_win_student")
win.protocol("WM_DELETE_WINDOW", _on_close)
return win
def _open_metadata_window(self) -> None:
self._win_meta = self._raise_or_create(self._win_meta, self._create_metadata_window)
def _create_metadata_window(self) -> tk.Toplevel:
from .doc_metadata import (
AUTHOR_SOURCES,
DEFAULT_AUTHOR_SOURCE,
DEFAULT_DOC_COMMENTS,
DocumentMetadata,
os_user,
)
from .user_profile import load_user_profile, save_user_profile
profile = load_user_profile()
meta = profile.metadata or DocumentMetadata()
win = tk.Toplevel(self.root)
win.title("Настройки — Метаданные")
win.configure(bg=PAPER)
win.minsize(520, 420)
win.geometry("580x500")
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
ttk.Label(
frame,
text=(
"Свойства файла Word (Файл → сведения): автор, название, тема, теги, примечание. "
"Хранятся в md2gost.user.json рядом с приложением."
),
style="Hint.TLabel",
wraplength=540,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
src_value = meta.author_source if meta.author_source in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
author_src = tk.StringVar(value=src_value)
author_var = tk.StringVar(value=meta.author)
last_mod_var = tk.StringVar(value=meta.last_modified_by)
title_var = tk.StringVar(value=meta.title)
subject_var = tk.StringVar(value=meta.subject)
keywords_var = tk.StringVar(value=meta.keywords)
category_var = tk.StringVar(value=meta.category)
comments_var = tk.StringVar(value=meta.comments)
ttk.Label(frame, text="Автор").grid(row=1, column=0, sticky="nw", pady=(4, 0))
src_box = ttk.Frame(frame)
src_box.grid(row=1, column=1, sticky="ew", pady=(4, 0), padx=(8, 0))
os_name = os_user() or ""
student_name = (profile.student or "").strip()
student_text = (
f"ФИО студента ({student_name})"
if student_name
else "ФИО студента (сначала Настройки → Студент…)"
)
ttk.Radiobutton(
src_box, text=f"Имя пользователя Windows ({os_name})",
variable=author_src, value="os",
).pack(anchor="w")
ttk.Radiobutton(
src_box, text=student_text, variable=author_src, value="student",
).pack(anchor="w")
ttk.Radiobutton(
src_box, text="Указать вручную", variable=author_src, value="custom",
).pack(anchor="w")
author_entry = ttk.Entry(frame, textvariable=author_var)
author_entry.grid(row=2, column=1, sticky="ew", pady=(2, 4), padx=(8, 0))
_tip(author_entry, "Используется, если выбран пункт «Указать вручную»")
def _sync_author_entry(*_a) -> None:
state = "normal" if author_src.get() == "custom" else "disabled"
try:
author_entry.configure(state=state)
except tk.TclError:
pass
author_src.trace_add("write", _sync_author_entry)
_sync_author_entry()
def _row(row: int, label: str, var: tk.StringVar, tip: str) -> None:
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
entry = ttk.Entry(frame, textvariable=var)
entry.grid(row=row, column=1, sticky="ew", pady=4, padx=(8, 0))
_tip(entry, tip)
_row(3, "Кем изменён", last_mod_var, "Пусто — то же значение, что у автора")
_row(4, "Название", title_var, "dc:title в свойствах файла; пусто — не задавать")
_row(5, "Тема", subject_var, "Тема документа в свойствах файла")
_row(6, "Ключевые слова", keywords_var, "Теги в свойствах файла Word")
_row(7, "Категория", category_var, "Категория в свойствах файла")
_row(
8,
"Примечание",
comments_var,
f"По умолчанию: «{DEFAULT_DOC_COMMENTS}». Пусто — не писать примечание.",
)
ttk.Label(
frame,
text="Пустое примечание убирает и текст md2gost, и «generated by python-docx» из шаблона.",
style="Hint.TLabel",
wraplength=540,
).grid(row=9, column=0, columnspan=2, sticky="w", pady=(4, 0))
def _save() -> None:
src = author_src.get() if author_src.get() in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
current = load_user_profile()
current.metadata = DocumentMetadata(
author_source=src,
author=author_var.get().strip(),
last_modified_by=last_mod_var.get().strip(),
title=title_var.get().strip(),
subject=subject_var.get().strip(),
keywords=keywords_var.get().strip(),
comments=comments_var.get().strip(),
category=category_var.get().strip(),
)
save_user_profile(current)
self._close_toplevel(win, "_win_meta")
def _reset() -> None:
author_src.set(DEFAULT_AUTHOR_SOURCE)
author_var.set("")
last_mod_var.set("")
title_var.set("")
subject_var.set("")
keywords_var.set("")
category_var.set("")
comments_var.set(DEFAULT_DOC_COMMENTS)
btns = ttk.Frame(frame)
btns.grid(row=10, column=0, columnspan=2, sticky="e", pady=(12, 0))
ttk.Button(btns, text="Сбросить", command=_reset).pack(side=tk.LEFT, padx=(0, 8))
ttk.Button(btns, text="Сохранить", command=_save).pack(side=tk.RIGHT)
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_meta"))
return win
def _create_diagrams_window(self) -> tk.Toplevel:
win = tk.Toplevel(self.root)
win.title("Настройки — Диаграммы")
@@ -362,10 +591,10 @@ class Md2GostApp:
hint = ttk.Label(
extra,
text=(
"Блоки ```uml / ```uml-c4 / ```bpmn / ```mermaid сами станут рисунками.\n"
"Блоки ```uml / ```uml-c4 / ```mermaid / ```idef0 / ```dfd сами станут рисунками.\n"
"Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n"
"UML: PlantUML + Java, иначе Kroki. Mermaid: системный браузер → "
"Chromium → QuickJS → Kroki (kroki.io остаётся запасным)."
"Chromium → QuickJS → Kroki. IDEF0 — Pillow. DFD — Graphviz / Kroki."
),
style="Hint.TLabel",
wraplength=640,
@@ -377,14 +606,17 @@ class Md2GostApp:
status_row.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
status_row.columnconfigure(0, weight=1)
self.diag_status = ttk.Label(status_row, text="", style="Hint.TLabel", wraplength=480, justify=tk.LEFT)
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=2)
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=3)
btn_dl = ttk.Button(status_row, text="Скачать PlantUML", command=self._download_plantuml)
btn_dl.grid(row=0, column=1, sticky="e", padx=(8, 0))
_tip(btn_dl, "Скачать plantuml.jar для локальной отрисовки (нужна Java)")
btn_gv = ttk.Button(status_row, text="Скачать Graphviz", command=self._download_graphviz)
btn_gv.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(btn_gv, "Скачать portable Graphviz (dot/neato) для локальных DFD")
btn_cr = ttk.Button(
status_row, text="Скачать headless Chromium", command=self._download_chromium,
)
btn_cr.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
btn_cr.grid(row=2, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(
btn_cr,
"Скачать Chromium для Mermaid в %LOCALAPPDATA%\\md2gost "
@@ -629,7 +861,7 @@ class Md2GostApp:
def _build_schemes_help(self, parent) -> None:
ttk.Label(
parent,
text="Настраиваемые схемы диаграмм (uml-c4, bpmn, …)",
text="Настраиваемые схемы диаграмм (uml-c4, usecase, …)",
style="Hint.TLabel",
).pack(anchor=tk.W, pady=(0, 6))
box = self._readonly_text(parent, SCHEMES_HELP.strip() + "\n")
@@ -641,7 +873,7 @@ class Md2GostApp:
parent,
text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. "
"Кнопки схем дописывают макросы из md2gost.schemes.json — "
"когда ИИ должен знать C4/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
"когда ИИ должен знать C4/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
style="Hint.TLabel",
wraplength=640,
).pack(anchor=tk.W, pady=(0, 8))
@@ -800,7 +1032,7 @@ class Md2GostApp:
self.zone.pack(fill=tk.X, pady=(0, 8))
self.zone_label = tk.Label(
self.zone,
text="Перетащите .md сюда · или нажмите, чтобы выбрать файл",
text="Перетащите .md или .docx сюда · или нажмите, чтобы выбрать файл",
bg=ZONE, fg=NAVY, font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
justify=tk.CENTER, pady=14,
)
@@ -812,8 +1044,8 @@ class Md2GostApp:
self.zone_path.pack(fill=tk.X, pady=(0, 8))
for w in (self.zone, self.zone_label, self.zone_path):
w.bind("<Button-1>", lambda _e: self._browse_md())
_tip(self.zone, "Перетащите .md или кликните, чтобы выбрать файл")
_tip(self.zone_label, "Перетащите .md или кликните, чтобы выбрать файл")
_tip(self.zone, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
_tip(self.zone_label, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
grid = ttk.Frame(outer)
grid.pack(fill=tk.X)
@@ -857,6 +1089,11 @@ class Md2GostApp:
checks = [
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
(
"Автотитул",
self.auto_title_var,
"Собрать титул из info_conv.yaml / ```title (нужны ФИО и группа). По умолчанию выкл.",
),
("Открыть после конвертации", self.open_var, "Открыть DOCX после успешной сборки"),
("Проверить по ТЗ", self.check_var, "Замечания по структуре, «рис.», источникам"),
("Только проверка", self.check_only_var, "Проверить без записи DOCX"),
@@ -886,7 +1123,17 @@ class Md2GostApp:
relief=tk.FLAT, padx=22, pady=7, cursor="hand2",
)
self.convert_btn.pack(side=tk.LEFT)
_tip(self.convert_btn, "Собрать DOCX из выбранного markdown")
_tip(self.convert_btn, "Собрать DOCX из markdown или импортировать DOCX → Markdown")
self.import_btn = tk.Button(
actions, text="Импорт DOCX→MD",
command=self._import_docx,
bg="#3d5a40", fg="white", activebackground="#2d4430", activeforeground="white",
disabledforeground="#d0d5dc",
font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
relief=tk.FLAT, padx=14, pady=7, cursor="hand2",
)
self.import_btn.pack(side=tk.LEFT, padx=(8, 0))
_tip(self.import_btn, "Выбрать .docx и сохранить диалект md2gost (.md + медиа)")
self.progress = ttk.Progressbar(actions, mode="indeterminate", length=180)
self.progress.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
@@ -901,7 +1148,7 @@ class Md2GostApp:
self.log.configure(yscrollcommand=scroll.set)
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.log.insert("1.0", "Готово к работе. Перетащите markdown-файл в верхнюю область.\n")
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
self.log.configure(state=tk.DISABLED)
def _build_schemes_tab(self, parent) -> None:
@@ -1067,6 +1314,7 @@ class Md2GostApp:
scheme = self._scheme_from_form(old_id)
if scheme is None:
return None
scheme = claim_user_scheme_if_diverged(scheme)
if old_id and old_id != scheme.id and old_id in self._schemes:
del self._schemes[old_id]
self._schemes[scheme.id] = scheme
@@ -1197,7 +1445,7 @@ class Md2GostApp:
if backend == "none":
self._append_log("Перетаскивание недоступно — выберите файл кликом по области.")
else:
self._append_log("Можно перетаскивать .md из проводника в окно.")
self._append_log("Можно перетаскивать .md / .docx из проводника в окно.")
def _on_drop(self, paths: list[str]) -> None:
md = first_markdown(paths)
@@ -1205,8 +1453,16 @@ class Md2GostApp:
self._set_markdown(md)
self._flash_zone(ZONE_OK)
return
docx = first_docx(paths)
if docx:
self._set_docx(docx)
self._flash_zone(ZONE_OK)
return
if paths:
self._append_log("Нужен файл .md, а не: " + ", ".join(os.path.basename(p) for p in paths))
self._append_log(
"Нужен файл .md или .docx, а не: "
+ ", ".join(os.path.basename(p) for p in paths)
)
self._flash_zone("#f8d7da")
def _flash_zone(self, color: str) -> None:
@@ -1222,10 +1478,19 @@ class Md2GostApp:
def _browse_md(self) -> None:
path = filedialog.askopenfilename(
title="Исходный Markdown",
filetypes=[("Markdown", "*.md"), ("Все файлы", "*.*")],
title="Исходный Markdown или Word",
filetypes=[
("Markdown / Word", "*.md;*.docx"),
("Markdown", "*.md"),
("Word", "*.docx"),
("Все файлы", "*.*"),
],
)
if path:
if not path:
return
if path.lower().endswith(".docx"):
self._set_docx(path)
else:
self._set_markdown(path)
def _browse_output(self) -> None:
@@ -1290,6 +1555,55 @@ class Md2GostApp:
"Можно ничего не делать — диаграммы нарисует kroki.io.",
)
def _download_graphviz(self) -> None:
from .dfd import (
cached_graphviz_root,
fetch_graphviz,
vendor_graphviz_root,
)
dest = vendor_graphviz_root()
try:
dest.mkdir(parents=True, exist_ok=True)
probe = dest / ".write_test"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError:
dest = cached_graphviz_root()
self._append_log(f"Скачиваю Graphviz → {dest}")
self.root.update_idletasks()
def work() -> None:
ok = fetch_graphviz(dest)
self.root.after(0, lambda: self._on_graphviz_installed(ok, dest))
import threading
threading.Thread(target=work, daemon=True).start()
def _on_graphviz_installed(self, ok: bool, dest) -> None:
self._refresh_diagram_status()
if ok:
engine = None
try:
from .dfd import resolve_graphviz_engine
engine = resolve_graphviz_engine(False)
except Exception:
pass
messagebox.showinfo(
"md2gost",
f"Graphviz сохранён:\n{dest}\n\n"
f"Движок: {engine or 'dot в каталоге'}",
)
else:
messagebox.showerror(
"md2gost",
"Не удалось скачать Graphviz.\n"
"DFD может пойти через kroki.io "
"(«Как рисовать» → через интернет) или системный PATH.",
)
def _download_chromium(self) -> None:
from .mermaid_renderer import install_playwright_chromium, playwright_browsers_dir, playwright_available
@@ -1351,6 +1665,32 @@ class Md2GostApp:
self._auto_output = True
self._append_log(f"Файл: {path}")
def _set_docx(self, path: str) -> None:
path = os.path.abspath(path)
self._last_md = path
self.zone_label.configure(text=os.path.basename(path))
self.zone_path.configure(text=path)
if self._auto_output:
stem = os.path.splitext(path)[0]
self.output_var.set(stem + ".md")
self._auto_output = True
self._append_log(f"DOCX для импорта: {path}")
def _import_docx(self) -> None:
"""Pick a DOCX (or use current) and run word2md."""
if self._busy:
return
path = self._last_md if self._last_md.lower().endswith(".docx") else ""
if not path:
path = filedialog.askopenfilename(
title="Импорт DOCX → Markdown",
filetypes=[("Word", "*.docx"), ("Все файлы", "*.*")],
)
if not path:
return
self._set_docx(path)
self._run_convert(force_import=True)
def _apply_request(self, req: ConvertRequest) -> None:
self.type_var.set(_display(DOC_TYPE_LABELS, req.doc_type if req.doc_type in DOC_TYPES else "practice"))
self.heading_var.set(_display(HEADING_LABELS, req.heading_numbering or DEFAULT_HEADING_NUMBERING))
@@ -1363,6 +1703,7 @@ class Md2GostApp:
self.scale_var.set(str(scale).rstrip("0").rstrip(".") if isinstance(scale, float) else str(scale))
self.emdash_var.set(bool(req.emdash_to_hyphen))
self.hr_pagebreak_var.set(bool(req.hr_pagebreak))
self.auto_title_var.set(bool(getattr(req, "auto_title", False)))
self.open_var.set(True)
self.check_var.set(bool(req.check))
self.check_only_var.set(bool(req.check_only))
@@ -1398,6 +1739,7 @@ class Md2GostApp:
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
emdash_to_hyphen=self.emdash_var.get(),
hr_pagebreak=self.hr_pagebreak_var.get(),
auto_title=self.auto_title_var.get(),
title=self.title_var.get().strip() or None,
assignment=self.assignment_var.get().strip() or None,
check=self.check_var.get() or self.check_only_var.get(),
@@ -1436,13 +1778,39 @@ class Md2GostApp:
return value if value >= 1 else 1.0
def _convert(self) -> None:
self._run_convert(force_import=False)
def _run_convert(self, *, force_import: bool) -> None:
if self._busy:
return
req = self._collect()
if not req.filename:
messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.")
messagebox.showinfo(
"md2gost",
"Сначала перетащите или выберите .md (сборка) или .docx (импорт).",
)
return
if not req.check_only:
is_docx = req.filename.lower().endswith(".docx")
if force_import and not is_docx:
messagebox.showinfo("md2gost", "Для импорта нужен файл .docx.")
return
if is_docx:
target = (req.output or "").strip()
if target.lower().endswith(".docx"):
target = os.path.splitext(req.filename)[0] + ".md"
req.output = target
self.output_var.set(target)
if target and os.path.isfile(target):
if not messagebox.askyesno(
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
):
base, ext = os.path.splitext(target)
target = f"{base}_import{ext or '.md'}"
self._append_log(f"Не перезаписываем, пишем в {target}")
req.output = target
self.output_var.set(target)
self._append_log("Импорт DOCX → Markdown…")
elif not req.check_only:
target = (req.output or "").strip() or (
default_output_path(req.filename) if req.filename else ""
)
@@ -1455,10 +1823,15 @@ class Md2GostApp:
req.output = target
self.output_var.set(target)
self._auto_output = False
self._append_log("Конвертация…")
else:
self._append_log("Проверка…")
self._busy = True
self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM)
if hasattr(self, "import_btn"):
self.import_btn.configure(state=tk.DISABLED)
self.progress.start(12)
self._append_log("Конвертация…")
def work():
result = convert(req, log=lambda m: self.root.after(0, self._append_log, m))
@@ -1469,6 +1842,8 @@ class Md2GostApp:
def _on_done(self, result) -> None:
self._busy = False
self.convert_btn.configure(state=tk.NORMAL, bg=NAVY)
if hasattr(self, "import_btn"):
self.import_btn.configure(state=tk.NORMAL, bg="#3d5a40")
self.progress.stop()
if self._debug_next or self.debug_var.get():
self.debug_var.set(False)
+65 -38
View File
@@ -13,6 +13,8 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
1. Перетащите .md в верхнюю область (или кликните по ней).
2. Выберите тип документа и параметры в блоке «Основные».
Шаблон / титул / задание — Настройки → Файлы.
ФИО и группа студента — Настройки → Студент… (спрашивают при первом запуске).
Свойства DOCX (автор, название, примечание) — Настройки → Метаданные….
PlantUML / Mermaid / Kroki — Настройки → Диаграммы.
Свои UML-схемы — меню «Шаблоны UML».
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
@@ -28,14 +30,29 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
• Проверить по ТЗ — замечания по структуре, «рис.», источникам.
• Проверить вёрстку в Word — полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32).
• Титул / задание — отдельные DOCX, вставляются перед телом отчёта.
Автотитул — галочка «Автотитул» / CLI `--auto-title` (по умолчанию **выкл.**):
info_conv.yaml рядом с .md + опционально блок ```title (number, year…);
ФИО/группа — Настройки → Студент… или --student / --group.
Явный путь к титулу отключает генератор. Подробно: docs/title.md.
• Свойства файла Word — Настройки → Метаданные… / --doc-author-from, --doc-author, --doc-title, --doc-comments….
По умолчанию автор = имя Windows, примечание «Создано при помощи md2gost (ТЗ МИРЭА)».
CLI (тот же движок)
python -m md2gost report.md -o report.docx --type coursework --check
python -m md2gost report.md --auto-title --student "Иванов И. И." --group ИНБО-31-23
python -m md2gost report.md --doc-author-from student --doc-comments ""
python -m md2gost report.docx -o report.md
python -m word2md report.docx
python -m md2gost --gui
md2gost.exe report.md --type PIS_custom --title title.docx
md2gost.exe report.md --schemes path/to/md2gost.schemes.json
md2gost.exe report.md --styles path/to/md2gost.styles.json
Импорт Word → Markdown
Перетащите .docx или кнопка «Импорт DOCX→MD». Подписи, таблицы, спецразделы
переводятся в диалект md2gost. UML/Mermaid из картинок не восстанавливаются.
Подробнее: docs/word2md.md.
Стили JSON (опционально)
Оверлей поверх пресета типа документа (--type). Файл md2gost.styles.json рядом с .md
или --styles / Настройки → Файлы → «Стили JSON». Меняет поля страницы и параметры
@@ -101,12 +118,32 @@ CLI (тот же движок)
Alice->>Bob: hello
```
IDEF0 (контекст A-0, рамка NIST):
%a0 Контекст +landscape
```idef0
title Распорядиться товаром
node A-0
[A0] Распорядиться товаром
<- Спрос
^ Нормативная документация
-> Товар
v Персонал
```
DFD (pbauermeister/dfd → Graphviz вшитый/PATH / Kroki):
%dfd0 Контекстная DFD
```dfd
style context
entity Client Клиент
process System Система учёта заявок
Client --> System заявка
System --> Client статус
```
+listing — ещё и листинг с исходником.
+landscape — отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная.
Языки: uml, plantuml, mermaid / mmd, или uml-<id> / <id> из файла схем (c4, usecase, bpmn, …).
BPMN 2.0: оградка ```bpmn / ```uml-bpmn, макросы Start, UserTask, XOR, Flow, Pool.
Формат в Word: PNG по умолчанию (PlantUML и локальный Mermaid ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
IDEF0 конвертер не рисует — вставляйте готовый PNG как обычный Рисунок.
Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, или uml-<id> / <id> из файла схем (c4, usecase, archimate, …).
Формат в Word: PNG по умолчанию (PlantUML, локальный Mermaid, IDEF0 и DFD ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
Подробнее — Справка → Схемы и раздел ниже в инструкции.
Формула (номер только если есть ссылка)
@@ -129,13 +166,17 @@ IDEF0 конвертер не рисует — вставляйте готовы
native — поле Word (обновить при открытии); manual — собирает md2gost.
Продолжение таблиц / листингов
word — после сборки Word COM режет по реальной пагинации и вставляет «Продолжение…»
(по умолчанию; нужны Windows + Word + pywin32).
off — не резать, Word сам переносит.
word — по умолчанию: после сборки Word COM режет по реальной пагинации и
вставляет «Продолжение…» (нужны Windows + Word + pywin32). Также уводит
таблицу целиком, если внизу страницы зависли только название или
название + шапка граф (методичка: рис. Г.3).
off — одна таблица, Word сам переносит строки; без авто«Продолжение».
Защита от сирот: keep_with_next у подписи/шапки и оценка
«подпись+шапка+1 строка» при рендере (без COM слабее, чем word).
legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word).
Промпт для ИИ — Справка → Промпт для ИИ: выберите промпт, при необходимости
включите схемы (C4, BPMN, …) кнопками — макросы допишутся в конец — скопируйте
включите схемы (C4, usecase, …) кнопками — макросы допишутся в конец — скопируйте
в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
Документация — Справка → Документация: встроенный просмотр docs/*.md
@@ -151,15 +192,22 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
Первый запуск
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
создаётся md2gost.schemes.json из встроенного шаблона.
Если файл уже есть — программа его не перезаписывает (ваши схемы сохраняются).
При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения
(добавление новых id, обновление, удаление исчезнувших вроде bpmn).
Схемы с пустым или другим author не трогаются.
Если правите встроенную схему в GUI и сохраняете — author сбрасывается,
чтобы следующая миграция не затёрла ваши правки.
Оградка в markdown
```uml — обычный PlantUML (или ```plantuml)
```uml-c4 — схема с id «c4» (то же, что ```c4)
```uml-usecase — схема «usecase»
```bpmn / ```uml-bpmn — BPMN 2.0 (макросы в diagrams/BPMN.puml)
```uml-archimate — ArchiMate 3.2 (макросы Business_*, Rel_Serving, …)
```mermaid / ```mmd — Mermaid локально (браузер / Chromium / QuickJS) или через Kroki
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn.
```idef0 — IDEF0 (FIPS 183): боксы, стрелки ICOM, рамка NIST; локальный Pillow
```dfd / ```data-flow-diagram — DFD (pbauermeister/dfd): process/entity/store, --> ;
Graphviz (вшитый / «Скачать Graphviz» / PATH) или Kroki
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase.
Пример
@@ -171,18 +219,6 @@ System(app, "Портал")
Rel(user, app, "логин")
```
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw)
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
Поля схемы в JSON
title — подпись в GUI
version — версия схемы
@@ -207,27 +243,17 @@ URL из includes (и !include https://… внутри .puml) при перво
«Сбросить кэш includes» в Настройки → Диаграммы или в «Шаблоны UML» удаляет индекс
и только файлы, перечисленные в нём.
BPMN 2.0 (```bpmn)
Пул: Pool(alias, "Участник") { Lane(alias, "Роль") { … } }
События: Start, StartMessage, StartTimer, CatchMessage, ThrowMessage,
CatchTimer, CatchError, End, EndMessage, EndError, EndTerminate
Задачи: Task / UserTask / ServiceTask / ScriptTask / ManualTask /
SendTask / ReceiveTask / BusinessRuleTask / SubProcess
Шлюзы: XOR (Exclusive), AND (Parallel), OR (Inclusive), EventBased
Потоки: Flow, CondFlow(from, to, "условие"), DefaultFlow, MessageFlow
Данные: DataObject, DataStore, Annotation
Граница: BoundaryError/Timer/Message + Attach(task, event)
Sequence Flow только внутри пула; между пулами — MessageFlow.
Чего нет
IDEF0 / DFD — PlantUML не умеет; вставляйте готовый PNG как Рисунок.
У BPMN нет «прилипания» boundary-события к кромке задачи (ставьте Attach)
и нет настоящей двойной окружности у intermediate (толщина линии).
Балансировка родитель/потомок DFD между уровнями не проверяется автоматически.
Рендер
UML: Java + plantuml.jar (локально) → иначе локальный Kroki → иначе kroki.io.
Mermaid: системный Chrome/Edge → Playwright Chromium (кнопка / --install-chromium) →
QuickJS (mermaidx, офлайн) → локальный Kroki → kroki.io.
IDEF0: Pillow, без сети и Java.
DFD: data-flow-diagram → Graphviz (dot/neato) или Kroki graphviz.
Нужны: Graphviz (вшитый / «Скачать Graphviz» / PATH) либо Kroki;
пакет data-flow-diagram ставится с md2gost.
Формат: PNG по умолчанию (PlantUML / локальный Mermaid ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg — PNG + SVG (svgBlip в Word 2016+).
+landscape у %подписи — альбомная страница под широкий рисунок/таблицу.
"""
@@ -236,6 +262,7 @@ PROMPT_FILES = (
("generate-md.md", "Markdown для md2gost"),
("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"),
("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"),
("emulate-student.md", "Стиль: человечный текст, не отчёт ИИ"),
)
+2185
View File
File diff suppressed because it is too large Load Diff
+125 -15
View File
@@ -2,13 +2,22 @@
from __future__ import annotations
import os
import tempfile
import zipfile
from pathlib import Path
from docx.enum.section import WD_ORIENT
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
from docx.oxml.ns import qn
from docx.shared import Mm, Pt
from lxml import etree
from .util import create_element
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_W = f"{{{_W_NS}}}"
# GOST-like A4 margins (same as styles._apply_common_page_and_body)
MARGIN_LEFT = Mm(30)
MARGIN_RIGHT = Mm(10)
@@ -91,6 +100,10 @@ def apply_section_geometry(section, *, landscape: bool) -> None:
if landscape:
sect_pr.append(create_element("w:vAlign", {"w:val": "center"}))
# add_section copies template sectPr including w:pgNumType/@w:start — strip so
# landscape / return-to-portrait sections continue numbering instead of restarting.
clear_section_page_start(section)
def content_size(*, landscape: bool) -> tuple:
"""Return (max_height, max_width) usable content area for LayoutTracker."""
@@ -131,31 +144,31 @@ def clear_section_footer(section) -> None:
footer.add_paragraph()
def clear_section_page_start(section) -> None:
"""Remove w:pgNumType/@w:start so PAGE continues from the previous section."""
sect_pr = section._sectPr
for child in list(sect_pr):
if child.tag != qn("w:pgNumType"):
continue
if child.get(qn("w:start")) is not None:
del child.attrib[qn("w:start")]
# Empty pgNumType (no start/fmt) is useless — drop the element.
if len(child.attrib) == 0:
sect_pr.remove(child)
def ensure_continuous_page_numbers(document) -> None:
"""
After docxcompose of title/assignment: no PAGE on early sections,
continuous numbering (do not restart at 1 on body section).
"""
from docx.oxml.ns import qn
sections = list(document.sections)
if not sections:
return
# Heuristic: sections before the first that already has a PAGE field /
# or first N-1 if title was prepended — clear footer on all but keep
# continuous pgNumType. Body renderer already puts PAGE on body section;
# after compose, early sections may inherit footers — clear empty ones
# that belong to front matter (no Heading-like body content is hard to
# detect), so: clear footer on every section that has no PAGE field, and
# strip w:pgNumType start=1 everywhere.
# Strip w:pgNumType start everywhere so landscape / body sections stay continuous.
for section in sections:
sect_pr = section._sectPr
for child in list(sect_pr):
if child.tag == qn("w:pgNumType"):
# Keep continuous: remove start attribute if present
if child.get(qn("w:start")) is not None:
del child.attrib[qn("w:start")]
clear_section_page_start(section)
def set_section_page_start(section, start: int) -> None:
@@ -191,3 +204,100 @@ def apply_page_number_start(
if idx >= len(sections):
return
set_section_page_start(sections[idx], int(start))
def _iter_sect_pr(document_root) -> list:
"""Return sectPr elements in document order (inline + final body sectPr)."""
body = document_root.find(f"{_W}body")
if body is None:
return []
sects: list = []
for child in body:
tag = child.tag
if tag == f"{_W}sectPr":
sects.append(child)
continue
if tag != f"{_W}p":
continue
p_pr = child.find(f"{_W}pPr")
if p_pr is None:
continue
sect = p_pr.find(f"{_W}sectPr")
if sect is not None:
sects.append(sect)
return sects
def _strip_pg_num_start_xml(sect_pr) -> None:
for child in list(sect_pr):
if child.tag != f"{_W}pgNumType":
continue
if child.get(f"{_W}start") is not None:
del child.attrib[f"{_W}start"]
if len(child.attrib) == 0:
sect_pr.remove(child)
def _set_pg_num_start_xml(sect_pr, start: int) -> None:
pg = sect_pr.find(f"{_W}pgNumType")
if pg is None:
pg = etree.SubElement(sect_pr, f"{_W}pgNumType")
pg.set(f"{_W}start", str(int(start)))
def patch_docx_page_starts(
path: str | Path,
start: int | None = None,
*,
front_sections: int = 0,
) -> None:
"""
Strip (and optionally re-apply) w:pgNumType/@w:start inside word/document.xml.
Used after Word COM Save, which often rewrites start=\"1\" onto every section.
Zip/lxml only does not round-trip through python-docx (preserves
continuation captions and other Word-written parts).
"""
abs_path = os.path.abspath(str(path))
if not os.path.isfile(abs_path):
return
with zipfile.ZipFile(abs_path, "r") as zin:
names = zin.namelist()
if "word/document.xml" not in names:
return
doc_bytes = zin.read("word/document.xml")
other = {n: zin.read(n) for n in names if n != "word/document.xml"}
root = etree.fromstring(doc_bytes)
sects = _iter_sect_pr(root)
for sect in sects:
_strip_pg_num_start_xml(sect)
if start is not None and int(start) >= 1:
idx = max(0, int(front_sections))
if idx < len(sects):
_set_pg_num_start_xml(sects[idx], int(start))
new_doc = etree.tostring(
root,
xml_declaration=True,
encoding="UTF-8",
standalone=True,
)
parent = os.path.dirname(abs_path) or "."
fd, tmp_name = tempfile.mkstemp(suffix=".docx", dir=parent)
os.close(fd)
try:
with zipfile.ZipFile(tmp_name, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, data in other.items():
zout.writestr(name, data)
zout.writestr("word/document.xml", new_doc)
os.replace(tmp_name, abs_path)
except Exception:
try:
os.remove(tmp_name)
except OSError:
pass
raise
+144 -17
View File
@@ -9,7 +9,7 @@ import subprocess
import traceback
from dataclasses import dataclass
from datetime import datetime
from getpass import getuser
from pathlib import Path
from typing import Callable
from docx import Document
@@ -41,6 +41,10 @@ class ConvertRequest:
hr_pagebreak: bool = False
title: str | None = None
assignment: str | None = None
student: str | None = None
group: str | None = None
# Auto-build title from info_conv.yaml / ```title (off by default).
auto_title: bool = False
check: bool = False
check_only: bool = False
strict: bool = False
@@ -58,6 +62,15 @@ class ConvertRequest:
table_repeat_header: bool = False
# PAGE field start on body section; None = continuous from document start.
page_number_start: int | None = None
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
doc_author_source: str | None = None
doc_author: str | None = None
doc_last_modified_by: str | None = None
doc_title: str | None = None
doc_subject: str | None = None
doc_keywords: str | None = None
doc_comments: str | None = None
doc_category: str | None = None
@dataclass
@@ -127,16 +140,33 @@ def _apply_page_offset(document, req: ConvertRequest, *, had_title: bool, had_as
from .page_geometry import apply_page_number_start
start = getattr(req, "page_number_start", None)
start_i: int | None
if start is None:
return
try:
start_i = int(start)
except (TypeError, ValueError):
return
start_i = None
else:
try:
start_i = int(start)
except (TypeError, ValueError):
start_i = None
front = int(bool(had_title)) + int(bool(had_assignment))
# Always strip copied w:pgNumType/@w:start (landscape sections); optional body restart.
apply_page_number_start(document, start_i, front_sections=front)
def _page_offset_args(req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> tuple[int | None, int]:
"""Return (page_number_start, front_sections) for post-Word patch."""
start = getattr(req, "page_number_start", None)
if start is None:
start_i = None
else:
try:
start_i = int(start)
except (TypeError, ValueError):
start_i = None
front = int(bool(had_title)) + int(bool(had_assignment))
return start_i, front
def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
"""Run check and/or conversion. Does not re-raise; errors go into ConvertResult."""
emit: LogFn = log if log is not None else print
@@ -154,6 +184,24 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
emit(report)
return ConvertResult(True, 0, check_report=report, message=report)
# DOCX → Markdown (word2md) when input is .docx without --check-pages
if filename.lower().endswith(".docx"):
from word2md import ImportRequest, convert_docx
out = (req.output or "").strip() or None
if out and out.lower().endswith(".docx"):
out = str(Path(filename).with_suffix(".md"))
result = convert_docx(
ImportRequest(filename=filename, output=out),
log=emit,
)
return ConvertResult(
result.ok,
result.exit_code,
output_path=result.output_path,
message=result.message,
)
if not filename:
return fail(2, "Укажите исходный markdown-файл")
if not filename.lower().endswith(".md"):
@@ -192,10 +240,57 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
with open(filename, encoding="utf-8") as f:
md_text = f.read()
from .title_page import (
build_title_docx,
load_info_conv,
merge_title_meta,
parse_and_strip_title_fence,
should_generate_title,
)
from .user_profile import load_user_profile
fence_fields, body_md = parse_and_strip_title_fence(md_text)
yaml_fields = load_info_conv(md_dir)
generated_title_path: str | None = None
title_path = (req.title or "").strip() or None
if not output:
output = default_output_path(filename)
if should_generate_title(
explicit_title=title_path,
fence_fields=fence_fields,
md_dir=md_dir,
enabled=bool(req.auto_title),
):
profile = load_user_profile()
student = (req.student or "").strip() or profile.student
group = (req.group or "").strip() or profile.group
if not student or not group:
return fail(
2,
"Для генерации титула нужны ФИО и группа студента "
"(GUI: Настройки → Студент…, или CLI: --student / --group).",
)
meta = merge_title_meta(
yaml_fields=yaml_fields,
fence_fields=fence_fields,
student=student,
group=group,
)
out_dir = os.path.dirname(os.path.abspath(output)) or md_dir
generated_title_path = os.path.join(out_dir, ".md2gost_title.docx")
try:
build_title_docx(meta, generated_title_path)
title_path = generated_title_path
emit(f"Титульный лист: {generated_title_path}")
except Exception as exc:
return fail(1, f"Не удалось сгенерировать титул: {exc}")
check_report = ""
if req.check or req.check_only:
issues = check_markdown(
md_text,
body_md,
req.doc_type,
table_continuation=req.table_continuation,
listing_continuation=req.listing_continuation,
@@ -211,9 +306,6 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
message=check_report,
)
if not output:
output = default_output_path(filename)
template = (req.template or "").strip() or default_template_path()
handler = _CallbackLogHandler(emit)
@@ -236,11 +328,12 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
listing_continuation=req.listing_continuation,
hr_pagebreak=req.hr_pagebreak,
styles_path=req.styles_path or None,
markdown_text=body_md,
)
converter.convert()
document = converter.document
if req.title or req.assignment:
if title_path or req.assignment:
try:
from docxcompose.composer import Composer
except ImportError:
@@ -256,8 +349,8 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
body.remove(child)
composer = Composer(shell)
if req.title:
composer.append(Document(req.title))
if title_path:
composer.append(Document(title_path))
shell.add_page_break()
if req.assignment:
composer.append(Document(req.assignment))
@@ -267,19 +360,36 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
apply_style_config(document, style_cfg)
_fix_front_matter_after_compose(
document,
had_title=bool(req.title),
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
_apply_page_offset(
document,
req,
had_title=bool(req.title),
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
document.core_properties.author = getuser()
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
from .doc_metadata import apply_document_metadata, resolve_document_metadata
from .user_profile import load_user_profile as _load_user_profile
profile = _load_user_profile()
apply_document_metadata(
document,
resolve_document_metadata(
stored=profile.metadata,
student=(req.student or "").strip() or profile.student,
author_source=req.doc_author_source,
author=req.doc_author,
last_modified_by=req.doc_last_modified_by,
title=req.doc_title,
subject=req.doc_subject,
keywords=req.doc_keywords,
comments=req.doc_comments,
category=req.doc_category,
),
)
try:
document.save(output)
except PermissionError:
@@ -297,12 +407,19 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
root_log.removeHandler(handler)
attached_diag.removeHandler(handler)
attached_diag.setLevel(prev_diag_level)
if generated_title_path and os.path.isfile(generated_title_path):
try:
os.remove(generated_title_path)
except OSError:
pass
abs_out = os.path.abspath(output)
emit(f"Generated document: {abs_out}")
if req.table_continuation == "word" or req.listing_continuation == "word":
from .word_fix import fix_continuations
from .page_geometry import patch_docx_page_starts
fix = fix_continuations(
abs_out,
tables=(req.table_continuation == "word"),
@@ -317,6 +434,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
check_report = (
(check_report + "\n" + fix.message).strip() if check_report else fix.message
)
# Word Save often rewrites w:pgNumType/@w:start=\"1\" on every section — strip again.
start_i, front = _page_offset_args(
req,
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
try:
patch_docx_page_starts(abs_out, start_i, front_sections=front)
except Exception as exc:
emit(f"Не удалось восстановить сквозную нумерацию после Word: {exc}")
if req.check_pages:
from .page_fill_check import check_docx_page_fill, format_page_fill_report
+1 -1
View File
@@ -99,7 +99,7 @@ def get_profile(doc_type: str) -> DocProfile:
# --- text autofixes ---
# ```uml / ```mermaid / ```uml-c4 / ```bpmn … — кавычки и тире там синтаксис
# ```uml / ```mermaid / ```uml-c4 … — кавычки и тире там синтаксис
_FENCE_RE = re.compile(
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n"
r"(?P<body>[\s\S]*?)"
+5 -3
View File
@@ -293,7 +293,9 @@ class Paragraph(Renderable):
yield SubRenderable(image, False)
layout_state.add_height(rendered_image_height)
else:
# Defer this image and any remaining siblings to the next page
# (as SubRenderable — bare Image objects break the renderer).
yield SubRenderable(image, True)
break
yield from images
for rest in images:
yield SubRenderable(rest, True)
return
+93 -11
View File
@@ -113,6 +113,10 @@ class Table(Renderable, RequiresNumbering):
cell_height = 0
if not (apply_merge and merge_v == "continue"):
for paragraph in row[col]:
# Method guide §3: do not leave caption + column headers alone
# at page bottom — glue header row to the following data row.
if row_idx == 0:
paragraph._docx_paragraph.paragraph_format.keep_with_next = True
cell_layout_state = LayoutState(
layout_state.max_height, layout_state.max_width
)
@@ -133,6 +137,62 @@ class Table(Renderable, RequiresNumbering):
)
return docx_row, row_height
def _peek_row_height(self, row_idx: int, layout_state: LayoutState) -> Length:
"""Estimate row height without attaching cell paragraphs to a table."""
from .paragraph_sizer import ParagraphSizer
row = self._rows[row_idx]
merge_row = self._merge[row_idx]
row_height = 0
col_w = self._col_width(layout_state)
col = 0
while col < self._cols:
merge_v, merge_h = merge_row[col]
if merge_h == "continue":
col += 1
continue
span = self._grid_span(merge_row, col)
if span < 1:
span = 1
cell_height = 0
if merge_v != "continue":
max_w = col_w * span - CELL_OFFSET
for paragraph in row[col]:
cell_height += ParagraphSizer(
paragraph._docx_paragraph, None, max_w
).calculate_height().full
row_height = max(cell_height, row_height)
col += span
row_height += Pt(0.5) + (
ROW_HEIGHT_SLACK if self._continuation_mode not in _WORD_PAGED_MODES else Pt(0)
)
return row_height
def _start_block_height(
self, previous_rendered: RenderedInfo | None, layout_state: LayoutState
) -> Length:
"""Caption + header + first data row (method: no orphan title/header)."""
from docx.text.paragraph import Paragraph as DocxParagraph
from .paragraph_sizer import ParagraphSizer
# Dry-run caption sizing (temporary Caption; not yielded into the body).
cap = Caption(
self._parent, "Таблица", self._caption_info, self._number, True
)
prev_p = (
previous_rendered.docx_element
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph)
else None
)
total = ParagraphSizer(
cap._docx_paragraph, prev_p, layout_state.max_width
).calculate_height().full
if self._rows:
total += self._peek_row_height(0, layout_state)
if len(self._rows) > 1:
total += self._peek_row_height(1, layout_state)
return total
def _should_split_fragment(
self, row_height: Length, layout_state: LayoutState, rows_in_fragment: int
) -> bool:
@@ -169,10 +229,29 @@ class Table(Renderable, RequiresNumbering):
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
-> Generator[RenderedInfo, None, None]:
# Method guide: never leave only «Таблица N» or «Таблица N» + column
# headers at the bottom of a page. If caption+header+first data row do
# not fit, push the whole block to the next page (layout leftover;
# Word also gets keep_with_next on caption/header).
leftover = Pt(0)
start_h = self._start_block_height(previous_rendered, layout_state)
if (
layout_state.current_page_height > 0
and start_h > layout_state.remaining_page_height
):
leftover = layout_state.remaining_page_height
layout_state.add_height(leftover)
previous_rendered = None
caption_rendered_infos = list(
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
.render(previous_rendered, copy(layout_state))
)
if leftover and caption_rendered_infos:
first = caption_rendered_infos[0]
caption_rendered_infos[0] = RenderedInfo(
first.docx_element, first.height + leftover
)
layout_state.add_height(sum(info.height for info in caption_rendered_infos))
yield from caption_rendered_infos
@@ -190,19 +269,22 @@ class Table(Renderable, RequiresNumbering):
)
if self._should_split_fragment(row_height, layout_state, rows_in_fragment):
yield RenderedInfo(docx_table, table_height)
yield from self._emit_page_break_and_optional_caption(layout_state)
# Never cut after the header alone (method: no title+column headers
# without a data row). Prefer slight overflow over an orphan fragment.
if rows_in_fragment >= 2:
yield RenderedInfo(docx_table, table_height)
yield from self._emit_page_break_and_optional_caption(layout_state)
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
apply_merge = False
rows_in_fragment = 0
table_height = Pt(0.5)
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
apply_merge = False
rows_in_fragment = 0
table_height = Pt(0.5)
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
docx_table._element.append(docx_row._element)
layout_state.add_height(row_height)
+3
View File
@@ -122,6 +122,9 @@ class Renderer:
def process(self, renderables: list[Renderable]):
items = self._ensure_appendix_index(list(renderables))
for i in range(len(items)):
# Figures that did not fit the previous page must land before the
# next top-level block (not after a later table / end of doc).
self._flush_to_new_screen()
self.render(items[i])
self._flush_to_new_screen()
+323
View File
@@ -0,0 +1,323 @@
"""Generate MIREA practice title page from TitleTemplate.docx + meta sources.
Red runs (w:color C00000/FF0000) in the bundled template are placeholders.
Sources (later wins for academic fields): info_conv.yaml < ```title fence.
Student ФИО/группа come from user profile / CLI, not from yaml.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
from dataclasses import dataclass, fields
from datetime import datetime
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import RGBColor
from . import package_dir
_log = logging.getLogger(__name__)
INFO_CONV_FILENAME = "info_conv.yaml"
TITLE_FENCE_RE = re.compile(
r"^```title[ \t]*\r?\n(.*?)(?:\r?\n)```[ \t]*(?:\r?\n|$)",
re.DOTALL | re.IGNORECASE | re.MULTILINE,
)
KV_LINE_RE = re.compile(r"^([^\s:#][^:#]*?)\s*:\s*(.*)$")
# Canonical field → accepted key aliases (lowercased for match)
_KEY_ALIASES: dict[str, tuple[str, ...]] = {
"institute": (
"institute",
"институт",
"инститиут",
),
"department": (
"department",
"кафедра",
),
"discipline": (
"discipline",
"дисциплина",
),
"teacher": (
"teacher",
"преподаватель",
"преподователь",
),
"teacher_position": (
"teacher_position",
"teacherposition",
"должность",
"должность преподавателя",
),
"number": ("number", "номер", "", "n"),
"year": ("year", "год"),
"student": ("student", "фио", "студент"),
"group": ("group", "группа"),
}
# Paragraph indices in bundled TitleTemplate.docx (do not reorder template).
# P0 institute, P1 department, P5 discipline (red inside guillemets),
# P8 number, P13 group, P15 student, P19 teacher_position, P21 teacher, P28 year.
_SLOT_PARAS: dict[str, int] = {
"institute": 0,
"department": 1,
"discipline": 5,
"number": 8,
"group": 13,
"student": 15,
"teacher_position": 19,
"teacher": 21,
"year": 28,
}
_RED_VALS = frozenset({"FF0000", "C00000", "FF0", "F00"})
@dataclass
class TitleMeta:
institute: str = ""
department: str = ""
discipline: str = ""
number: str = ""
group: str = ""
student: str = ""
teacher_position: str = ""
teacher: str = ""
year: str = ""
def academic_filled(self) -> bool:
return any(
[
self.institute,
self.department,
self.discipline,
self.teacher,
self.teacher_position,
self.number,
self.year,
]
)
def default_title_template_path() -> str:
return os.path.join(package_dir(), "TitleTemplate.docx")
def _norm_key(key: str) -> str:
return re.sub(r"\s+", " ", (key or "").strip().lower().replace("ё", "е"))
def _alias_to_field(key: str) -> str | None:
nk = _norm_key(key)
for field_name, aliases in _KEY_ALIASES.items():
if nk in aliases:
return field_name
return None
def parse_kv_text(text: str) -> dict[str, str]:
"""Parse flat `key: value` lines (title fence / info_conv.yaml)."""
out: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line in ("---", "..."):
continue
m = KV_LINE_RE.match(line)
if not m:
continue
field_name = _alias_to_field(m.group(1))
if not field_name:
continue
value = m.group(2).strip().strip("\"'")
# strip inline comment
if " #" in value:
value = value.split(" #", 1)[0].rstrip()
if value:
out[field_name] = value
return out
def parse_and_strip_title_fence(md: str) -> tuple[dict[str, str], str]:
"""Return (fields from first ```title fence, markdown without that fence)."""
m = TITLE_FENCE_RE.search(md or "")
if not m:
return {}, md or ""
fields_map = parse_kv_text(m.group(1))
body = (md or "")[: m.start()] + (md or "")[m.end() :]
# collapse leading blank lines left by stripping
body = re.sub(r"^\s*\n", "", body, count=1)
return fields_map, body
def load_info_conv(md_dir: str | Path) -> dict[str, str]:
path = Path(md_dir) / INFO_CONV_FILENAME
if not path.is_file():
return {}
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
_log.warning("Не удалось прочитать %s: %s", path, exc)
return {}
return parse_kv_text(text)
def info_conv_exists(md_dir: str | Path) -> bool:
return (Path(md_dir) / INFO_CONV_FILENAME).is_file()
def merge_title_meta(
*,
yaml_fields: dict[str, str] | None = None,
fence_fields: dict[str, str] | None = None,
student: str = "",
group: str = "",
) -> TitleMeta:
"""Fence overrides yaml for academic fields; student/group from args."""
merged: dict[str, str] = {}
for src in (yaml_fields or {}, fence_fields or {}):
for k, v in src.items():
if k in ("student", "group"):
continue # never from yaml/fence for title identity
if v:
merged[k] = v
meta = TitleMeta(**{f.name: merged.get(f.name, "") for f in fields(TitleMeta)})
meta.student = (student or "").strip()
meta.group = (group or "").strip()
if not meta.year:
meta.year = str(datetime.now().year)
if not meta.number:
meta.number = "1"
return meta
def _run_color_val(run) -> str | None:
rPr = run._element.find(qn("w:rPr"))
if rPr is None:
return None
c = rPr.find(qn("w:color"))
if c is None:
return None
return (c.get(qn("w:val")) or "").upper() or None
def _is_red_run(run) -> bool:
val = _run_color_val(run)
if not val:
return False
if val in _RED_VALS:
return True
# theme-ish: FF???? with green/blue near zero
if len(val) == 6 and val.startswith("FF") and val[2:4] == "00" and val[4:6] == "00":
return True
if len(val) == 6 and val.startswith("C0") and val[2:] == "0000":
return True
return val == "C00000"
def _clear_run_color(run) -> None:
rPr = run._element.find(qn("w:rPr"))
if rPr is None:
return
c = rPr.find(qn("w:color"))
if c is not None:
rPr.remove(c)
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
def _replace_red_in_paragraph(paragraph, value: str) -> bool:
"""Replace contiguous red runs' text with ``value``; clear red color."""
runs = list(paragraph.runs)
red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r) and (r.text or "")]
if not red_idxs:
# also consider empty/tab-only already captured
red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r)]
if not red_idxs:
return False
first = red_idxs[0]
runs[first].text = value
_clear_run_color(runs[first])
for i in red_idxs[1:]:
runs[i].text = ""
_clear_run_color(runs[i])
return True
def fill_title_document(doc: Document, meta: TitleMeta) -> None:
"""Fill red placeholder slots in an opened title template document."""
paras = doc.paragraphs
mapping = {
"institute": meta.institute,
"department": meta.department,
"discipline": meta.discipline,
"number": meta.number,
"group": meta.group,
"student": meta.student,
"teacher_position": meta.teacher_position,
"teacher": meta.teacher,
"year": meta.year,
}
for field_name, idx in _SLOT_PARAS.items():
value = (mapping.get(field_name) or "").strip()
if not value:
continue
if idx >= len(paras):
_log.warning("TitleTemplate: нет абзаца %s для поля %s", idx, field_name)
continue
if not _replace_red_in_paragraph(paras[idx], value):
# Fallback: whole paragraph is placeholder (student name)
if field_name in ("student", "institute", "department") and paras[idx].runs:
paras[idx].runs[0].text = value
for r in paras[idx].runs[1:]:
r.text = ""
_clear_run_color(paras[idx].runs[0])
else:
_log.warning(
"TitleTemplate: не найдены красные runs в P%s (%s)", idx, field_name
)
def build_title_docx(
meta: TitleMeta,
out_path: str | Path,
*,
template_path: str | Path | None = None,
) -> Path:
"""Copy template, fill slots, save to out_path. Returns out_path."""
src = Path(template_path or default_title_template_path())
if not src.is_file():
raise FileNotFoundError(f"Шаблон титульника не найден: {src}")
dest = Path(out_path)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
doc = Document(str(dest))
fill_title_document(doc, meta)
doc.save(str(dest))
return dest
def should_generate_title(
*,
explicit_title: str | None,
fence_fields: dict[str, str],
md_dir: str | Path,
enabled: bool = False,
) -> bool:
if not enabled:
return False
if explicit_title and str(explicit_title).strip():
return False
if fence_fields:
return True
return info_conv_exists(md_dir)
+60
View File
@@ -0,0 +1,60 @@
"""Persistent student profile (ФИО + группа) and DOCX metadata defaults."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .diagram_includes import app_dir
from .doc_metadata import DocumentMetadata
_log = logging.getLogger(__name__)
USER_PROFILE_FILENAME = "md2gost.user.json"
@dataclass
class UserProfile:
student: str = ""
group: str = ""
metadata: DocumentMetadata = field(default_factory=DocumentMetadata)
def is_complete(self) -> bool:
return bool(self.student.strip() and self.group.strip())
def user_profile_path(base: Path | None = None) -> Path:
return (base or app_dir()) / USER_PROFILE_FILENAME
def load_user_profile(base: Path | None = None) -> UserProfile:
path = user_profile_path(base)
if not path.is_file():
return UserProfile()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
_log.warning("Не удалось прочитать %s: %s", path, exc)
return UserProfile()
if not isinstance(raw, dict):
return UserProfile()
return UserProfile(
student=str(raw.get("student") or "").strip(),
group=str(raw.get("group") or "").strip(),
metadata=DocumentMetadata.from_dict(raw.get("metadata")),
)
def save_user_profile(profile: UserProfile, base: Path | None = None) -> Path:
path = user_profile_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
meta = profile.metadata or DocumentMetadata()
data = {
"student": (profile.student or "").strip(),
"group": (profile.group or "").strip(),
"metadata": meta.to_dict(),
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
+396 -15
View File
@@ -81,6 +81,24 @@ def find_page_break_row(page_numbers: list[int]) -> int | None:
return None
def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
"""True if a Word split would leave only the heading row on the previous page.
Method guide §3: do not leave «Таблица N» + column headers alone at page end.
"""
return bool(had_header and break_at == 2)
def is_caption_orphaned(caption_page: int | None, first_row_page: int | None) -> bool:
"""True if «Таблица N — …» sits on an earlier page than the table body.
Method guide: do not leave only the table title at the bottom of a page.
"""
if caption_page is None or first_row_page is None:
return False
return caption_page < first_row_page
def continuation_label(kind: str, number: str) -> str:
if kind == "listing":
return f"Продолжение Листинга {number}"
@@ -146,6 +164,11 @@ def fix_continuations(
splits += made
skipped = sum(1 for d in details if d.startswith("skip:"))
_clear_section_page_restarts(doc)
try:
doc.Fields.Update()
except Exception as exc:
details.append(f"skip: fields update: {exc}")
doc.Save()
msg = (
f"Пост-разрыв Word: разрезов {splits}"
@@ -175,25 +198,260 @@ def fix_continuations(
pass
def _clear_section_page_restarts(doc) -> None:
"""Turn off page-number restart on every section (continuous PAGE through landscape)."""
# wdHeaderFooterPrimary = 1, wdHeaderFooterFirstPage = 2, wdHeaderFooterEvenPages = 3
for i in range(1, int(doc.Sections.Count) + 1):
sec = doc.Sections(i)
for hf_kind in (1, 2, 3):
for getter in (sec.Headers, sec.Footers):
try:
hf = getter(hf_kind)
except Exception:
continue
try:
pns = hf.PageNumbers
except Exception:
continue
try:
if int(pns.Count) < 1:
continue
except Exception:
continue
try:
pns.RestartNumberingAtSection = False
except Exception:
pass
def _row_page_numbers(table) -> list[int]:
"""Page number at the start of each row (more reliable than end-of-range)."""
"""Page number at the start of each row (more reliable than end-of-range).
Tables with vertical merges (`^` in md2gost) often raise on ``Rows(i).Range``;
fall back to ``Cell(i, j).Range`` per column.
"""
pages: list[int] = []
n = int(table.Rows.Count)
for i in range(1, n + 1):
try:
rng = table.Rows(i).Range
# Collapse to start so a tall row reports where it begins
start = int(rng.Start)
page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
try:
page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
page = pages[-1] if pages else 1
page = _page_at_row(table, i)
if page is None:
page = pages[-1] if pages else 1
pages.append(page)
return pages
def _page_at_row(table, row_1based: int) -> int | None:
"""Best-effort page number for a table row (handles vertical merges)."""
# Prefer Cells — works when Rows(i) is blocked by vMerge.
for j in range(1, 32):
try:
start = int(table.Cell(row_1based, j).Range.Start)
return int(
table.Range.Document.Range(start, start).Information(
_WD_ACTIVE_END_PAGE_NUMBER
)
)
except Exception:
# Missing cell in this column (merge continue) or past last column
if j == 1:
continue
# After first column, consecutive failures → stop scanning columns
break
try:
rng = table.Rows(row_1based).Range
start = int(rng.Start)
return int(
rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER)
)
except Exception:
try:
return int(table.Rows(row_1based).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
return None
def _probe_ncols(table) -> int:
"""Max column index reachable from row 1 (and a few other rows)."""
ncols = 1
for row in (1, 2, 3):
for probe in range(1, 64):
try:
table.Cell(row, probe)
ncols = max(ncols, probe)
except Exception:
break
return max(ncols, 1)
def _rows_individually_accessible(table) -> bool:
"""False when Word blocks ``Rows(i)`` due to vertical merges."""
try:
n = int(table.Rows.Count)
if n < 1:
return True
_ = table.Rows(1).Range
if n > 1:
_ = table.Rows(n).Range
return True
except Exception:
return False
def _capture_vmerge_spans(table) -> list[tuple[int, int, int, str]]:
"""Return ``(row, col, span, text)`` for each vertical merge (1-based, span>1)."""
try:
nrows = int(table.Rows.Count)
except Exception:
return []
ncols = _probe_ncols(table)
spans: list[tuple[int, int, int, str]] = []
r = 1
while r <= nrows:
for c in range(1, ncols + 1):
try:
cell = table.Cell(r, c)
except Exception:
continue
span = 1
while r + span <= nrows:
try:
table.Cell(r + span, c)
break
except Exception:
row_exists = False
for cc in range(1, ncols + 1):
try:
table.Cell(r + span, cc)
row_exists = True
break
except Exception:
pass
if not row_exists:
break
span += 1
if span > 1:
spans.append((r, c, span, _cell_plain_text(cell)))
r += 1
return spans
def split_vmerge_spans_at_break(
spans: list[tuple[int, int, int, str]],
break_at: int,
) -> tuple[list[tuple[int, int, int, str]], list[tuple[int, int, int, str]]]:
"""Split captured merges across a 1-based ``Table.Split`` row index."""
first: list[tuple[int, int, int, str]] = []
cont: list[tuple[int, int, int, str]] = []
for r, c, span, text in spans:
if span <= 1:
continue
end = r + span - 1
if end < break_at:
first.append((r, c, span, text))
elif r >= break_at:
cont.append((r - break_at + 1, c, span, text))
else:
top = break_at - r
if top > 1:
first.append((r, c, top, text))
bottom = end - break_at + 1
if bottom >= 1:
cont.append((1, c, bottom, text))
return first, cont
def _set_cell_plain_text(cell, text: str) -> None:
"""Write plain text into a cell without eating the end-of-cell mark badly."""
raw = (text or "").replace("\r", "").replace("\x07", "").replace("\a", "")
try:
rng = cell.Range.Duplicate
# Exclude the cell's terminal marker characters
if int(rng.End) > int(rng.Start) + 1:
rng.End = int(rng.End) - 1
rng.Text = raw
except Exception:
try:
cell.Range.Text = raw
except Exception:
pass
def _restore_vmerge_spans(table, spans: list[tuple[int, int, int, str]]) -> int:
"""Re-apply vertical merges after unmerge+Split. Returns merges restored."""
made = 0
for r, c, span, text in spans:
if span <= 1:
continue
try:
nrows = int(table.Rows.Count)
if r < 1 or r > nrows or r + span - 1 > nrows:
continue
start = table.Cell(r, c)
if text and not _cell_plain_text(start):
_set_cell_plain_text(start, text)
if span == 1:
made += 1
continue
end = table.Cell(r + span - 1, c)
start.Merge(end)
made += 1
except Exception:
continue
return made
def _unmerge_vertical_cells(table) -> int:
"""Split vertically merged cells so ``Table.Split`` can run.
Returns number of cell-groups unmerged. Safe no-op if nothing to do.
Prefer capturing spans with ``_capture_vmerge_spans`` first and restoring
after the table split.
"""
try:
nrows = int(table.Rows.Count)
except Exception:
return 0
ncols = _probe_ncols(table)
made = 0
r = 1
while r <= nrows:
for c in range(1, ncols + 1):
try:
cell = table.Cell(r, c)
except Exception:
continue
span = 1
while r + span <= nrows:
try:
table.Cell(r + span, c)
break
except Exception:
row_exists = False
for cc in range(1, ncols + 1):
try:
table.Cell(r + span, cc)
row_exists = True
break
except Exception:
pass
if not row_exists:
break
span += 1
if span > 1:
try:
cell.Split(span, 1)
made += 1
except Exception:
pass
r += 1
try:
nrows = int(table.Rows.Count)
except Exception:
break
return made
def _paragraph_page_numbers_in_cell(table) -> list[int]:
"""Fallback: page of each paragraph in a single-cell table (legacy 1-row listings)."""
try:
@@ -245,6 +503,25 @@ def _expand_single_cell_listing_to_rows(table) -> bool:
return False
def _paragraph_page(paragraph) -> int | None:
try:
start = int(paragraph.Range.Start)
return int(
paragraph.Range.Document.Range(start, start).Information(
_WD_ACTIVE_END_PAGE_NUMBER
)
)
except Exception:
return None
def _caption_page_before_table(table) -> int | None:
p = _paragraph_before_table(table)
if p is None:
return None
return _paragraph_page(p)
def _fix_pass(
doc,
*,
@@ -285,18 +562,81 @@ def _fix_pass(
doc.Repaginate()
pages = _row_page_numbers(table)
# Method: only the title («Таблица N») at page bottom is forbidden.
if caption.kind == "table" and pages:
if is_caption_orphaned(_caption_page_before_table(table), pages[0]):
try:
if _page_break_before_table_caption(table):
details.append(
f"orphan-caption: {caption.kind} {caption.number} "
f"→ page break before caption"
)
doc.Repaginate()
else:
details.append(
f"skip: orphan-caption {caption.kind} {caption.number}: "
f"no caption para"
)
except Exception as exc:
details.append(f"skip: orphan-caption page break: {exc}")
continue
break_at = find_page_break_row(pages)
if break_at is None:
continue
if break_at > int(table.Rows.Count):
continue
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
vmerge_spans: list[tuple[int, int, int, str]] = []
if not _rows_individually_accessible(table):
try:
vmerge_spans = _capture_vmerge_spans(table)
n_un = _unmerge_vertical_cells(table)
if n_un:
details.append(
f"unmerge: {caption.kind} {caption.number} ({n_un} groups)"
)
doc.Repaginate()
table = doc.Tables(ti)
pages = _row_page_numbers(table)
break_at = find_page_break_row(pages)
if break_at is None:
details.append(
f"skip: {caption.kind} {caption.number} "
f"no page break after unmerge"
)
continue
if break_at > int(table.Rows.Count):
continue
except Exception as exc:
details.append(f"skip: unmerge {caption.kind} {caption.number}: {exc}")
continue
had_header = False
try:
had_header = bool(table.Rows(1).HeadingFormat)
except Exception:
had_header = False
# Method guide: caption + column headers alone on a page is forbidden.
# If Word broke right after the heading row, push the table (caption) to
# the next page instead of splitting into a header-only fragment.
if is_orphan_header_break(break_at, had_header=had_header):
try:
if _page_break_before_table_caption(table):
details.append(
f"orphan-header: {caption.kind} {caption.number} → page break before caption"
)
doc.Repaginate()
else:
details.append(
f"skip: orphan-header {caption.kind} {caption.number}: no caption para"
)
except Exception as exc:
details.append(f"skip: orphan-header page break: {exc}")
continue
try:
table.Split(break_at)
except Exception as exc:
@@ -304,12 +644,28 @@ def _fix_pass(
continue
try:
table = doc.Tables(ti)
cont = doc.Tables(ti + 1)
except Exception as exc:
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
made += 1
continue
if vmerge_spans:
try:
first_spans, cont_spans = split_vmerge_spans_at_break(
vmerge_spans, break_at
)
n1 = _restore_vmerge_spans(table, first_spans)
n2 = _restore_vmerge_spans(cont, cont_spans)
if n1 or n2:
details.append(
f"remerge: {caption.kind} {caption.number} "
f"(first={n1}, cont={n2})"
)
except Exception as exc:
details.append(f"skip: remerge {caption.kind} {caption.number}: {exc}")
before_cont = _paragraph_text_before_table(cont)
first_line = before_cont.strip().split("\n", 1)[0] if before_cont else ""
if not (first_line and _CONTINUATION_RE.match(first_line)):
@@ -332,7 +688,7 @@ def _fix_pass(
except Exception as exc:
details.append(f"skip: listing borders: {exc}")
# Header repeat is opt-in (default off)
# Header repeat is opt-in (default off) — not Word's native tblHeader paint
if repeat_header and had_header and caption.kind == "table":
try:
_ensure_header_on_continuation(first_table=table, cont_table=cont)
@@ -351,19 +707,44 @@ def _caption_before_table(table) -> CaptionInfo | None:
return parse_caption_text(text)
def _paragraph_text_before_table(table) -> str:
def _paragraph_before_table(table):
"""Return the Word Paragraph immediately before ``table``, or None."""
try:
rng = table.Range
if rng.Start <= 1:
return ""
return None
doc = table.Range.Document
prev = doc.Range(rng.Start - 1, rng.Start)
p = prev.Paragraphs(1)
return prev.Paragraphs(1)
except Exception:
return None
def _paragraph_text_before_table(table) -> str:
try:
p = _paragraph_before_table(table)
if p is None:
return ""
return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
except Exception:
return ""
def _page_break_before_table_caption(table) -> bool:
"""Set PageBreakBefore on the caption paragraph above the table."""
p = _paragraph_before_table(table)
if p is None:
return False
info = parse_caption_text(
(p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
)
if info is None or info.is_continuation:
return False
p.Format.PageBreakBefore = True
p.Format.KeepWithNext = True
return True
def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None:
"""Insert caption paragraph *outside* the table (before its start).
+2 -1
View File
@@ -7,12 +7,13 @@
| [`generate-md.md`](generate-md.md) | Диалект md2gost: отличия от обычного MD, диаграммы конвертируются сами |
| [`generate-mirea-report.md`](generate-mirea-report.md) | Полный системный промпт (ГОСТ / МИРЭА) |
| [`generate-pis-custom-report.md`](generate-pis-custom-report.md) | Итоговый отчёт ПИС |
| [`emulate-student.md`](emulate-student.md) | Голос: человечный учебный текст, не отчёт ИИ (копировать **после** generate-*) |
В GUI (**Справка → Промпт для ИИ**) можно дописать к любому промпту выбранные UML-схемы из `md2gost.schemes.json`.
### Как использовать
1. Открой файл или GUI, скопируй промпт (в GUI — после выбора схем).
1. Открой файл или GUI, скопируй промпт (в GUI — после выбора схем). Для живого тона добавь [`emulate-student.md`](emulate-student.md) после базового generate-*.
2. Вставь в:
- **ChatGPT / Claude** — Custom instructions / первое сообщение;
- **Cursor** — Rules for AI / сообщение агенту;
+57
View File
@@ -0,0 +1,57 @@
# Промпт: человечный учебный текст (не отчёт ИИ)
Скопируйте **весь** текст ниже **после** базового промпта md2gost (`generate-md` / `generate-mirea-report` / `generate-pis-custom-report`) или вторым сообщением.
Тема, объект, факты — из следующего сообщения пользователя. Этот блок **не привязан** к конкретному предмету: он задаёт только голос.
---
Ты пишешь учебный текст так, чтобы он читался как работа студента-человека, а не как отчёт модели о выполненных командах.
## Главное
Пиши **про предмет работы**. Не пиши про то, что задание сделано, пункт закрыт или следующий этап курса уже «зафиксирован».
Читатель должен узнать объект / тему. Он не должен видеть пайплайн промпта, чеклист ТЗ и разговор с невидимым проверяющим.
## Как строить кусок текста
1. Тезис по сути.
2. Опора: таблица, рисунок, число, факт.
3. Короткий вывод **про сам предмет** (13 предложения).
Не начинай раздел преамбулой «зачем этот блок существует». Запрещены обороты вроде: «X является исходным условием…», «без X невозможны…», «что позволяет связать предмет…», «а не подменять…». Сразу давай содержание.
Факт имеет право просто быть. Не пристёгивай каждое предложение к следующей главе, «предмету исследования», автоматизации, пользе для проверяющего или «прикладной ценности полученной характеристики» — если пользователь сам не сделал это темой.
## Введение и заключение
**Введение:** цель → объект / тема → почему взято → методы. Без оглавления остальных работ курса. Без фраз вроде «роботизация закрепляется за…», «предметом последующих работ принимается…», «целевая архитектура должна…».
**Заключение:** сожми предмет — что это, как устроено, что важно.
Запрещены глаголы сдачи работы: «сформирована характеристика», «установлены», «определён», «построена», «зафиксирован предмет».
Запрещён план следующих лаб вместо вывода по теме.
## Таблицы
Таблица описывает предмет. Последняя колонка — смысл в теме (содержание, влияние, назначение в деятельности).
Не делай колонки: «значение для объекта исследования», «роль в предмете исследования», «назначение для характеристики», «как пригодится дальше / для автоматизации».
## Тон и язык
- Безличность нормальна: «выбрано», «представлены», «систематизированы». Без «я» / «мы».
- Ссылки по делу, не в каждом абзаце. Методички «как писать работу» в содержательную часть не тащи.
- Допустима лёгкая неровность: не каждый абзац одной длины, не каждое предложение «отрабатывает ценность».
- Не заканчивай абзацы мета-моралью: «следовательно, целевое решение должно…», «полученная характеристика задаёт основу…», «прикладное значение работы состоит в…» — если это не запрошенная тема.
## Чего избегать (запахи отчёта ИИ)
- Текст отвечает на вопрос «что я сделал по пунктам», а не «что это за объект / тема».
- Каждый абзац продаёт себя проверяющему.
- Идеальная симметрия, плотный ряд умных ярлыков подряд, консультантский лоск без живого факта.
- Спор с невидимым рецензентом внутри основного текста.
- Комментарии к собственному методу вместо содержания.
## Выход
Соблюдай формат и диалект из базового промпта md2gost. Этот блок меняет только **голос и логику изложения**, не синтаксис разметки.
+47 -5
View File
@@ -78,19 +78,61 @@ md2gost читает расширения ниже, нумерует объек
**Не** экспортируй UML/Mermaid в PNG вручную и **не** пиши «вставь картинку сюда».
В `.md` оставь fenced-блок — md2gost превратит его в **Рисунок** (PlantUML jar / Kroki; Mermaid — через Kroki).
В `.md` оставь fenced-блок — md2gost превратит его в **Рисунок** (PlantUML jar / Kroki; Mermaid — локально или Kroki; IDEF0 — Pillow; DFD — data-flow-diagram + Graphviz/Kroki).
| Оградка | Содержание тела |
|---------|-----------------|
| `` ```uml `` / `` ```plantuml `` | Полный PlantUML, обычно с `@startuml`…`@enduml` |
| `` ```uml-<id> `` / `` ```<id> `` | Только макросы схемы (C4, BPMN, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама |
| `` ```uml-<id> `` / `` ```<id> `` | Только макросы схемы (C4, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама |
| `` ```mermaid `` / `` ```mmd `` | Синтаксис Mermaid |
| `` ```idef0 `` | IDEF0: боксы, ICOM-стрелки, рамка A-0 |
| `` ```dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): process/entity/store, `-->` |
Перед блоком — `%id Подпись` (и ссылка `@Рисунок:id` в тексте).
**IDEF0** конвертер не рисует — только готовый PNG как обычный Рисунок.
IDEF0 пиши в ````idef0`, не внешним PNG. Пример контекста A-0:
Если в конец этого промпта добавлены блоки «Схема: …» — используй их макросы и оградки. Если блоков нет — для C4/BPMN/usecase всё равно можно писать `` ```uml-c4 ``, `` ```bpmn ``, `` ```usecase `` с типичными макросами; детали пользователь может дописать из GUI.
~~~markdown
%a0 Контекстная диаграмма A-0
```idef0
title Распорядиться товаром
node A-0
number 1
author Кинзябулатов Рамиль
project Разработка универсальной модели торгового предприятия
date 08.02.2022
rev 31.03.2022
status PUBLICATION
context TOP
[A0] Распорядиться товаром
<- Спрос
^ Нормативная документация
-> Товар
v Персонал
```
~~~
DFD пиши в ````dfd` (синтаксис [pbauermeister/dfd](https://github.com/pbauermeister/dfd)), не внешним PNG. Пример контекста:
~~~markdown
%dfd0 Контекстная DFD
```dfd
style context
entity Client Клиент
entity Warehouse Склад
process System Система учёта заявок
Client --> System заявка
System --> Client статус
System --> Warehouse накладная
```
~~~
Если в конец этого промпта добавлены блоки «Схема: …» — используй их макросы и оградки. Если блоков нет — для C4/usecase всё равно можно писать `` ```uml-c4 ``, `` ```usecase `` с типичными макросами; детали пользователь может дописать из GUI.
Пример схемы:
@@ -110,7 +152,7 @@ Rel(user, app, "логин")
1. Структура соответствует типу работы (МИРЭА / ПИС / иное — из входа пользователя).
2. У каждого `%id` / title картинки есть `@Тип:id` в тексте.
3. Диаграммы — fence в md, не внешние PNG (кроме IDEF0 и готовых иллюстраций).
3. Диаграммы — fence в md, не внешние PNG (кроме готовых иллюстраций).
4. Нет «рис.» и ссылок `[n]` во введении/заключении.
После проверки выдай только markdown.
+1 -15
View File
@@ -102,7 +102,7 @@
### Диаграмма (PlantUML / Mermaid / схемы)
Перед fenced-блоком с языком `uml`, `plantuml`, `c4`, `uml-c4`, `usecase`, `bpmn` / `uml-bpmn`, `mermaid` / `mmd` и т.п. — `%id Подпись`. Конвертер рисует **Рисунок** (PNG; опционально SVG через `--diagram-format svg`). Опционально `+listing` — ещё и **Листинг** с исходником; `+landscape` — альбомная страница под широкий рисунок/таблицу. Схемы (`c4`, `bpmn`, …) берутся из `md2gost.schemes.json` (создаётся при первом запуске). Mermaid идёт через Kroki (не PlantUML). **IDEF0** не рисуется — вставляйте готовый PNG.
Перед fenced-блоком с языком `uml`, `plantuml`, `c4`, `uml-c4`, `usecase`, `mermaid` / `mmd`, `idef0`, `dfd` / `data-flow-diagram` и т.п. — `%id Подпись`. Конвертер рисует **Рисунок** (PNG; опционально SVG через `--diagram-format svg`). Опционально `+listing` — ещё и **Листинг** с исходником; `+landscape` — альбомная страница под широкий рисунок/таблицу. Схемы (`c4`, `usecase`, …) берутся из `md2gost.schemes.json` (создаётся при первом запуске). Mermaid идёт через Kroki (не PlantUML). **IDEF0** — оградка `` ```idef0 `` (локальный рендер). **DFD** — оградка `` ```dfd `` ([pbauermeister/dfd](https://github.com/pbauermeister/dfd); Graphviz вшитый/PATH или Kroki).
~~~markdown
См. @Рисунок:usecase1 и @Листинг:usecase1.
@@ -123,20 +123,6 @@ Person(user, "Студент")
System(app, "Портал")
Rel(user, app, "логин")
```
%bpmn1 Процесс заявки
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw, "ок?")
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
~~~
### Листинг
+1 -1
View File
@@ -62,7 +62,7 @@ User --> (Login)
```
```
Языки fence: `uml`, `plantuml`, `mermaid` / `mmd`, `c4` / `uml-c4`, `usecase`, `bpmn` / `uml-bpmn` и другие id из `md2gost.schemes.json`. Mermaid — через Kroki. IDEF0 не поддерживается. Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником; `+landscape` — альбомная страница.
Языки fence: `uml`, `plantuml`, `mermaid` / `mmd`, `idef0`, `dfd` / `data-flow-diagram`, `c4` / `uml-c4`, `usecase` и другие id из `md2gost.schemes.json`. Mermaid — через Kroki. IDEF0 — локально в ````idef0`. DFD — [pbauermeister/dfd](https://github.com/pbauermeister/dfd) в ````dfd` (рендер: Graphviz вшитый/PATH или Kroki). Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником; `+landscape` — альбомная страница.
Ссылки: `@Рисунок:usecase1`, `@Листинг:usecase1`.
Формула (обычно работа №8): `$$ … $$` с `%eq_id` и ссылкой `@Формула:eq_id`.
+11 -3
View File
@@ -3,7 +3,7 @@ name = "md2gost"
version = "0.1.0"
description = "Markdown to DOCX (MIREA TZ / GOST 7.32)"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"python-docx>=0.8.11,<1",
"marko>=2.0.0,<3",
@@ -16,6 +16,7 @@ dependencies = [
"lxml>=4.9,<6",
"mermaidx>=0.9.0,<1",
"playwright>=1.40.0,<2",
"data-flow-diagram>=1.16.0,<2",
]
[project.scripts]
@@ -23,6 +24,7 @@ md2gost = "md2gost.__main__:main"
md2gost-gui = "md2gost.gui:main"
md2latex = "md2latex.__main__:main"
md2fodt = "md2fodt.__main__:main"
word2md = "word2md.__main__:main"
[dependency-groups]
dev = [
@@ -39,24 +41,29 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["md2gost", "md2latex", "md2fodt"]
packages = ["md2gost", "md2latex", "md2fodt", "word2md"]
[tool.hatch.build.targets.wheel.force-include]
"md2gost/Template.docx" = "md2gost/Template.docx"
"md2gost/TitleTemplate.docx" = "md2gost/TitleTemplate.docx"
"md2gost/mml2omml" = "md2gost/mml2omml"
"md2gost/diagrams" = "md2gost/diagrams"
"latex/mirea" = "latex/mirea"
"prompts/generate-md.md" = "md2gost/prompts/generate-md.md"
"prompts/generate-mirea-report.md" = "md2gost/prompts/generate-mirea-report.md"
"prompts/generate-pis-custom-report.md" = "md2gost/prompts/generate-pis-custom-report.md"
"prompts/emulate-student.md" = "md2gost/prompts/emulate-student.md"
"docs/README.md" = "md2gost/docs/README.md"
"docs/quickstart.md" = "md2gost/docs/quickstart.md"
"docs/markdown.md" = "md2gost/docs/markdown.md"
"docs/schemes.md" = "md2gost/docs/schemes.md"
"docs/archimate.md" = "md2gost/docs/archimate.md"
"docs/types.md" = "md2gost/docs/types.md"
"docs/cli-gui.md" = "md2gost/docs/cli-gui.md"
"docs/title.md" = "md2gost/docs/title.md"
"docs/styles.md" = "md2gost/docs/styles.md"
"docs/prompts.md" = "md2gost/docs/prompts.md"
"docs/word2md.md" = "md2gost/docs/word2md.md"
# Legacy Poetry metadata (optional; prefer uv + [project] above)
[tool.poetry]
@@ -67,7 +74,7 @@ authors = ["witelokk <death.thunder@yandex.ru>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
python = "^3.11"
python-docx = "^0.8.11"
marko = "^2.0.0"
docxcompose = "^1.4.0"
@@ -79,6 +86,7 @@ latex2mathml = "^3.76.0"
pygments = "^2.16.1"
mermaidx = "^0.9.0"
playwright = "^1.40.0"
data-flow-diagram = "^1.16.0"
[tool.poetry.scripts]
md2gost = "md2gost.__main__:main"
-461
View File
@@ -1,461 +0,0 @@
"""One-shot generator for BPMN.puml geometric sprites. Run from repo root."""
from __future__ import annotations
import math
from pathlib import Path
N = 48
HEX = "0123456789ABCDEF"
SS = 2
def clamp01(v: float) -> float:
return 0.0 if v < 0 else 1.0 if v > 1 else v
def mix(grid: list[list[float]], x: int, y: int, v: float) -> None:
if 0 <= x < N and 0 <= y < N:
grid[y][x] = max(grid[y][x], clamp01(v))
def ring(d: float, r: float, hw: float) -> float:
return clamp01(1.0 - abs(d - r) / hw)
def disk(d: float, r: float, hw: float = 0.65) -> float:
if d <= r - hw:
return 1.0
if d >= r + hw:
return 0.0
return clamp01((r + hw - d) / (2 * hw))
def line_cov(px: float, py: float, x0: float, y0: float, x1: float, y1: float, hw: float) -> float:
dx, dy = x1 - x0, y1 - y0
len2 = dx * dx + dy * dy
if len2 < 1e-6:
return disk(math.hypot(px - x0, py - y0), 0, hw)
t = clamp01(((px - x0) * dx + (py - y0) * dy) / len2)
qx, qy = x0 + t * dx, y0 + t * dy
return disk(math.hypot(px - qx, py - qy), 0, hw)
def diamond_m(px: float, py: float, cx: float, cy: float) -> float:
return abs(px - cx) + abs(py - cy)
def sample_event(
px: float,
py: float,
*,
kind: str,
dashed: bool,
) -> float:
cx = cy = (N - 1) / 2
d = math.hypot(px - cx, py - cy)
v = 0.0
if kind == "start":
v = max(v, ring(d, 18.2, 1.15))
elif kind == "inter":
v = max(v, ring(d, 18.4, 1.05))
v = max(v, ring(d, 14.6, 1.05))
elif kind == "end":
v = max(v, ring(d, 17.6, 2.55))
elif kind == "endfill":
v = max(v, ring(d, 17.6, 2.55))
v = max(v, disk(d, 7.2, 0.7))
if dashed and v > 0:
ang = (math.atan2(py - cy, px - cx) + math.pi) / (2 * math.pi)
if int(ang * 18) % 2 == 1:
v *= 0.08
return v
def icon_msg(px: float, py: float, cx: float, cy: float, filled: bool) -> float:
# envelope 16x10 around center
x0, y0, x1, y1 = cx - 8, cy - 5, cx + 8, cy + 5
v = 0.0
# outer rect
inside = (x0 + 0.8 <= px <= x1 - 0.8) and (y0 + 0.8 <= py <= y1 - 0.8)
on_border = (
(abs(px - x0) < 1.1 or abs(px - x1) < 1.1) and (y0 - 0.4 <= py <= y1 + 0.4)
) or (
(abs(py - y0) < 1.1 or abs(py - y1) < 1.1) and (x0 - 0.4 <= px <= x1 + 0.4)
)
if filled:
if inside or on_border:
v = 1.0
# inverted flap (white cut) approximated by not drawing center of flap
v = max(v, line_cov(px, py, x0, y0, cx, cy + 1.5, 0.9))
v = max(v, line_cov(px, py, x1, y0, cx, cy + 1.5, 0.9))
return v
v = max(v, 1.0 if on_border else 0.0)
v = max(v, line_cov(px, py, x0, y0, cx, cy + 1.8, 0.95))
v = max(v, line_cov(px, py, x1, y0, cx, cy + 1.8, 0.95))
return v
def icon_timer(px: float, py: float, cx: float, cy: float) -> float:
d = math.hypot(px - cx, py - cy)
v = ring(d, 7.2, 1.0)
v = max(v, line_cov(px, py, cx, cy, cx, cy - 5.2, 0.9))
v = max(v, line_cov(px, py, cx, cy, cx + 3.6, cy, 0.9))
return v
def icon_error(px: float, py: float, cx: float, cy: float) -> float:
pts = [
(cx + 1, cy - 8),
(cx - 2, cy - 1),
(cx + 3, cy - 1),
(cx - 1, cy + 8),
(cx + 0.5, cy + 1.5),
(cx - 3.5, cy + 1.5),
]
v = 0.0
for a, b in zip(pts, pts[1:] + pts[:1]):
v = max(v, line_cov(px, py, a[0], a[1], b[0], b[1], 1.0))
return v
def icon_signal(px: float, py: float, cx: float, cy: float, filled: bool) -> float:
a, b, c = (cx, cy - 7.5), (cx - 7.2, cy + 6), (cx + 7.2, cy + 6)
v = 0.0
v = max(v, line_cov(px, py, *a, *b, 1.05))
v = max(v, line_cov(px, py, *b, *c, 1.05))
v = max(v, line_cov(px, py, *c, *a, 1.05))
if filled:
# barycentric fill
def area(p, q, r):
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
p = (px, py)
a0 = area(a, b, c)
if abs(a0) > 0.1:
w1 = area(p, b, c) / a0
w2 = area(a, p, c) / a0
w3 = area(a, b, p) / a0
if min(w1, w2, w3) >= -0.02:
v = max(v, 1.0)
return v
def icon_esc(px: float, py: float, cx: float, cy: float) -> float:
v = 0.0
v = max(v, line_cov(px, py, cx, cy + 7, cx, cy - 2, 1.1))
v = max(v, line_cov(px, py, cx, cy - 2, cx - 6, cy + 4, 1.05))
v = max(v, line_cov(px, py, cx, cy - 2, cx + 6, cy + 4, 1.05))
v = max(v, line_cov(px, py, cx - 6, cy + 4, cx + 6, cy + 4, 1.0))
return v
def icon_link(px: float, py: float, cx: float, cy: float) -> float:
v = line_cov(px, py, cx - 8, cy, cx + 5, cy, 1.1)
v = max(v, line_cov(px, py, cx + 5, cy, cx + 1, cy - 4, 1.05))
v = max(v, line_cov(px, py, cx + 5, cy, cx + 1, cy + 4, 1.05))
return v
def icon_comp(px: float, py: float, cx: float, cy: float) -> float:
v = 0.0
for ox in (-4.2, 3.2):
a, b, c = (cx + ox + 4, cy), (cx + ox - 3.5, cy - 6), (cx + ox - 3.5, cy + 6)
v = max(v, line_cov(px, py, *a, *b, 1.0))
v = max(v, line_cov(px, py, *a, *c, 1.0))
v = max(v, line_cov(px, py, *b, *c, 1.0))
return v
def icon_cond(px: float, py: float, cx: float, cy: float) -> float:
x0, y0, x1, y1 = cx - 6, cy - 8, cx + 6, cy + 8
v = 0.0
on = (
(abs(px - x0) < 1.0 or abs(px - x1) < 1.0) and (y0 <= py <= y1)
) or (
(abs(py - y0) < 1.0 or abs(py - y1) < 1.0) and (x0 <= px <= x1)
)
if on:
v = 1.0
for yy in (cy - 3, cy, cy + 3):
v = max(v, line_cov(px, py, cx - 3.5, yy, cx + 3.5, yy, 0.8))
return v
def icon_multi(px: float, py: float, cx: float, cy: float) -> float:
r = 7.4
pts = []
for i in range(5):
a = -math.pi / 2 + i * 2 * math.pi / 5
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
v = 0.0
for a, b in zip(pts, pts[1:] + pts[:1]):
v = max(v, line_cov(px, py, *a, *b, 1.0))
return v
def icon_par_multi(px: float, py: float, cx: float, cy: float) -> float:
v = ring(math.hypot(px - cx, py - cy), 7.4, 1.0)
v = max(v, line_cov(px, py, cx, cy - 5, cx, cy + 5, 1.05))
v = max(v, line_cov(px, py, cx - 5, cy, cx + 5, cy, 1.05))
return v
def icon_cancel(px: float, py: float, cx: float, cy: float) -> float:
v = line_cov(px, py, cx - 6, cy - 6, cx + 6, cy + 6, 1.15)
v = max(v, line_cov(px, py, cx + 6, cy - 6, cx - 6, cy + 6, 1.15))
return v
def icon_user(px: float, py: float, cx: float, cy: float) -> float:
v = disk(math.hypot(px - cx, py - (cy - 3.2)), 3.1, 0.7)
v = max(v, ring(math.hypot(px - cx, py - (cy - 3.2)), 3.1, 0.9))
# shoulders
v = max(v, line_cov(px, py, cx - 6, cy + 7, cx + 6, cy + 7, 1.1))
v = max(v, line_cov(px, py, cx - 6, cy + 7, cx - 4, cy + 1.5, 1.0))
v = max(v, line_cov(px, py, cx + 6, cy + 7, cx + 4, cy + 1.5, 1.0))
return v
def icon_service(px: float, py: float, cx: float, cy: float) -> float:
v = ring(math.hypot(px - cx, py - cy), 4.2, 1.15)
for i in range(8):
a = i * math.pi / 4
v = max(
v,
line_cov(
px, py,
cx + 3.2 * math.cos(a), cy + 3.2 * math.sin(a),
cx + 7.4 * math.cos(a), cy + 7.4 * math.sin(a),
1.05,
),
)
return v
def icon_script(px: float, py: float, cx: float, cy: float) -> float:
return icon_cond(px, py, cx, cy)
def icon_manual(px: float, py: float, cx: float, cy: float) -> float:
v = line_cov(px, py, cx - 7, cy + 2, cx + 7, cy + 2, 1.2)
v = max(v, line_cov(px, py, cx - 7, cy + 2, cx - 5, cy - 4, 1.0))
v = max(v, line_cov(px, py, cx - 2, cy + 2, cx - 1, cy - 6, 1.0))
v = max(v, line_cov(px, py, cx + 2, cy + 2, cx + 3, cy - 5.5, 1.0))
v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 7, cy - 3, 1.0))
return v
def icon_rule(px: float, py: float, cx: float, cy: float) -> float:
v = 0.0
for yy in (cy - 6, cy, cy + 6):
v = max(v, line_cov(px, py, cx - 7, yy, cx + 7, yy, 0.95))
for xx in (cx - 7, cx, cx + 7):
v = max(v, line_cov(px, py, xx, cy - 6, xx, cy + 6, 0.95))
return v
def icon_plusbox(px: float, py: float, cx: float, cy: float) -> float:
x0, y0, x1, y1 = cx - 7, cy - 7, cx + 7, cy + 7
v = 0.0
on = (
(abs(px - x0) < 1.05 or abs(px - x1) < 1.05) and (y0 <= py <= y1)
) or (
(abs(py - y0) < 1.05 or abs(py - y1) < 1.05) and (x0 <= px <= x1)
)
if on:
v = 1.0
v = max(v, line_cov(px, py, cx, cy - 4.5, cx, cy + 4.5, 1.05))
v = max(v, line_cov(px, py, cx - 4.5, cy, cx + 4.5, cy, 1.05))
return v
ICONS = {
"": None,
"msg": lambda px, py, cx, cy: icon_msg(px, py, cx, cy, False),
"msgf": lambda px, py, cx, cy: icon_msg(px, py, cx, cy, True),
"timer": icon_timer,
"error": icon_error,
"signal": lambda px, py, cx, cy: icon_signal(px, py, cx, cy, False),
"signalf": lambda px, py, cx, cy: icon_signal(px, py, cx, cy, True),
"esc": icon_esc,
"link": icon_link,
"comp": icon_comp,
"cond": icon_cond,
"multi": icon_multi,
"par": icon_par_multi,
"cancel": icon_cancel,
"user": icon_user,
"service": icon_service,
"script": icon_script,
"manual": icon_manual,
"rule": icon_rule,
"plusbox": icon_plusbox,
}
def raster(draw) -> list[list[float]]:
acc = [[0.0] * N for _ in range(N)]
big = N * SS
for y in range(big):
for x in range(big):
v = draw((x + 0.5) / SS, (y + 0.5) / SS)
acc[y // SS][x // SS] += v
s = float(SS * SS)
return [[acc[y][x] / s for x in range(N)] for y in range(N)]
def to_sprite(name: str, grid: list[list[float]]) -> str:
lines = [f"sprite ${name} [{N}x{N}/16] {{"]
for row in grid:
lines.append("".join(HEX[min(15, int(round(v * 15)))] for v in row))
lines.append("}")
return "\n".join(lines)
def event_grid(kind: str, icon: str, dashed: bool = False) -> list[list[float]]:
cx = cy = (N - 1) / 2.0
fn = ICONS[icon]
def draw(px, py):
v = sample_event(px, py, kind=kind, dashed=dashed)
if fn is not None:
v = max(v, fn(px, py, cx, cy))
return v
return raster(draw)
def gateway_grid(mark: str) -> list[list[float]]:
cx = cy = (N - 1) / 2.0
r = 21.0
def draw(px, py):
m = diamond_m(px, py, cx, cy)
v = ring(m, r, 1.25)
if mark == "x":
v = max(v, line_cov(px, py, cx - 8, cy - 8, cx + 8, cy + 8, 1.25))
v = max(v, line_cov(px, py, cx + 8, cy - 8, cx - 8, cy + 8, 1.25))
elif mark == "+":
v = max(v, line_cov(px, py, cx, cy - 9, cx, cy + 9, 1.3))
v = max(v, line_cov(px, py, cx - 9, cy, cx + 9, cy, 1.3))
elif mark == "o":
v = max(v, ring(math.hypot(px - cx, py - cy), 7.0, 1.15))
elif mark == "*":
for i in range(4):
a = i * math.pi / 4
v = max(
v,
line_cov(
px, py,
cx - 7 * math.cos(a), cy - 7 * math.sin(a),
cx + 7 * math.cos(a), cy + 7 * math.sin(a),
1.05,
),
)
elif mark == "ev":
v = max(v, icon_multi(px, py, cx, cy))
v = max(v, ring(math.hypot(px - cx, py - cy), 10.5, 0.95))
elif mark == "pev":
v = max(v, icon_par_multi(px, py, cx, cy))
return v
return raster(draw)
EVENTS = [
("evStart", "start", "", False),
("evStartMsg", "start", "msg", False),
("evStartTimer", "start", "timer", False),
("evStartSignal", "start", "signal", False),
("evStartCond", "start", "cond", False),
("evStartError", "start", "error", False),
("evStartEsc", "start", "esc", False),
("evStartMulti", "start", "multi", False),
("evStartPar", "start", "par", False),
("evStartNI", "start", "", True),
("evStartMsgNI", "start", "msg", True),
("evStartTimerNI", "start", "timer", True),
("evStartSignalNI", "start", "signal", True),
("evInter", "inter", "", False),
("evCatchMsg", "inter", "msg", False),
("evCatchTimer", "inter", "timer", False),
("evCatchSignal", "inter", "signal", False),
("evCatchError", "inter", "error", False),
("evCatchEsc", "inter", "esc", False),
("evCatchCond", "inter", "cond", False),
("evCatchLink", "inter", "link", False),
("evCatchComp", "inter", "comp", False),
("evCatchCancel", "inter", "cancel", False),
("evCatchMulti", "inter", "multi", False),
("evCatchPar", "inter", "par", False),
("evThrowMsg", "inter", "msgf", False),
("evThrowSignal", "inter", "signalf", False),
("evThrowEsc", "inter", "esc", False),
("evThrowLink", "inter", "link", False),
("evThrowComp", "inter", "comp", False),
("evThrowMulti", "inter", "multi", False),
("evEnd", "end", "", False),
("evEndMsg", "end", "msgf", False),
("evEndError", "end", "error", False),
("evEndEsc", "end", "esc", False),
("evEndCancel", "end", "cancel", False),
("evEndComp", "end", "comp", False),
("evEndSignal", "end", "signalf", False),
("evEndMulti", "end", "multi", False),
("evTerminate", "endfill", "", False),
("evBoundMsg", "inter", "msg", False),
("evBoundTimer", "inter", "timer", False),
("evBoundError", "inter", "error", False),
("evBoundEsc", "inter", "esc", False),
("evBoundSignal", "inter", "signal", False),
("evBoundCancel", "inter", "cancel", False),
("evBoundComp", "inter", "comp", False),
("evBoundCond", "inter", "cond", False),
("evBoundMsgNI", "inter", "msg", True),
("evBoundTimerNI", "inter", "timer", True),
("evBoundEscNI", "inter", "esc", True),
("evBoundSignalNI", "inter", "signal", True),
("evBoundCondNI", "inter", "cond", True),
]
GATES = [
("gwXor", "x"),
("gwAnd", "+"),
("gwOr", "o"),
("gwComplex", "*"),
("gwEvent", "ev"),
("gwParEvent", "pev"),
]
TASK_ICONS = ["user", "service", "script", "manual", "rule", "plusbox", "msg", "msgf"]
def main() -> None:
chunks = ["' auto-generated geometric sprites (BPMN 2.0 notation)"]
for name, kind, icon, dashed in EVENTS:
chunks.append(to_sprite(name, event_grid(kind, icon, dashed)))
chunks.append("")
for name, mark in GATES:
chunks.append(to_sprite(name, gateway_grid(mark)))
chunks.append("")
cx = cy = (N - 1) / 2.0
for icon in TASK_ICONS:
fn = ICONS[icon]
grid = raster(lambda px, py, fn=fn: fn(px, py, cx, cy))
chunks.append(to_sprite("ic" + icon.capitalize() if icon != "msgf" else "icMsgf", grid))
chunks.append("")
# plusbox already as icPlusbox; loop/adhoc/mi as simple extra
def loop_draw(px, py):
v = ring(math.hypot(px - cx, py - (cy - 1)), 8.5, 1.1)
v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 10, cy + 6, 1.05))
v = max(v, line_cov(px, py, cx + 6, cy + 2, cx + 2, cy + 6, 1.05))
return v
chunks.append(to_sprite("icLoop", raster(loop_draw)))
chunks.append("")
Path("_bpmn_sprites.puml").write_text("\n".join(chunks), encoding="utf-8")
print("wrote", len(chunks), "chunks")
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
"""Download official Graphviz win64 portable into md2gost/vendor/graphviz (for exe builds)."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from md2gost.dfd import ( # noqa: E402
GRAPHVIZ_VERSION,
GRAPHVIZ_ZIP_URL,
fetch_graphviz,
resolve_graphviz_engine,
strip_graphviz_tcl_libs,
vendor_graphviz_root,
)
def main() -> int:
dest = vendor_graphviz_root()
print(f"Graphviz {GRAPHVIZ_VERSION}: {GRAPHVIZ_ZIP_URL}")
print(f"Куда: {dest}")
if resolve_graphviz_engine(False) and (dest / "bin").is_dir():
n = strip_graphviz_tcl_libs(dest)
hit = resolve_graphviz_engine(False)
extra = f", снял Tcl/Tk DLL Graphviz: {n}" if n else ""
print(f"Уже есть: {hit}{extra}")
return 0
if fetch_graphviz(dest):
hit = resolve_graphviz_engine(False)
print(f"Готово: {hit or dest}")
return 0
print(
"Не удалось скачать Graphviz "
"(exe соберётся; DFD пойдёт через kroki.io или системный PATH)."
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+111
View File
@@ -0,0 +1,111 @@
"""PyInstaller freeze helpers: pin CPython Tcl/Tk, drop Graphviz's copy."""
from __future__ import annotations
import os
import sys
from pathlib import Path
def _python_dll_dir() -> Path | None:
candidates = [
Path(sys.base_prefix) / "DLLs",
Path(sys.prefix) / "DLLs",
Path(sys.executable).resolve().parent / "DLLs",
Path(sys.executable).resolve().parent.parent / "DLLs",
]
for folder in candidates:
if (folder / "tcl86t.dll").is_file() or (folder / "tk86t.dll").is_file():
return folder
return None
def prepare_tcltk_env() -> None:
"""Make Analysis / isolated hook subprocesses see this interpreter's Tcl."""
dll_dir = _python_dll_dir()
prefix = Path(sys.base_prefix)
tcl_lib = prefix / "tcl" / "tcl8.6"
tk_lib = prefix / "tcl" / "tk8.6"
if not tcl_lib.is_dir() and dll_dir is not None:
tcl_lib = dll_dir.parent / "tcl" / "tcl8.6"
tk_lib = dll_dir.parent / "tcl" / "tk8.6"
if tcl_lib.is_dir():
os.environ["TCL_LIBRARY"] = str(tcl_lib)
if tk_lib.is_dir():
os.environ["TK_LIBRARY"] = str(tk_lib)
if dll_dir is not None:
os.environ["PATH"] = str(dll_dir) + os.pathsep + os.environ.get("PATH", "")
def collect_tkinterdnd2():
datas: list = []
hidden: list = ["tkinterdnd2", "tkinterdnd2.TkinterDnD"]
try:
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
datas += collect_data_files("tkinterdnd2")
hidden = list(collect_submodules("tkinterdnd2")) or hidden
except Exception:
pass
return datas, hidden
def _toc_name(item) -> str:
dest = str(item[0]).replace("\\", "/")
return os.path.basename(dest).lower()
def _toc_blob(item) -> str:
dest = str(item[0]).replace("\\", "/").lower()
src = str(item[1]).replace("\\", "/").lower() if len(item) > 1 else ""
return dest + " " + src
def _graphviz_tcl_names() -> set[str]:
try:
from md2gost.dfd import GRAPHVIZ_TCL_DLL_NAMES
return {n.lower() for n in GRAPHVIZ_TCL_DLL_NAMES}
except Exception:
return {
"tcl86t.dll",
"tk86t.dll",
"tcl86.dll",
"tk86.dll",
"tcldot.dll",
"tcldot_builtin.dll",
"tclplan.dll",
"gdtclft.dll",
}
def _is_graphviz_tcl(item) -> bool:
if _toc_name(item) not in _graphviz_tcl_names():
return False
return "graphviz" in _toc_blob(item)
def _wrap_toc(items):
try:
from PyInstaller.building.datastruct import TOC
return TOC(items)
except Exception:
return items
def sanitize_analysis(a):
"""Drop Graphviz Tcl/Tk DLLs and force this CPython's tcl86t/tk86t into the exe."""
a.datas = _wrap_toc([x for x in a.datas if not _is_graphviz_tcl(x)])
binaries = [x for x in a.binaries if not _is_graphviz_tcl(x)]
dll_dir = _python_dll_dir()
pin = ("tcl86t.dll", "tk86t.dll", "zlib1.dll")
if dll_dir is not None:
pin_lower = {n.lower() for n in pin}
binaries = [x for x in binaries if _toc_name(x) not in pin_lower]
for name in pin:
path = dll_dir / name
if path.is_file():
binaries.append((name, str(path), "BINARY"))
a.binaries = _wrap_toc(binaries)
return a
+33
View File
@@ -0,0 +1,33 @@
# Runtime hook: load this CPython's Tcl/Tk before Graphviz's tcl86t.dll (8.6.10)
# can shadow them. PyInstaller then points TCL_LIBRARY at bundled _tcl_data (8.6.12).
def _pyi_rthook():
import os
import sys
if not getattr(sys, "frozen", False):
return
base = getattr(sys, "_MEIPASS", None) or os.path.dirname(sys.executable)
os.environ["PATH"] = base + os.pathsep + os.environ.get("PATH", "")
try:
os.add_dll_directory(base)
except (AttributeError, OSError):
pass
tcldir = os.path.join(base, "_tcl_data")
tkdir = os.path.join(base, "_tk_data")
if os.path.isdir(tcldir):
os.environ["TCL_LIBRARY"] = tcldir
if os.path.isdir(tkdir):
os.environ["TK_LIBRARY"] = tkdir
try:
import ctypes
for name in ("tcl86t.dll", "tk86t.dll"):
path = os.path.join(base, name)
if os.path.isfile(path):
ctypes.WinDLL(path)
except Exception:
pass
_pyi_rthook()
del _pyi_rthook
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+78
View File
@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1700" viewBox="0 0 2400 1700">
<rect width="100%" height="100%" fill="#fff"/>
<rect x="16.0" y="16.0" width="2368.0" height="1668.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<rect x="22.4" y="22.4" width="2355.2" height="1655.2" fill="none" stroke="#000000" stroke-width="2.00"/>
<line x1="22.4" y1="214.4" x2="2377.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="22.4" y1="1549.6" x2="2377.6" y2="1549.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="363.9" y1="22.4" x2="363.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1200.0" y1="22.4" x2="1200.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1612.2" y1="22.4" x2="1612.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1953.7" y1="22.4" x2="1953.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1200.0" y1="66.4" x2="1612.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1426.7" y1="22.4" x2="1426.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
<text x="375.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
<text x="487.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
<text x="375.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
<text x="503.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
<text x="375.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
<text x="479.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
<text x="507.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
<text x="535.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
<text x="563.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
<text x="591.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
<text x="619.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
<text x="647.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
<text x="675.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
<text x="703.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
<text x="731.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
<text x="1210.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
<text x="1284.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
<text x="1434.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
<text x="1498.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
<rect x="1212.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
<rect x="1212.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
<rect x="1212.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
<rect x="1212.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<text x="1236.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
<text x="1624.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
<line x1="1612.2" y1="66.4" x2="1953.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="1624.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
<text x="1965.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
<text x="2165.6" y="134.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="700" fill="#000000">TOP</text>
<line x1="446.3" y1="1549.6" x2="446.3" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1906.6" y1="1549.6" x2="1906.6" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
<text x="234.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A-0</text>
<text x="458.3" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
<text x="1176.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
<text x="1918.6" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
<text x="2142.1" y="1621.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">1</text>
<rect x="2341.6" y="1641.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="2359.6" y="1659.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">1</text>
<line x1="38.4" y1="870.0" x2="888.0" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="888.0,870.0 872.0,863.3 872.0,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1208.0" y1="230.4" x2="1208.0" y2="732.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,732.0 1201.3,716.0 1214.7,716.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1528.0" y1="870.0" x2="2361.6" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2361.6,870.0 2345.6,863.3 2345.6,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1208.0" y1="1533.6" x2="1208.0" y2="1008.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,1008.0 1201.3,1024.0 1214.7,1024.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<rect x="406.6" y="799.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="446.2" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
<rect x="1007.8" y="438.7" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1091.9" y="452.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
<text x="1091.9" y="479.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
<rect x="1889.0" y="799.9" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1928.1" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
<rect x="1056.8" y="1262.3" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1116.4" y="1276.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
<rect x="896.0" y="740.0" width="640.0" height="276.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="888.0" y="732.0" width="640.0" height="276.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="888.0,732.0 904.0,732.0 888.0,748.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1208.0" y="864.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Распорядиться товаром</text>
<text x="1516.0" y="998.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A0</text>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+166
View File
@@ -0,0 +1,166 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2800" height="1840" viewBox="0 0 2800 1840">
<rect width="100%" height="100%" fill="#fff"/>
<rect x="16.0" y="16.0" width="2768.0" height="1808.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<rect x="22.4" y="22.4" width="2755.2" height="1795.2" fill="none" stroke="#000000" stroke-width="2.00"/>
<line x1="22.4" y1="214.4" x2="2777.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="22.4" y1="1689.6" x2="2777.6" y2="1689.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="421.9" y1="22.4" x2="421.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1400.0" y1="22.4" x2="1400.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1882.2" y1="22.4" x2="1882.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="2281.7" y1="22.4" x2="2281.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1400.0" y1="66.4" x2="1882.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1665.2" y1="22.4" x2="1665.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
<text x="433.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
<text x="545.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
<text x="433.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
<text x="561.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
<text x="433.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
<text x="537.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
<text x="565.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
<text x="593.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
<text x="621.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
<text x="649.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
<text x="677.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
<text x="705.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
<text x="733.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
<text x="761.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
<text x="789.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
<text x="1410.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
<text x="1484.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
<text x="1673.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
<text x="1737.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
<rect x="1412.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
<rect x="1412.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
<rect x="1412.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
<rect x="1412.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<text x="1436.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
<text x="1894.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
<line x1="1882.2" y1="66.4" x2="2281.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="1894.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
<text x="2293.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
<rect x="2475.6" y="90.4" width="116.0" height="56.0" fill="#c4c4c4" stroke="none" stroke-width="0.00"/>
<rect x="2471.6" y="86.4" width="116.0" height="56.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<text x="2579.6" y="136.4" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">A-0</text>
<line x1="518.3" y1="1689.6" x2="518.3" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="2226.6" y1="1689.6" x2="2226.6" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<text x="34.4" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
<text x="270.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A0</text>
<text x="530.3" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
<text x="1372.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
<text x="2238.6" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
<text x="2502.1" y="1761.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">2</text>
<rect x="2741.6" y="1781.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
<text x="2759.6" y="1799.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">2</text>
<line x1="38.4" y1="652.0" x2="696.0" y2="652.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="696.0,652.0 680.0,645.3 680.0,658.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="38.4" y1="891.8" x2="1208.0" y2="891.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,891.8 1192.0,885.1 1192.0,898.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1296.0" y1="230.4" x2="1296.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="278.4" x2="784.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="784.0" y1="278.4" x2="784.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="230.4" x2="1296.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="278.4" x2="1808.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1808.0" y1="278.4" x2="1808.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="784.0,566.0 777.3,550.0 790.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1296.0,854.0 1289.3,838.0 1302.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1808.0,1142.0 1801.3,1126.0 1814.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1008.0" y1="230.4" x2="1008.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1008.0,566.0 1001.3,550.0 1014.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2032.0" y1="230.4" x2="2032.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2032.0,1142.0 2025.3,1126.0 2038.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1096.0" y1="603.8" x2="1152.0" y2="603.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1152.0" y1="603.8" x2="1152.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1152.0" y1="988.2" x2="1208.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1208.0,988.2 1192.0,981.4 1192.0,994.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1096.0" y1="700.2" x2="1216.0" y2="700.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1216.0" y1="700.2" x2="1216.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1216.0" y1="798.0" x2="1520.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1520.0" y1="798.0" x2="1520.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1520.0,854.0 1513.3,838.0 1526.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1608.0" y1="940.0" x2="1664.0" y2="940.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1664.0" y1="940.0" x2="1664.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1664.0" y1="1228.0" x2="1720.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1720.0,1228.0 1704.0,1221.3 1704.0,1234.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2120.0" y1="1179.8" x2="2761.6" y2="1179.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2761.6,1179.8 2745.6,1173.1 2745.6,1186.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="2120.0" y1="1276.2" x2="2761.6" y2="1276.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="2761.6,1276.2 2745.6,1269.4 2745.6,1282.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1408.0" y1="1673.6" x2="1408.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="896.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="896.0" y1="1362.0" x2="896.0" y2="738.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="1296.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1296.0" y1="1362.0" x2="1296.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1408.0" y1="1362.0" x2="1920.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<line x1="1920.0" y1="1362.0" x2="1920.0" y2="1314.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="896.0,738.0 889.3,754.0 902.7,754.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1296.0,1026.0 1289.3,1042.0 1302.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<polygon points="1920.0,1314.0 1913.3,1330.0 1926.7,1330.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<line x1="1520.0" y1="1673.6" x2="1520.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
<polygon points="1520.0,1026.0 1513.3,1042.0 1526.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
<rect x="42.4" y="627.0" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="54.1" y="637.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I1</text>
<rect x="42.4" y="866.8" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="54.1" y="877.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I2</text>
<rect x="1306.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1321.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C1</text>
<rect x="1018.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1033.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C2</text>
<rect x="2042.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2057.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C3</text>
<rect x="2724.2" y="1154.8" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2740.9" y="1165.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O1</text>
<rect x="2724.2" y="1251.2" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2740.9" y="1261.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O2</text>
<rect x="1418.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1435.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M1</text>
<rect x="1530.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1547.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M2</text>
<rect x="314.4" y="581.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="354.0" y="596.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
<rect x="518.2" y="794.9" width="163.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="599.8" y="809.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Предложения</text>
<text x="599.8" y="836.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставщиков</text>
<rect x="1211.9" y="295.0" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1296.0" y="309.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
<text x="1296.0" y="336.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
<rect x="1040.0" y="392.6" width="195.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1137.6" y="406.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Ассортиментная</text>
<text x="1137.6" y="433.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">политика</text>
<rect x="1889.8" y="635.5" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1944.9" y="649.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Условия</text>
<text x="1944.9" y="676.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставки</text>
<rect x="1122.9" y="533.8" width="155.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1200.5" y="548.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">план закупок</text>
<rect x="1165.6" y="603.2" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1220.7" y="617.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">график</text>
<text x="1220.7" y="644.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставок</text>
<rect x="1634.9" y="869.9" width="192.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1731.0" y="884.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">товар к отгрузке</text>
<rect x="2388.9" y="1109.8" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2428.0" y="1124.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
<rect x="2352.9" y="1206.1" width="150.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="2428.0" y="1220.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Акт отгрузки</text>
<rect x="1256.8" y="1505.0" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1316.4" y="1519.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
<rect x="1407.8" y="1279.0" width="80.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
<text x="1447.9" y="1293.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Склад</text>
<rect x="704.0" y="574.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="696.0" y="566.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="696.0,566.0 712.0,566.0 696.0,582.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="896.0" y="646.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Принять и оценить спрос</text>
<text x="1084.0" y="728.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A1</text>
<rect x="1216.0" y="862.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="1208.0" y="854.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="1208.0,854.0 1224.0,854.0 1208.0,870.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1408.0" y="934.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Закупить и хранить</text>
<text x="1596.0" y="1016.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A2</text>
<rect x="1728.0" y="1150.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
<rect x="1720.0" y="1142.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
<polygon points="1720.0,1142.0 1736.0,1142.0 1720.0,1158.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
<text x="1920.0" y="1222.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Отгрузить товар</text>
<text x="2108.0" y="1304.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A3</text>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

+194
View File
@@ -0,0 +1,194 @@
"""DFD via pbauermeister/data-flow-diagram (Graphviz / Kroki)."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
from md2gost.dfd import (
SAMPLE_CONTEXT,
SAMPLE_LEVEL0,
DfdParseError,
_compile_dot,
write_dfd_outputs,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable_factory import RenderableFactory
def test_is_diagram_lang_dfd():
assert is_diagram_lang("dfd")
assert is_diagram_lang("uml-dfd")
assert is_diagram_lang("data-flow-diagram")
assert is_diagram_lang("yourdon")
assert is_diagram_lang("uml-yourdon")
assert "dfd" in DIAGRAM_LANGS
def test_prepare_dfd_stays_native():
src, dtype = prepare_source("dfd", SAMPLE_CONTEXT)
assert dtype == "dfd"
assert "@startuml" not in src.lower()
assert "process" in src
assert "System" in src
for lang in ("uml-dfd", "data-flow-diagram", "yourdon"):
_, dtype2 = prepare_source(lang, SAMPLE_LEVEL0)
assert dtype2 == "dfd"
def test_compile_dot_empty_raises():
with pytest.raises(DfdParseError, match="пустой"):
_compile_dot(" \n\n ")
def test_compile_dot_sample(monkeypatch):
"""When the package is importable, real compile; else accept mock."""
try:
dot, is_ctx = _compile_dot(SAMPLE_CONTEXT)
except DfdParseError as exc:
if "data-flow-diagram не найден" in str(exc) or "не найден" in str(exc):
pytest.skip("data-flow-diagram not installed for this Python")
raise
assert "digraph" in dot.lower() or "graph" in dot.lower()
assert is_ctx is True
def test_strip_graphviz_tcl_libs(tmp_path):
from md2gost.dfd import GRAPHVIZ_TCL_DLL_NAMES, strip_graphviz_tcl_libs
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
(bin_dir / "dot.exe").write_bytes(b"mz")
(bin_dir / "tcl86t.dll").write_bytes(b"tcl")
(bin_dir / "gdtclft.dll").write_bytes(b"gd")
(bin_dir / "cgraph.dll").write_bytes(b"keep")
tcl_mod = tmp_path / "lib" / "graphviz" / "tcl"
tcl_mod.mkdir(parents=True)
(tcl_mod / "pkgIndex.tcl").write_text("x", encoding="utf-8")
removed = strip_graphviz_tcl_libs(tmp_path)
assert removed >= 3
assert not (bin_dir / "tcl86t.dll").exists()
assert not (bin_dir / "gdtclft.dll").exists()
assert (bin_dir / "dot.exe").exists()
assert (bin_dir / "cgraph.dll").exists()
assert not tcl_mod.exists()
for name in GRAPHVIZ_TCL_DLL_NAMES:
assert not (bin_dir / name).exists()
def test_write_dfd_outputs_uses_local_or_kroki(tmp_path):
png = tmp_path / "a.png"
svg = tmp_path / "a.svg"
fake_dot = "digraph G { a -> b; }\n"
def fake_local(dot, out_path, fmt, *, context):
Path(out_path).write_bytes(
b"\x89PNG\r\n\x1a\n" if fmt == "png" else b"<svg></svg>"
)
if fmt == "png":
# minimal valid-enough PNG header for startswith checks
Path(out_path).write_bytes(
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
+ b"\x00" * 20
)
else:
Path(out_path).write_text(
"<svg xmlns='http://www.w3.org/2000/svg'><text>Клиент</text></svg>",
encoding="utf-8",
)
return True
with patch("md2gost.dfd._compile_dot", return_value=(fake_dot, False)), \
patch("md2gost.dfd._render_dot_local", side_effect=fake_local), \
patch("md2gost.dfd._render_dot_kroki", return_value=False):
assert write_dfd_outputs(SAMPLE_LEVEL0, png, svg, scale=1)
assert png.read_bytes().startswith(b"\x89PNG")
assert "Клиент" in svg.read_text(encoding="utf-8")
def test_write_dfd_outputs_falls_back_to_kroki(tmp_path):
png = tmp_path / "a.png"
def fake_kroki(dot, out_path, fmt):
Path(out_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
return True
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local", return_value=False), \
patch("md2gost.dfd._render_dot_kroki", side_effect=fake_kroki):
assert write_dfd_outputs(SAMPLE_CONTEXT, png, None, scale=1)
assert png.read_bytes().startswith(b"\x89PNG")
def test_write_dfd_outputs_raises_when_no_renderer(tmp_path):
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
patch("md2gost.dfd._render_dot_local", return_value=False), \
patch("md2gost.dfd._render_dot_kroki", return_value=False):
with pytest.raises(DfdParseError, match="Graphviz|Kroki"):
write_dfd_outputs(SAMPLE_CONTEXT, tmp_path / "x.png", None)
def test_render_diagram_dfd_skips_jar(tmp_path):
calls = {"jar": 0}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_write(source, out_png, out_svg, *, scale=2.0):
Path(out_png).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
return True
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1)
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.dfd.write_dfd_outputs", side_effect=fake_write):
result = render_diagram(
"dfd", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1,
)
assert calls["jar"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
def test_render_diagram_dfd_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with patch("md2gost.dfd._compile_dot", side_effect=DfdParseError("пустой DFD")):
with pytest.raises(RuntimeError, match="пустой|DFD"):
render_diagram("dfd", " ", cache_dir=str(tmp_path))
def test_factory_routes_dfd_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%ctx Контекст DFD\n\n```dfd\n" + SAMPLE_CONTEXT + "```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0]._lang == "dfd"
+151 -53
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import json
import pytest
from md2gost.diagram_includes import (
@@ -25,8 +26,12 @@ from md2gost.diagram_renderer import (
from md2gost.diagram_schemes import (
DiagramScheme,
apply_scheme,
bundled_schemes_path,
claim_user_scheme_if_diverged,
configure_schemes,
ensure_user_schemes,
load_schemes_from_path,
migrate_user_schemes,
prepare_with_schemes,
scheme_id_from_lang,
user_schemes_path,
@@ -83,35 +88,21 @@ def test_unknown_scheme_raises(tmp_path, monkeypatch):
prepare_with_schemes("uml-nosuch", "A -> B", base=tmp_path)
def test_bpmn_scheme_prepare(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
assert is_diagram_lang("bpmn")
assert is_diagram_lang("uml-bpmn")
src, dtype = prepare_with_schemes("bpmn", "Start(s)\nEnd(e)\nFlow(s, e)", base=tmp_path)
assert dtype == "plantuml"
assert "Start(s)" in src
assert "!include" in src
assert "BPMN.puml" in src.replace("\\", "/")
assert src.lower().count("@startuml") == 1
def test_bundled_bpmn_survives_user_file_without_it(tmp_path, monkeypatch):
def test_bundled_c4_survives_user_file_without_it(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = ensure_user_schemes(tmp_path)
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
configure_schemes(base=tmp_path)
assert is_diagram_lang("mine")
assert is_diagram_lang("bpmn")
assert is_diagram_lang("c4")
def test_scheme_id_from_lang():
assert scheme_id_from_lang("uml") is None
assert scheme_id_from_lang("uml-c4") == "c4"
assert scheme_id_from_lang("c4") == "c4"
assert scheme_id_from_lang("bpmn") == "bpmn"
assert scheme_id_from_lang("uml-bpmn") == "bpmn"
assert scheme_id_from_lang("usecase") == "usecase"
assert scheme_id_from_lang("uml-usecase") == "usecase"
def test_ensure_user_schemes_once(tmp_path):
@@ -121,10 +112,129 @@ def test_ensure_user_schemes_once(tmp_path):
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
again = ensure_user_schemes(tmp_path)
assert again == path
assert "mine" in path.read_text(encoding="utf-8")
text = path.read_text(encoding="utf-8")
assert "mine" in text
assert "c4" in text # migration re-adds bundled ids
assert path == user_schemes_path(tmp_path)
def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"bpmn": {
"title": "BPMN 2.0",
"author": "md2gost",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": ["BPMN.puml"],
},
"mine": {"title": "x", "author": "", "prefix": "", "postfix": ""},
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert result.written
assert "bpmn" in result.removed
assert "c4" in result.added
schemes = load_schemes_from_path(path)
assert "bpmn" not in schemes
assert "mine" in schemes
assert schemes["mine"].title == "x"
bundled = load_schemes_from_path(bundled_schemes_path())
for sid in bundled:
assert sid in schemes
def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "My C4",
"author": "",
"docs": "custom docs",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
schemes = load_schemes_from_path(path)
assert schemes["c4"].docs == "custom docs"
assert schemes["c4"].author == ""
assert "c4" not in result.updated
assert "usecase" in schemes # other bundled ids still added
def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
path = user_schemes_path(tmp_path)
path.write_text(
json.dumps(
{
"c4": {
"title": "Old C4",
"author": "md2gost",
"version": "0.0.1",
"docs": "stale",
"prefix": "@startuml\n",
"postfix": "\n@enduml",
"includes": [],
}
},
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
result = migrate_user_schemes(tmp_path)
assert "c4" in result.updated
schemes = load_schemes_from_path(path)
bundled = load_schemes_from_path(bundled_schemes_path())
assert schemes["c4"].to_dict() == bundled["c4"].to_dict()
def test_migrate_idempotent(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
path = user_schemes_path(tmp_path)
before = path.read_text(encoding="utf-8")
mtime1 = path.stat().st_mtime_ns
result = migrate_user_schemes(tmp_path)
assert not result.written
assert not result.changed
assert path.read_text(encoding="utf-8") == before
assert path.stat().st_mtime_ns == mtime1
def test_claim_user_scheme_if_diverged():
bundled = load_schemes_from_path(bundled_schemes_path())
stock = bundled["c4"]
same = DiagramScheme.from_dict("c4", stock.to_dict())
claimed_same = claim_user_scheme_if_diverged(same, bundled=bundled)
assert claimed_same.author == "md2gost"
edited = DiagramScheme.from_dict(
"c4",
{**stock.to_dict(), "docs": "my docs"},
)
claimed = claim_user_scheme_if_diverged(edited, bundled=bundled)
assert claimed.author == ""
assert claimed.docs == "my docs"
def test_apply_scheme_no_double_start():
scheme = DiagramScheme(
id="t",
@@ -137,6 +247,18 @@ def test_apply_scheme_no_double_start():
assert "skinparam monochrome true" in out
def test_archimate_scheme_includes_stdlib():
bundled = load_schemes_from_path(bundled_schemes_path())
scheme = bundled["archimate"]
assert scheme.title == "ArchiMate 3.2"
out = apply_scheme(scheme, 'Business_Actor(a, "X")\n')
assert "!include <archimate/Archimate>" in out
assert "Business_Actor(a, \"X\")" in out
assert out.strip().lower().startswith("@startuml")
assert "@enduml" in out.lower()
assert out.lower().count("@startuml") == 1
def test_include_cache_index_no_overwrite(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
calls = []
@@ -455,39 +577,15 @@ def test_diagram_engine_status_mentions_java_or_kroki():
def test_diagram_langs():
assert "uml" in DIAGRAM_LANGS
assert "c4" in DIAGRAM_LANGS
assert "bpmn" in DIAGRAM_LANGS
assert "usecase" in DIAGRAM_LANGS
assert "idef0" in DIAGRAM_LANGS
assert "dfd" in DIAGRAM_LANGS
assert is_diagram_lang("uml")
assert is_diagram_lang("uml-c4")
assert is_diagram_lang("uml-bpmn")
def test_render_bpmn_with_jar(tmp_path, monkeypatch):
import shutil
monkeypatch.chdir(tmp_path)
ensure_user_schemes(tmp_path)
configure_schemes(base=tmp_path)
jar = resolve_plantuml_jar(download=False)
if not shutil.which("java") or not jar:
pytest.skip("нужны Java и plantuml.jar")
configure_diagrams(fallback="off", cache_dir=str(tmp_path), plantuml_jar=jar)
body = "\n".join(
[
"Start(s)",
"StartMessage(sm, \"msg\")",
"UserTask(t, \"Шаг\")",
"XOR(gw)",
"AND(p)",
"OR(o)",
"End(e)",
"EndTerminate(et)",
"Flow(s, t)",
"Flow(t, gw)",
"CondFlow(gw, e, \"да\")",
"DefaultFlow(gw, et)",
]
)
result = render_diagram("bpmn", body, cache_dir=str(tmp_path), fallback="off")
data = Path(result.png_path).read_bytes()
assert data.startswith(b"\x89PNG")
assert len(data) > 500
assert is_diagram_lang("uml-usecase")
assert is_diagram_lang("idef0")
assert is_diagram_lang("uml-idef0")
assert is_diagram_lang("dfd")
assert is_diagram_lang("uml-dfd")
assert is_diagram_lang("data-flow-diagram")
assert is_diagram_lang("yourdon")
+228
View File
@@ -0,0 +1,228 @@
"""DOCX core properties: resolve, apply, persist in user profile."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from docx import Document
from md2gost.doc_metadata import (
CORE_PROP_MAX,
DEFAULT_AUTHOR_SOURCE,
DEFAULT_DOC_COMMENTS,
DocumentMetadata,
apply_document_metadata,
clip_core,
resolve_document_metadata,
)
from md2gost.pipeline import ConvertRequest, convert
from md2gost.user_profile import UserProfile, load_user_profile, save_user_profile
from md2gost.__main__ import build_parser, request_from_args
def test_clip_core_limit():
assert clip_core("ab") == "ab"
assert len(clip_core("x" * (CORE_PROP_MAX + 10))) == CORE_PROP_MAX
def test_metadata_from_dict_defaults_and_empty_comments():
blank = DocumentMetadata.from_dict(None)
assert blank.author_source == DEFAULT_AUTHOR_SOURCE
assert blank.comments == DEFAULT_DOC_COMMENTS
empty = DocumentMetadata.from_dict({"comments": "", "author_source": "nope"})
assert empty.comments == ""
assert empty.author_source == DEFAULT_AUTHOR_SOURCE
custom = DocumentMetadata.from_dict({"author_source": "student", "title": " Отчёт "})
assert custom.author_source == "student"
assert custom.title == "Отчёт"
def test_resolve_author_sources(monkeypatch):
monkeypatch.setattr("md2gost.doc_metadata.os_user", lambda: "win-user")
stored = DocumentMetadata(author_source="custom", author="Кастом К. К.")
os_meta = resolve_document_metadata(stored=DocumentMetadata(author_source="os"))
assert os_meta.author == "win-user"
assert os_meta.last_modified_by == "win-user"
assert os_meta.comments == DEFAULT_DOC_COMMENTS
student_meta = resolve_document_metadata(
stored=DocumentMetadata(author_source="student"),
student="Студентов С. С.",
)
assert student_meta.author == "Студентов С. С."
custom_meta = resolve_document_metadata(stored=stored)
assert custom_meta.author == "Кастом К. К."
override = resolve_document_metadata(
stored=stored,
author="С CLI",
comments="",
title="Тема работы",
last_modified_by="Редактор",
)
assert override.author == "С CLI"
assert override.comments == ""
assert override.title == "Тема работы"
assert override.last_modified_by == "Редактор"
empty_custom = resolve_document_metadata(
stored=DocumentMetadata(author_source="custom", author=""),
)
assert empty_custom.author == "win-user"
def test_apply_document_metadata_writes_core_props(tmp_path: Path):
from md2gost import package_dir
doc = Document(str(Path(package_dir()) / "Template.docx"))
stamp = datetime(2026, 9, 8, 12, 0, 0)
apply_document_metadata(
doc,
DocumentMetadata(
author="Автор А. А.",
last_modified_by="Автор А. А.",
title="Название",
subject="Тема",
keywords="гост, отчёт",
comments="",
category="учебный",
),
now=stamp,
)
out = tmp_path / "meta.docx"
doc.save(str(out))
loaded = Document(str(out))
cp = loaded.core_properties
assert cp.author == "Автор А. А."
assert cp.last_modified_by == "Автор А. А."
assert cp.title == "Название"
assert cp.subject == "Тема"
assert cp.keywords == "гост, отчёт"
assert cp.comments == ""
assert cp.category == "учебный"
assert cp.revision == 1
assert cp.created.replace(tzinfo=None) == stamp
assert cp.modified.replace(tzinfo=None) == stamp
def test_user_profile_preserves_metadata_when_saving_student(tmp_path: Path):
save_user_profile(
UserProfile(
student="А А А",
group="ГР-1",
metadata=DocumentMetadata(
author_source="student",
title="Отчёт",
comments="",
),
),
base=tmp_path,
)
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "А А А"
assert loaded.metadata.author_source == "student"
assert loaded.metadata.title == "Отчёт"
assert loaded.metadata.comments == ""
loaded.student = "Б Б Б"
save_user_profile(loaded, base=tmp_path)
again = load_user_profile(base=tmp_path)
assert again.student == "Б Б Б"
assert again.group == "ГР-1"
assert again.metadata.title == "Отчёт"
assert again.metadata.comments == ""
def test_old_user_json_without_metadata_block(tmp_path: Path):
path = tmp_path / "md2gost.user.json"
path.write_text('{"student": "Иванов", "group": "ИНБО-01-00"}\n', encoding="utf-8")
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "Иванов"
assert loaded.metadata.comments == DEFAULT_DOC_COMMENTS
assert loaded.metadata.author_source == DEFAULT_AUTHOR_SOURCE
def test_parser_doc_metadata_flags():
parser = build_parser()
args = parser.parse_args(
[
"report.md",
"--doc-author-from", "student",
"--doc-author", "Петров П. П.",
"--doc-title", "Курсовая",
"--doc-comments", "",
"--doc-keywords", "гост",
]
)
req = request_from_args(args)
assert req.doc_author_source == "student"
assert req.doc_author == "Петров П. П."
assert req.doc_title == "Курсовая"
assert req.doc_comments == ""
assert req.doc_keywords == "гост"
default = request_from_args(parser.parse_args(["report.md"]))
assert default.doc_author is None
assert default.doc_comments is None
def test_pipeline_applies_profile_and_cli_metadata(tmp_path: Path, monkeypatch):
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(
student="Конвейев К. К.",
group="ИНБО-00-00",
metadata=DocumentMetadata(
author_source="student",
title="Из профиля",
subject="Тема профиля",
comments="штамп",
),
),
base=tmp_path,
)
md = tmp_path / "report.md"
md.write_text("# *ВВЕДЕНИЕ\n\nТекст.\n", encoding="utf-8")
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
cp = Document(str(out)).core_properties
assert cp.author == "Конвейев К. К."
assert cp.last_modified_by == "Конвейев К. К."
assert cp.title == "Из профиля"
assert cp.subject == "Тема профиля"
assert cp.comments == "штамп"
assert cp.revision == 1
out2 = tmp_path / "report2.docx"
result2 = convert(
ConvertRequest(
filename=str(md),
output=str(out2),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
doc_author="С CLI",
doc_comments="",
doc_title="С CLI название",
),
log=lambda _m: None,
)
assert result2.ok, result2.message
cp2 = Document(str(out2)).core_properties
assert cp2.author == "С CLI"
assert cp2.comments == ""
assert cp2.title == "С CLI название"
assert cp2.subject == "Тема профиля"
+422
View File
@@ -0,0 +1,422 @@
"""Parse and render IDEF0 fenced diagrams."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from PIL import Image
from md2gost.diagram_renderer import (
DIAGRAM_LANGS,
configure_diagrams,
is_diagram_lang,
prepare_source,
render_diagram,
)
from md2gost.extended_markdown import markdown
from md2gost.extended_markdown.caption import Caption as CapEl
from md2gost.idef0 import (
SAMPLE_CONTEXT,
SAMPLE_DECOMPOSITION,
Idef0ParseError,
parse_idef0,
render_idef0,
write_idef0_outputs,
_Rect,
_layout_arrow_label,
_load_font,
_polyline_backtracks,
_seg_hits_open_box,
_layout_boxes,
_assign_slots,
_box_order,
_draw_arrow,
_make_channels,
_draw_kit_form,
_Scene,
_port_point,
)
from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.diagram import DiagramFigure
from md2gost.renderable_factory import RenderableFactory
def _svg_has_words(svg: str, phrase: str) -> None:
missing = [tok for tok in phrase.split() if tok and tok not in svg]
assert not missing, f"missing {missing} in SVG for {phrase!r}"
def test_is_diagram_lang_idef0():
assert is_diagram_lang("idef0")
assert is_diagram_lang("uml-idef0")
assert "idef0" in DIAGRAM_LANGS
def test_prepare_idef0_stays_native():
src, dtype = prepare_source("idef0", SAMPLE_CONTEXT)
assert dtype == "idef0"
assert "@startuml" not in src.lower()
assert "Распорядиться товаром" in src
src2, dtype2 = prepare_source("uml-idef0", "[A0] X\n<- in")
assert dtype2 == "idef0"
def test_parse_sample_context():
d = parse_idef0(SAMPLE_CONTEXT)
assert d.node == "A-0"
assert d.title == "Распорядиться товаром"
assert d.number == "1"
assert d.author == "Кинзябулатов Рамиль"
assert "торгового предприятия" in d.project
assert d.date == "08.02.2022"
assert d.rev == "31.03.2022"
assert d.status == "PUBLICATION"
assert d.context == "TOP"
assert len(d.boxes) == 1
assert d.boxes[0].id == "A0"
assert d.boxes[0].name == "Распорядиться товаром"
labels = {a.label: a for a in d.arrows}
assert set(labels) == {"Спрос", "Нормативная документация", "Товар", "Персонал"}
assert labels["Спрос"].targets[0].side == "I"
assert labels["Нормативная документация"].targets[0].side == "C"
assert labels["Товар"].sources[0].side == "O"
assert labels["Персонал"].targets[0].side == "M"
def test_parse_sample_decomposition():
d = parse_idef0(SAMPLE_DECOMPOSITION)
assert d.node == "A0"
assert [b.id for b in d.boxes] == ["A1", "A2", "A3"]
by_label = {a.label: a for a in d.arrows}
assert by_label["Нормативная документация"].icom == "C1"
assert {t.box for t in by_label["Нормативная документация"].targets} == {
"A1", "A2", "A3",
}
assert by_label["график поставок"].sources[0].box == "A1"
assert by_label["график поставок"].targets[0].box == "A2"
assert by_label["график поставок"].targets[0].side == "C"
assert by_label["план закупок"].targets[0].box == "A2"
assert by_label["товар к отгрузке"].sources[0].box == "A2"
assert by_label["Товар"].icom == "O1"
assert {t.box for t in by_label["Персонал"].targets} == {"A1", "A2", "A3"}
assert by_label["Склад"].targets[0].box == "A2"
assert by_label["Спрос"].icom == "I1"
def test_layout_boxes_cluster_is_compact_and_centered():
d = parse_idef0(SAMPLE_DECOMPOSITION)
work = _Rect(0, 0, 1400, 720)
geoms = _layout_boxes(d, work, 1.0)
boxes = [geoms[b.id].rect for b in d.boxes]
x0, x1 = min(b.x for b in boxes), max(b.r for b in boxes)
y0, y1 = min(b.y for b in boxes), max(b.b for b in boxes)
assert x1 - x0 < work.w * 0.55
assert y1 - y0 < work.h * 0.70
assert abs((x0 + x1) / 2 - work.cx) < 80
assert x0 > 80
assert work.r - x1 > 70
assert y0 > 60
assert work.b - y1 > 70
def test_output_icom_with_control_branch_also_hits_boundary():
d = parse_idef0(
"""
[A1] One
[A2] Two
A1 -> A2.C : план продаж [O1]
"""
)
arrow = d.arrows[0]
assert arrow.icom == "O1"
assert { (t.box, t.side) for t in arrow.targets } == {("A2", "C"), (None, "O")}
def test_arrow_label_sits_off_vertical_shaft():
work = _Rect(0, 0, 800, 600)
font = _load_font(12, False)
placed = _layout_arrow_label(
[(200.0, 40.0), (200.0, 320.0)],
"Нормативная документация",
font,
12.0,
1.0,
work,
[],
)
assert placed is not None
halo, attach, _connect, lines, orient = placed
assert orient == "V"
assert not halo.contains_point(*attach, pad=3)
assert abs(halo.cx - attach[0]) >= 10
assert len(lines) >= 2
def test_arrow_label_sits_above_horizontal_shaft():
work = _Rect(0, 0, 800, 600)
font = _load_font(12, False)
placed = _layout_arrow_label(
[(40.0, 200.0), (360.0, 200.0)],
"Спрос",
font,
12.0,
1.0,
work,
[],
)
assert placed is not None
halo, attach, _connect, _lines, orient = placed
assert orient == "H"
assert halo.b <= attach[1] - 4 or halo.y >= attach[1] + 4
assert not halo.contains_point(*attach, pad=2)
def test_decomposition_paths_do_not_backtrack_or_cut_boxes():
d = parse_idef0(SAMPLE_DECOMPOSITION)
s = 1.0
w, h = 1400, 920
scene = _Scene(w, h)
fonts = {
"lab": 9, "val": 11, "small": 9.5, "box": 13, "id": 10,
"arrow": 12, "node": 20, "title": 16, "top": 22,
}
work = _draw_kit_form(scene, d, _Rect(8, 8, w - 16, h - 16), s, fonts)
geoms = _layout_boxes(d, work, s)
_assign_slots(d, geoms)
order = _box_order(d)
ch = _make_channels(d, geoms, work, s)
for arrow in d.arrows:
paths = _draw_arrow(scene, arrow, geoms, work, s, 1.2, order, ch)
skip = {e.box for e in arrow.sources + arrow.targets if e.box}
for pts in paths:
assert not _polyline_backtracks(pts), arrow.label
for a, b in zip(pts, pts[1:]):
for gid, g in geoms.items():
if gid in skip:
continue
assert not _seg_hits_open_box(a, b, g.rect), (arrow.label, gid)
def test_two_ports_on_same_side_are_spread():
d = parse_idef0(
"""
[A0] Box
<- one
<- two
-> out
^ ctrl
v mech
"""
)
work = _Rect(0, 0, 800, 600)
geoms = _layout_boxes(d, work, 1.0)
_assign_slots(d, geoms)
g = geoms["A0"]
y0, y1 = _port_point(g, "I", 0, 2)[1], _port_point(g, "I", 1, 2)[1]
assert abs(y1 - y0) >= 0.5 * g.rect.h
def test_parse_keyword_and_explicit_box():
d = parse_idef0(
"""
[A0] Распорядиться товаром
A0 in Спрос
A0 control Нормативная документация
A0 out Товар
A0 mech Персонал
"""
)
assert [a.label for a in d.arrows] == [
"Спрос",
"Нормативная документация",
"Товар",
"Персонал",
]
def test_parse_decomposition_fork_join_icom_tunnel():
d = parse_idef0(
"""
node A0
title Обработать заказ
number 2
[A1] Принять заказ
[A2] Закупить
[A3] Отгрузить
A1 <- Заявка [I1]
A1 ^ Правила [C1]
A1 -> A2 : потребность
A2 -> A3 : товар на складе
A3 -> Товар [O1]
A1, A2 -> A3 : сводка
A1 -> A2, A3 : данные
(in Секрет
in) Локальный
A3 v Склад [M1]
"""
)
assert d.node == "A0"
assert [b.id for b in d.boxes] == ["A1", "A2", "A3"]
by_label = {a.label: a for a in d.arrows}
assert by_label["потребность"].sources[0].box == "A1"
assert by_label["потребность"].targets[0].box == "A2"
assert by_label["Заявка"].icom == "I1"
assert {t.box for t in by_label["данные"].targets} == {"A2", "A3"}
assert {s.box for s in by_label["сводка"].sources} == {"A1", "A2"}
secret = by_label["Секрет"]
assert secret.sources[0].box is None and secret.sources[0].tunnel
local = by_label["Локальный"]
assert local.targets[0].tunnel
def test_parse_empty_raises():
with pytest.raises(Idef0ParseError, match="пустой"):
parse_idef0(" \n # only comment\n")
def test_parse_bad_line_raises():
with pytest.raises(Idef0ParseError, match="строку"):
parse_idef0("[A0] Box\nthis is not an arrow\n")
def test_render_sample_png_and_svg(tmp_path):
d = parse_idef0(SAMPLE_CONTEXT)
png, svg = render_idef0(d, scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
assert svg is not None
for needle in (
"Распорядиться товаром",
"A-0",
"Спрос",
"Нормативная документация",
"Товар",
"Персонал",
"PUBLICATION",
"TOP",
"A0",
"Кинзябулатов",
):
_svg_has_words(svg, needle)
img = Image.open(__import__("io").BytesIO(png))
assert img.size[0] >= 1000
assert img.size[1] >= 700
extrema = img.convert("L").getextrema()
assert extrema[0] < 250 # has ink, not a blank page
out_png = tmp_path / "a.png"
out_svg = tmp_path / "a.svg"
assert write_idef0_outputs(SAMPLE_CONTEXT, out_png, out_svg, scale=1)
assert out_png.read_bytes().startswith(b"\x89PNG")
assert "NODE" in out_svg.read_text(encoding="utf-8")
def test_render_decomposition_does_not_crash():
src = """
node A0
title Распорядиться товаром
[A1] Принять заказ
[A2] Отгрузить
[A3] Учесть
A1 <- Заявка [I1]
A1 -> A2 : заказ
A2 -> A3 : накладная
A3 -> Товар [O1]
A1 ^ Правила [C1]
A2 v Склад [M1]
"""
png, svg = render_idef0(parse_idef0(src), scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
assert "Принять заказ" in (svg or "")
assert "заказ" in (svg or "")
def test_render_sample_decomposition_png_and_svg():
png, svg = render_idef0(parse_idef0(SAMPLE_DECOMPOSITION), scale=1.0, want_svg=True)
assert png.startswith(b"\x89PNG")
text = svg or ""
for needle in (
"Принять и оценить спрос",
"Закупить и хранить",
"план закупок",
"график поставок",
"Персонал",
"C1",
"A3",
):
_svg_has_words(text, needle)
assert "Purpose:" not in text
assert "Viewpoint:" not in text
assert "A4" not in text
img = Image.open(__import__("io").BytesIO(png))
assert img.size[0] >= 1400
assert img.size[1] >= 900
extrema = img.convert("L").getextrema()
assert extrema[0] < 250
def test_render_diagram_idef0_skips_jar_and_kroki(tmp_path):
calls = {"jar": 0, "kroki": 0}
def fake_jar(*args, **kwargs):
calls["jar"] += 1
return False
def fake_kroki(*args, **kwargs):
calls["kroki"] += 1
return False
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1)
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
patch("md2gost.diagram_renderer._render_kroki", side_effect=fake_kroki):
result = render_diagram(
"idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1,
)
assert calls["jar"] == 0
assert calls["kroki"] == 0
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
assert result.pixel_scale == 1
def test_render_diagram_idef0_scale_and_cache(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=2)
r1 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2)
r2 = render_diagram("idef0", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=2)
assert r1.png_path == r2.png_path
assert r1.pixel_scale == 2
img = Image.open(r1.png_path)
assert img.size[0] >= 2000
def test_render_diagram_idef0_bad_source(tmp_path):
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
with pytest.raises(RuntimeError, match="IDEF0"):
render_diagram("idef0", "not a diagram", cache_dir=str(tmp_path))
def test_factory_routes_idef0_to_diagram():
from docx import Document
from md2gost import package_dir
import os
md = "%ctx Контекст A-0\n\n```idef0\n" + SAMPLE_CONTEXT + "```\n"
parsed = markdown.parse(md)
doc = Document(os.path.join(package_dir(), "Template.docx"))
factory = RenderableFactory(doc._body)
caption = None
renderables = []
for el in parsed.children:
from marko.block import BlankLine
if isinstance(el, BlankLine):
continue
if isinstance(el, CapEl):
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
continue
renderables.append(factory.create(el, caption))
caption = None
assert len(renderables) == 1
assert isinstance(renderables[0], DiagramFigure)
assert renderables[0]._lang == "idef0"
+52
View File
@@ -16,6 +16,14 @@ from md2gost.renderable.caption import CaptionInfo
from md2gost.renderable.image import Image
from md2gost.renderable.table import Table
from md2gost.renderer import Renderer
from docx.oxml.ns import qn
def _section_page_start(section):
pg = section._sectPr.find(qn("w:pgNumType"))
if pg is None:
return None
return pg.get(qn("w:start"))
def test_content_size_landscape_wider():
@@ -57,6 +65,50 @@ def test_renderer_landscape_section_for_table():
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
assert int(doc.sections[-1].page_width) < int(doc.sections[-1].page_height)
# Landscape and return-to-portrait must not restart PAGE (no w:start).
for section in land2:
assert _section_page_start(section) is None
assert _section_page_start(doc.sections[-1]) is None
def test_landscape_sections_have_no_page_restart():
"""+landscape must not leave w:pgNumType/@w:start on album / following portrait."""
doc = Document(os.path.join(package_dir(), "Template.docx"))
body = doc._body._element
for child in list(body):
if child.tag.endswith("}sectPr"):
continue
body.remove(child)
from md2gost.renderable.heading import Heading
from md2gost.renderable.paragraph import Paragraph
from md2gost.renderable.toc import ToC
h = Heading(doc._body, 1, False)
h.add_run("СОДЕРЖАНИЕ")
toc = ToC(doc._body, toc_mode="native")
para = Paragraph(doc._body)
para.add_run("Текст перед широкой таблицей.")
table = Table(doc._body, 2, 3, CaptionInfo("wide", "Карта", landscape=True))
for r in range(2):
for c in range(3):
table.add_paragraph_to_cell(r, c).add_run(f"{r}{c}")
after = Paragraph(doc._body)
after.add_run("Текст после альбома.")
Renderer(doc, skip_numbering=True).process([h, toc, para, table, after])
land = [s for s in doc.sections if s.orientation == WD_ORIENT.LANDSCAPE]
assert land
for section in land:
assert _section_page_start(section) is None, "landscape restarted PAGE"
# Body portrait after landscape also continuous.
assert doc.sections[-1].orientation == WD_ORIENT.PORTRAIT
assert _section_page_start(doc.sections[-1]) is None
# Non-first sections must not restart (first may still carry template start).
for section in list(doc.sections)[1:]:
assert _section_page_start(section) is None
def test_parser_propagates_landscape_to_table():
doc = Document(os.path.join(package_dir(), "Template.docx"))
+49
View File
@@ -201,6 +201,15 @@ def test_preprocess_skips_uml_and_mermaid_fences():
"```\n\n"
"```uml-c4context\n"
'Person(user, "Заявитель")\n'
"```\n\n"
"```idef0\n"
'title "Выручка"\n'
"[A0] Процесс\n"
"```\n\n"
"```dfd\n"
'entity User\n'
'process App\n'
'User --> App "request"\n'
"```\n"
)
out = preprocess_markdown(md)
@@ -209,6 +218,10 @@ def test_preprocess_skips_uml_and_mermaid_fences():
assert 'title "Выручка"' in out
assert 'Person(user, "Заявитель")' in out
assert "«Акционеры»" not in out
assert "[A0] Процесс" in out
assert "entity User" in out
assert 'User --> App "request"' in out
assert "«request»" not in out
def test_formula_refs():
@@ -247,6 +260,42 @@ def test_biblio_entries_from_concatenated_paragraph():
assert flat[0].key == "1"
def test_fold_bibliography_drops_citation_mangled_residue():
"""Citation linking turns [5]: into []: in .text — must still be consumed."""
import docx
from md2gost.biblio_processor import (
fold_bibliography,
is_biblio_source_paragraph,
)
from md2gost.bibliography_renderable import Bibliography
from md2gost.renderable.heading import Heading
from md2gost.renderable.paragraph import Paragraph
assert is_biblio_source_paragraph("[1]: Книга. — М., 2024.")
assert is_biblio_source_paragraph("[]: Книга. — М., 2024.")
assert is_biblio_source_paragraph("[]: https://example.com/a")
assert not is_biblio_source_paragraph("Обычный абзац без источника.")
doc = docx.Document(r"md2gost/Template.docx")
doc._body.clear_content()
h = Heading(doc._body, 1, numbered=False)
h.add_run("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ")
residue = Paragraph(doc._body)
residue.add_run("[]: Остаток после гиперссылки на цитату.")
prose = Paragraph(doc._body)
prose.add_run("Это уже не библиография.")
md = """# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Книга. М., 2024.
"""
out = fold_bibliography([h, residue, prose], doc._body, raw_markdown=md)
types = [type(x).__name__ for x in out]
assert types == ["Heading", "Bibliography", "Paragraph"]
assert isinstance(out[1], Bibliography)
assert out[2] is prose
def test_emdash_to_hyphen_preprocess():
from md2gost.profiles import preprocess_markdown, set_emdash_to_hyphen, dash_separator
out = preprocess_markdown("Цель — тест. Слово - слово.", emdash_to_hyphen=True)
+113 -1
View File
@@ -1,9 +1,28 @@
"""Page number start / offset helpers."""
from pathlib import Path
from zipfile import ZipFile
from docx import Document
from docx.oxml.ns import qn
from lxml import etree
from md2gost.page_geometry import apply_page_number_start, set_section_page_start
from md2gost.page_geometry import (
apply_page_number_start,
apply_section_geometry,
clear_section_page_start,
patch_docx_page_starts,
set_section_page_start,
)
_W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
def _pg_start(section):
pg = section._sectPr.find(qn("w:pgNumType"))
if pg is None:
return None
return pg.get(qn("w:start"))
def test_set_section_page_start():
@@ -33,3 +52,96 @@ def test_apply_page_number_start_none_strips():
apply_page_number_start(doc, None)
pg = doc.sections[0]._sectPr.find(qn("w:pgNumType"))
assert pg is None or pg.get(qn("w:start")) is None
def test_apply_page_number_start_none_strips_all_four_sections():
doc = Document()
for _ in range(3):
doc.add_section()
for section in doc.sections:
set_section_page_start(section, 1)
assert all(_pg_start(s) == "1" for s in doc.sections)
apply_page_number_start(doc, None)
assert all(_pg_start(s) is None for s in doc.sections)
def test_apply_page_number_start_body_only_with_front():
doc = Document()
for _ in range(3):
doc.add_section() # front + body + landscape + portrait = 4
for section in doc.sections:
set_section_page_start(section, 1)
apply_page_number_start(doc, 1, front_sections=1)
assert _pg_start(doc.sections[0]) is None
assert _pg_start(doc.sections[1]) == "1"
assert _pg_start(doc.sections[2]) is None
assert _pg_start(doc.sections[3]) is None
def test_apply_section_geometry_strips_copied_start():
doc = Document()
set_section_page_start(doc.sections[0], 1)
apply_section_geometry(doc.sections[0], landscape=True)
assert _pg_start(doc.sections[0]) is None
def test_clear_section_page_start_removes_empty_element():
doc = Document()
set_section_page_start(doc.sections[0], 1)
clear_section_page_start(doc.sections[0])
assert doc.sections[0]._sectPr.find(qn("w:pgNumType")) is None
def test_patch_docx_page_starts_strips_and_restores(tmp_path: Path):
doc = Document()
for _ in range(3):
doc.add_section()
for section in doc.sections:
set_section_page_start(section, 1)
path = tmp_path / "pages.docx"
doc.save(path)
patch_docx_page_starts(path, None)
with ZipFile(path) as z:
root = etree.fromstring(z.read("word/document.xml"))
starts = [
el.get(f"{_W}start")
for el in root.findall(f".//{_W}pgNumType")
]
assert all(s is None for s in starts)
# Re-seed starts then restore body-only restart.
doc2 = Document(str(path))
for section in doc2.sections:
set_section_page_start(section, 1)
doc2.save(path)
patch_docx_page_starts(path, 2, front_sections=1)
with ZipFile(path) as z:
root = etree.fromstring(z.read("word/document.xml"))
body = root.find(f"{_W}body")
sects = []
for child in body:
if child.tag == f"{_W}sectPr":
sects.append(child)
continue
if child.tag != f"{_W}p":
continue
p_pr = child.find(f"{_W}pPr")
if p_pr is None:
continue
sect = p_pr.find(f"{_W}sectPr")
if sect is not None:
sects.append(sect)
assert len(sects) == 4
vals = [
(s.find(f"{_W}pgNumType").get(f"{_W}start")
if s.find(f"{_W}pgNumType") is not None else None)
for s in sects
]
assert vals[0] is None
assert vals[1] == "2"
assert vals[2] is None
assert vals[3] is None
+1
View File
@@ -156,6 +156,7 @@ def test_prompt_catalog_loads():
assert "generate-md.md" in names
assert "generate-mirea-report.md" in names
assert "generate-pis-custom-report.md" in names
assert "emulate-student.md" in names
assert all(text.strip() for _n, _t, text in catalog)
+291
View File
@@ -0,0 +1,291 @@
"""Tests for title page generation (```title, info_conv.yaml, template fill)."""
from __future__ import annotations
from pathlib import Path
from docx import Document
from md2gost.title_page import (
TitleMeta,
build_title_docx,
load_info_conv,
merge_title_meta,
parse_and_strip_title_fence,
parse_kv_text,
should_generate_title,
)
from md2gost.user_profile import UserProfile, load_user_profile, save_user_profile
def test_parse_title_fence_and_strip():
md = (
"```title\n"
"number: 3\n"
"year: 2025\n"
"discipline: Тестовая дисциплина\n"
"```\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Text.\n"
)
fields, body = parse_and_strip_title_fence(md)
assert fields["number"] == "3"
assert fields["year"] == "2025"
assert fields["discipline"] == "Тестовая дисциплина"
assert "# *ВВЕДЕНИЕ" in body
assert "```title" not in body
def test_parse_kv_russian_aliases_and_typos():
raw = (
"Инститиут: ИИТ\n"
"Кафедра: ЦТ\n"
"Дисциплина: ПАЦО\n"
"Преподователь: Иванов И. И.\n"
"Должность: старший преподаватель\n"
)
y = parse_kv_text(raw)
assert y["institute"] == "ИИТ"
assert y["department"] == "ЦТ"
assert y["discipline"] == "ПАЦО"
assert y["teacher"] == "Иванов И. И."
assert y["teacher_position"] == "старший преподаватель"
def test_merge_fence_overrides_yaml():
yaml_f = {"institute": "ИИТ", "discipline": "Из yaml"}
fence = {"discipline": "Из fence", "number": "2"}
meta = merge_title_meta(
yaml_fields=yaml_f,
fence_fields=fence,
student="Петров П. П.",
group="ИНБО-01-20",
)
assert meta.institute == "ИИТ"
assert meta.discipline == "Из fence"
assert meta.number == "2"
assert meta.student == "Петров П. П."
assert meta.group == "ИНБО-01-20"
# student/group ignored from yaml/fence
meta2 = merge_title_meta(
yaml_fields={"student": "X", "group": "Y", "institute": "Z"},
student="A",
group="B",
)
assert meta2.student == "A" and meta2.group == "B"
assert meta2.institute == "Z"
def test_load_info_conv(tmp_path: Path):
p = tmp_path / "info_conv.yaml"
p.write_text(
"Институт: Институт информационных технологий (ИИТ)\n"
"Кафедра: Кафедра ЦТ\n"
"Преподаватель: Сидоров\n"
"Должность: ассистент\n",
encoding="utf-8",
)
data = load_info_conv(tmp_path)
assert "ИИТ" in data["institute"]
assert data["teacher"] == "Сидоров"
def test_should_generate_title(tmp_path: Path):
assert should_generate_title(
explicit_title="ready.docx",
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=True,
) is False
assert should_generate_title(
explicit_title=None,
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=False,
) is False
assert should_generate_title(
explicit_title=None,
fence_fields={"number": "1"},
md_dir=tmp_path,
enabled=True,
) is True
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=True,
) is False
(tmp_path / "info_conv.yaml").write_text("Институт: X\n", encoding="utf-8")
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=True,
) is True
assert should_generate_title(
explicit_title=None,
fence_fields={},
md_dir=tmp_path,
enabled=False,
) is False
def test_user_profile_roundtrip(tmp_path: Path):
save_user_profile(UserProfile(student="А А А", group="ГР-1"), base=tmp_path)
loaded = load_user_profile(base=tmp_path)
assert loaded.student == "А А А"
assert loaded.group == "ГР-1"
assert loaded.is_complete()
def test_build_title_docx_fills_red_slots(tmp_path: Path):
meta = TitleMeta(
institute="Институт ИИТ",
department="Кафедра ЦТ",
discipline="Дисциплина X",
number="7",
group="ИНБО-99-99",
student="Тестов Тест Тестович",
teacher_position="доцент, кафедра ЦТ",
teacher="Преподов П. П.",
year="2024",
)
out = tmp_path / "title.docx"
build_title_docx(meta, out)
doc = Document(str(out))
texts = [p.text for p in doc.paragraphs]
assert "Институт ИИТ" in texts[0]
assert "Кафедра ЦТ" in texts[1]
assert "Дисциплина X" in texts[5]
assert "7" in texts[8]
assert "ИНБО-99-99" in texts[13]
assert "Тестов Тест Тестович" in texts[15]
assert "доцент" in texts[19]
assert "Преподов" in texts[21]
assert "2024" in texts[28]
# prefixes kept
assert texts[13].startswith("студент группы")
assert "по дисциплине" in texts[5]
assert "Москва" in texts[28]
def test_pipeline_generates_title(tmp_path: Path, monkeypatch):
from md2gost.pipeline import ConvertRequest, convert
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(student="Конвейев К. К.", group="ИНБО-00-00"),
base=tmp_path,
)
(tmp_path / "info_conv.yaml").write_text(
"Институт: ИИТ тест\n"
"Кафедра: Каф тест\n"
"Дисциплина: Дисц тест\n"
"Преподаватель: Преп П. П.\n"
"Должность: ст. преподаватель\n",
encoding="utf-8",
)
md = tmp_path / "report.md"
md.write_text(
"```title\n"
"number: 2\n"
"year: 2026\n"
"```\n"
"\n"
"# *СОДЕРЖАНИЕ\n"
"\n"
"[TOC]\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Текст введения.\n"
"\n"
"# *ЗАКЛЮЧЕНИЕ\n"
"\n"
"Вывод.\n"
"\n"
"# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ\n"
"\n"
"[1]: Книга. — М., 2024.\n",
encoding="utf-8",
)
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
auto_title=True,
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
assert out.is_file()
doc = Document(str(out))
blob = "\n".join(p.text for p in doc.paragraphs)
assert "Конвейев" in blob
assert "ИНБО-00-00" in blob
assert "ИИТ тест" in blob
assert "Дисц тест" in blob
assert "Преп П. П." in blob
def test_pipeline_skips_title_by_default(tmp_path: Path, monkeypatch):
from md2gost.pipeline import ConvertRequest, convert
from md2gost import user_profile as up
monkeypatch.setattr(up, "app_dir", lambda: tmp_path)
save_user_profile(
UserProfile(student="Конвейев К. К.", group="ИНБО-00-00"),
base=tmp_path,
)
(tmp_path / "info_conv.yaml").write_text(
"Институт: ИИТ тест\n"
"Кафедра: Каф тест\n"
"Дисциплина: Дисц тест\n"
"Преподаватель: Преп П. П.\n"
"Должность: ст. преподаватель\n",
encoding="utf-8",
)
md = tmp_path / "report.md"
md.write_text(
"```title\n"
"number: 2\n"
"year: 2026\n"
"```\n"
"\n"
"# *ВВЕДЕНИЕ\n"
"\n"
"Текст введения.\n"
"\n"
"# *ЗАКЛЮЧЕНИЕ\n"
"\n"
"Вывод.\n"
"\n"
"# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ\n"
"\n"
"[1]: Книга. — М., 2024.\n",
encoding="utf-8",
)
out = tmp_path / "report.docx"
result = convert(
ConvertRequest(
filename=str(md),
output=str(out),
table_continuation="off",
listing_continuation="off",
open_when_done=False,
),
log=lambda _m: None,
)
assert result.ok, result.message
doc = Document(str(out))
blob = "\n".join(p.text for p in doc.paragraphs)
assert "Конвейев" not in blob
assert "ИИТ тест" not in blob
assert "Текст введения" in blob
+378
View File
@@ -0,0 +1,378 @@
"""Tests for word2md (DOCX → md2gost Markdown)."""
from __future__ import annotations
from pathlib import Path
from docx import Document
from word2md.captions import caption_unique_id, parse_any_caption
from word2md.classify import classify_heading_text, is_body_start_heading, is_special_title
from word2md.emit import emit_markdown
from word2md.pipeline import ImportRequest, convert_docx
from word2md.refs import apply_reference_pass, rewrite_text
from word2md.blocks import CaptionBlock, HeadingBlock, ParagraphBlock, InlineSpan, TableBlock, ImageBlock
from word2md.walker import postprocess_blocks
from word2md.tables import table_to_grid
def test_parse_captions():
fig = parse_any_caption("Рисунок 1.1 — Архитектура")
assert fig is not None
assert fig.kind == "figure"
assert fig.number == "1.1"
assert fig.text == "Архитектура"
tbl = parse_any_caption("Таблица 2 — Сравнение")
assert tbl is not None and tbl.kind == "table" and tbl.number == "2"
cont = parse_any_caption("Продолжение Таблицы 2.1")
assert cont is not None and cont.is_continuation and cont.kind == "table"
lst = parse_any_caption("Листинг 1 — Код")
assert lst is not None and lst.kind == "listing"
assert caption_unique_id("figure", "1.1") == "fig1_1"
def test_special_headings():
assert is_special_title("ВВЕДЕНИЕ")
assert is_special_title("Список использованных источников")
level, title, numbered = classify_heading_text("ВВЕДЕНИЕ", 1)
assert level == 1 and not numbered and title == "ВВЕДЕНИЕ"
assert is_body_start_heading("ВВЕДЕНИЕ", "Heading 1")
def test_rewrite_refs():
maps = {
"figure": {"1.1": "fig1_1", "3.1": "fig3_1"},
"table": {"2": "tbl2", "1.1": "tbl1_1"},
"listing": {},
"equation": {"1": "eq1"},
}
text = "См. Рисунок 1.1 и табл. 2, а также [1]."
out = rewrite_text(text, maps)
assert "@Рисунок:fig1_1" in out
assert "@Таблица:tbl2" in out
assert "[1]" in out
# trailing sentence period must not stick to the number
assert rewrite_text("сведены в Таблице 1.1.", maps) == "сведены в @Таблица:tbl1_1."
assert rewrite_text("показан на Рисунке 3.1.", maps) == "показан на @Рисунок:fig3_1."
def test_emit_special_and_table():
blocks = [
HeadingBlock(1, "СОДЕРЖАНИЕ", numbered=False),
HeadingBlock(1, "ВВЕДЕНИЕ", numbered=False),
ParagraphBlock(spans=[InlineSpan("Текст со ссылкой на Рисунок 1.")]),
CaptionBlock(kind="table", number="1", text="Сравнение", unique_id="tbl1"),
TableBlock(rows=[["A", "B"], ["1", "2"]], caption_id="tbl1", caption_text="Сравнение"),
]
blocks = apply_reference_pass(blocks)
# no figure map → leave as is for figure
md = emit_markdown(blocks)
assert "# *СОДЕРЖАНИЕ" in md
assert "# *ВВЕДЕНИЕ" in md
assert "%tbl1 Сравнение" in md
assert "| A | B |" in md
def test_escape_literal_asterisk():
"""DOCX stores bare '*'; MD footnotes need '\\*' so Marko won't eat them."""
from word2md.emit import escape_md_inline
assert escape_md_inline("* Для 2025") == "\\* Для 2025"
assert escape_md_inline("1,50*") == "1,50\\*"
assert escape_md_inline("@Рисунок:fig1_1") == "@Рисунок:fig1_1"
md = emit_markdown(
[
ParagraphBlock(spans=[InlineSpan("* Для 2025 года приведён документооборот.")]),
TableBlock(rows=[["Показатель", "2025"], ["ЭДО", "1,50*"]]),
]
)
assert "\\* Для 2025 года" in md
assert "1,50\\*" in md
# dialect heading star stays structural
assert "# *ВВЕДЕНИЕ" == emit_markdown(
[HeadingBlock(1, "ВВЕДЕНИЕ", numbered=False)]
).strip()
def test_postprocess_continuation_tables():
blocks = [
CaptionBlock(kind="table", number="1", text="T", unique_id="tbl1"),
TableBlock(rows=[["A"], ["1"]], caption_id="tbl1"),
CaptionBlock(kind="table", number="1", is_continuation=True, unique_id="tbl1"),
TableBlock(rows=[["2"]]),
]
out = postprocess_blocks(blocks)
tables = [b for b in out if isinstance(b, TableBlock)]
assert len(tables) == 1
assert tables[0].rows == [["A"], ["1"], ["2"]]
def test_postprocess_skips_repeated_header():
blocks = [
CaptionBlock(kind="table", number="1", text="T", unique_id="tbl1"),
TableBlock(rows=[["H1", "H2"], ["1", "a"]], caption_id="tbl1"),
CaptionBlock(kind="table", number="1", is_continuation=True, unique_id="tbl1"),
TableBlock(rows=[["H1", "H2"], ["2", "b"]]),
]
out = postprocess_blocks(blocks)
tables = [b for b in out if isinstance(b, TableBlock)]
assert len(tables) == 1
assert tables[0].rows == [["H1", "H2"], ["1", "a"], ["2", "b"]]
def test_fold_figure_caption_under_image():
"""GOST: picture then «Рисунок N — …» → one image with %id in title."""
blocks = [
ParagraphBlock(spans=[InlineSpan("См. Рисунок 1.1.")]),
ImageBlock(rel_path="media/a.png"),
CaptionBlock(kind="figure", number="1.1", text="Схема", unique_id="fig1_1"),
]
out = postprocess_blocks(blocks)
assert len([b for b in out if isinstance(b, CaptionBlock)]) == 0
imgs = [b for b in out if isinstance(b, ImageBlock)]
assert len(imgs) == 1
assert imgs[0].caption_id == "fig1_1"
assert imgs[0].caption_text == "Схема"
out = apply_reference_pass(out)
md = emit_markdown(out)
assert "@Рисунок:fig1_1" in md
img_line = [l for l in md.splitlines() if l.startswith("![")][0]
assert "%fig1_1" in img_line
def test_sequential_figures_keep_distinct_ids():
"""Caption under fig N must not leak onto figure N+1 via pending."""
blocks = [
ImageBlock(rel_path="a.png"),
CaptionBlock(kind="figure", number="5.3", text="Архитектура", unique_id="fig5_3"),
ImageBlock(rel_path="b.png"),
CaptionBlock(kind="figure", number="5.2", text="DFD", unique_id="fig5_2"),
]
out = postprocess_blocks(blocks)
imgs = [b for b in out if isinstance(b, ImageBlock)]
assert [i.caption_id for i in imgs] == ["fig5_3", "fig5_2"]
def test_docx_continuation_tables_merge(tmp_path: Path):
"""«Продолжение Таблицы N» + fragment must become one markdown table."""
doc = Document()
_ensure_styles(doc)
h = doc.add_paragraph("ВВЕДЕНИЕ")
try:
h.style = "Heading 1"
except KeyError:
pass
cap = doc.add_paragraph("Таблица 1 — Демо")
try:
cap.style = "Название таблицы"
except KeyError:
pass
t1 = doc.add_table(rows=3, cols=2)
t1.cell(0, 0).text = "A"
t1.cell(0, 1).text = "B"
t1.cell(1, 0).text = "1"
t1.cell(1, 1).text = "x"
t1.cell(2, 0).text = "2"
t1.cell(2, 1).text = "y"
cont = doc.add_paragraph("Продолжение Таблицы 1")
try:
cont.style = "Название таблицы"
except KeyError:
pass
t2 = doc.add_table(rows=2, cols=2)
t2.cell(0, 0).text = "3"
t2.cell(0, 1).text = "z"
t2.cell(1, 0).text = "4"
t2.cell(1, 1).text = "w"
path = tmp_path / "cont.docx"
doc.save(path)
out = tmp_path / "cont.md"
result = convert_docx(ImportRequest(filename=str(path), output=str(out)))
assert result.ok, result.message
md = out.read_text(encoding="utf-8")
assert md.count("%tbl1") == 1
assert "Продолжение" not in md
# one header separator only
assert md.count("| --- | --- |") == 1 or md.count("| --- | --- |") == 1
assert "| 3 | z |" in md
assert "| 4 | w |" in md
assert "| 1 | x |" in md
def _ensure_styles(doc: Document) -> None:
from docx.enum.style import WD_STYLE_TYPE
for name, base in (
("Caption Figure", "Caption"),
("Название таблицы", "Caption"),
("Caption Listing", "Caption"),
("Code", "Normal"),
("Bibliography", "Normal"),
):
try:
doc.styles[name]
except KeyError:
try:
st = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
try:
st.base_style = doc.styles[base]
except KeyError:
pass
except Exception:
pass
def test_convert_minimal_docx(tmp_path: Path):
doc = Document()
_ensure_styles(doc)
doc.add_heading("ВВЕДЕНИЕ", level=1)
# unnumbered look: we'll rely on special title text
p = doc.paragraphs[-1]
p.text = "ВВЕДЕНИЕ"
try:
p.style = doc.styles["Heading 1"]
except KeyError:
pass
doc.add_paragraph("Абзац с жирным текстом и ссылкой [1].")
cap = doc.add_paragraph("Таблица 1 — Демо")
try:
cap.style = "Название таблицы"
except KeyError:
pass
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "A"
table.cell(0, 1).text = "B"
table.cell(1, 0).text = "1"
table.cell(1, 1).text = "2"
doc.add_heading("ЗАКЛЮЧЕНИЕ", level=1)
doc.paragraphs[-1].text = "ЗАКЛЮЧЕНИЕ"
doc.add_heading("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", level=1)
doc.paragraphs[-1].text = "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"
bib = doc.add_paragraph("[1]: Иванов И. И. Книга. — М., 2023.")
try:
bib.style = "Bibliography"
except KeyError:
pass
docx_path = tmp_path / "mini.docx"
doc.save(docx_path)
result = convert_docx(ImportRequest(filename=str(docx_path), output=str(tmp_path / "mini.md")))
assert result.ok, result.message
md = Path(result.output_path).read_text(encoding="utf-8")
assert "# *ВВЕДЕНИЕ" in md
assert "%tbl1" in md
assert "| A | B |" in md
assert "[1]:" in md
assert "# *ЗАКЛЮЧЕНИЕ" in md
def test_roundtrip_structure(tmp_path: Path):
"""MD → DOCX (md2gost) → MD (word2md): keep headings / table / biblio shape."""
from md2gost.pipeline import ConvertRequest, convert
md_src = tmp_path / "sample.md"
md_src.write_text(
"""# *ВВЕДЕНИЕ
Текст введения со ссылкой [1].
%cmp Сравнение
| A | B |
|---|---|
| 1 | 2 |
См. Таблица 1 будет заменена после нумерации.
# *ЗАКЛЮЧЕНИЕ
Итог.
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
[1]: Источник один. М., 2023.
""",
encoding="utf-8",
)
docx_out = tmp_path / "sample.docx"
conv = convert(
ConvertRequest(
filename=str(md_src),
output=str(docx_out),
table_continuation="off",
listing_continuation="off",
check=False,
)
)
assert conv.ok, conv.message
md2 = tmp_path / "back.md"
imp = convert_docx(ImportRequest(filename=str(docx_out), output=str(md2)))
assert imp.ok, imp.message
text = md2.read_text(encoding="utf-8")
assert "# *ВВЕДЕНИЕ" in text
assert "# *ЗАКЛЮЧЕНИЕ" in text
assert "| A | B |" in text or "| A |" in text
assert "[1]" in text
assert "%tbl" in text or "Таблица" in text or "|" in text
def test_table_grid_merge_markers():
doc = Document()
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = "X"
table.cell(0, 1).text = "Y"
table.cell(1, 0).text = "Z"
table.cell(1, 1).text = "W"
# merge horizontally first row
table.cell(0, 0).merge(table.cell(0, 1))
rows, merges = table_to_grid(table)
assert len(rows) == 2
# first row should have continue marker for span
assert any(cell == ">" for cell in rows[0]) or merges[0][1][1] == "continue"
def test_nobreak_hyphen_preserved(tmp_path: Path):
"""md2gost writes '-' as w:noBreakHyphen; word2md must restore ASCII hyphen."""
from md2gost.pipeline import ConvertRequest, convert
from md2gost.util import create_element
from docx.oxml.ns import qn
md_src = tmp_path / "hyphen.md"
md_src.write_text(
"# *ВВЕДЕНИЕ\n\n"
"Во-первых, ИТ-компания и 63-ФЗ. Организационно-правовая форма.\n",
encoding="utf-8",
)
docx_out = tmp_path / "hyphen.docx"
conv = convert(
ConvertRequest(
filename=str(md_src),
output=str(docx_out),
table_continuation="off",
listing_continuation="off",
)
)
assert conv.ok, conv.message
# Sanity: DOCX really has noBreakHyphen
doc = Document(str(docx_out))
xml = doc.element.body.xml
assert "noBreakHyphen" in xml
# python-docx .text drops them
joined = " ".join(p.text or "" for p in doc.paragraphs)
assert "Вопервых" in joined.replace(" ", "") or "Во-первых" not in joined
md2 = tmp_path / "back.md"
imp = convert_docx(ImportRequest(filename=str(docx_out), output=str(md2)))
assert imp.ok, imp.message
text = md2.read_text(encoding="utf-8")
assert "Во-первых" in text
assert "ИТ-компания" in text
assert "63-ФЗ" in text
assert "Организационно-правовая" in text
+30
View File
@@ -13,7 +13,10 @@ from md2gost.word_fix import (
continuation_label,
find_page_break_row,
fix_continuations,
is_caption_orphaned,
is_orphan_header_break,
parse_caption_text,
split_vmerge_spans_at_break,
)
@@ -54,6 +57,33 @@ def test_find_page_break_row():
assert find_page_break_row([1, 1, 2, 2]) == 3
assert find_page_break_row([2, 2, 3]) == 3
assert find_page_break_row([1, 2]) == 2
# typical vMerge table pages after cell-based detection
assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7
def test_orphan_header_and_caption_guards():
"""Method guide: no title-only / title+column-headers alone at page end."""
# Split would leave only heading row on previous page
assert is_orphan_header_break(2, had_header=True) is True
assert is_orphan_header_break(2, had_header=False) is False
assert is_orphan_header_break(3, had_header=True) is False
assert is_orphan_header_break(None, had_header=True) is False
# Caption on earlier page than first table row
assert is_caption_orphaned(5, 6) is True
assert is_caption_orphaned(5, 5) is False
assert is_caption_orphaned(None, 6) is False
def test_split_vmerge_spans_at_break():
# (row, col, span, text) — merge rows 2..6 (span 5), break at row 7 → all in first
spans = [(2, 1, 5, "A"), (7, 1, 2, "B")]
first, cont = split_vmerge_spans_at_break(spans, 7)
assert first == [(2, 1, 5, "A")]
assert cont == [(1, 1, 2, "B")]
# merge crosses break: rows 5..8, break at 7
first, cont = split_vmerge_spans_at_break([(5, 1, 4, "X")], 7)
assert first == [(5, 1, 2, "X")]
assert cont == [(1, 1, 2, "X")]
def test_continuation_label():
+1 -1
View File
@@ -1,2 +1,2 @@
- Ожидаем внедрение BPMN в mermaid
- Оценки с текстом через сам Word а не предугадываание
- Проверка «текст могут не принять»: эвристика отчёта ИИ (пишет про сдачу ТЗ, а не про предмет). `--check` + отдельный разбор по абзацам (`--check-voice` / кнопка в GUI). Идеи правил — сравнение живой практики и сгенерированного отчёта: телеология «последующие работы», преамбулы разделов, заключение-чеклист, колонки «значение для объекта», плотность ссылок, консультантский лоск.
Generated
+12 -143
View File
@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.10"
requires-python = ">=3.11"
[[package]]
name = "babel"
@@ -26,22 +26,6 @@ version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" },
{ url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" },
{ url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" },
{ url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" },
{ url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" },
{ url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" },
{ url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" },
{ url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" },
{ url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" },
{ url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" },
{ url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" },
{ url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" },
{ url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" },
@@ -192,6 +176,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "data-flow-diagram"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/90/cd847322671c58555a0de75a53869e0650f217d4f9fe415532a0a8ec1587/data_flow_diagram-1.17.0.tar.gz", hash = "sha256:6856f009fdaaede077c8c2181ab22d3967ee9d908fce7e7e867afb894bb3c8dc", size = 33331, upload-time = "2026-09-03T16:00:42.656Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/fd/988b2c4ca0dba660ccf021bf732ae3b37e7a5d85bdf0182f1f0225a78571/data_flow_diagram-1.17.0-py3-none-any.whl", hash = "sha256:60dc1bb98b4249383b262bd71f418202c3b95f8d7b316a205747883e99eafaf1", size = 32213, upload-time = "2026-09-03T16:00:41.267Z" },
]
[[package]]
name = "docxcompose"
version = "1.4.1"
@@ -207,18 +200,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/03/053f291ee7ad6a3fd5a20eef3ff73925fe815803335180808582719f2c06/docxcompose-1.4.1-py3-none-any.whl", hash = "sha256:8a0a5c6f5bc8f4c0b2691d9714aac7195c05229c24d2dffc2222b5bdec547258", size = 24120, upload-time = "2026-03-14T17:42:50.667Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "freetype-py"
version = "2.5.1"
@@ -239,15 +220,6 @@ version = "3.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" },
{ url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" },
{ url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" },
{ url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" },
{ url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" },
{ url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" },
{ url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" },
@@ -352,23 +324,6 @@ version = "5.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/1f/a3b6b74a451ceb84b471caa75c934d2430a4d84395d38ef201d539f38cd1/lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c", size = 8076838, upload-time = "2025-04-23T01:44:29.325Z" },
{ url = "https://files.pythonhosted.org/packages/36/af/a567a55b3e47135b4d1f05a1118c24529104c003f95851374b3748139dc1/lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7", size = 4381827, upload-time = "2025-04-23T01:44:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/50/ba/4ee47d24c675932b3eb5b6de77d0f623c2db6dc466e7a1f199792c5e3e3a/lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf", size = 5204098, upload-time = "2025-04-23T01:44:35.809Z" },
{ url = "https://files.pythonhosted.org/packages/f2/0f/b4db6dfebfefe3abafe360f42a3d471881687fd449a0b86b70f1f2683438/lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28", size = 4930261, upload-time = "2025-04-23T01:44:38.271Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1f/0bb1bae1ce056910f8db81c6aba80fec0e46c98d77c0f59298c70cd362a3/lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609", size = 5529621, upload-time = "2025-04-23T01:44:40.921Z" },
{ url = "https://files.pythonhosted.org/packages/21/f5/e7b66a533fc4a1e7fa63dd22a1ab2ec4d10319b909211181e1ab3e539295/lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4", size = 4983231, upload-time = "2025-04-23T01:44:43.871Z" },
{ url = "https://files.pythonhosted.org/packages/11/39/a38244b669c2d95a6a101a84d3c85ba921fea827e9e5483e93168bf1ccb2/lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7", size = 5084279, upload-time = "2025-04-23T01:44:46.632Z" },
{ url = "https://files.pythonhosted.org/packages/db/64/48cac242347a09a07740d6cee7b7fd4663d5c1abd65f2e3c60420e231b27/lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f", size = 4927405, upload-time = "2025-04-23T01:44:49.843Z" },
{ url = "https://files.pythonhosted.org/packages/98/89/97442835fbb01d80b72374f9594fe44f01817d203fa056e9906128a5d896/lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997", size = 5550169, upload-time = "2025-04-23T01:44:52.791Z" },
{ url = "https://files.pythonhosted.org/packages/f1/97/164ca398ee654eb21f29c6b582685c6c6b9d62d5213abc9b8380278e9c0a/lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c", size = 5062691, upload-time = "2025-04-23T01:44:56.108Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bc/712b96823d7feb53482d2e4f59c090fb18ec7b0d0b476f353b3085893cda/lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b", size = 5133503, upload-time = "2025-04-23T01:44:59.222Z" },
{ url = "https://files.pythonhosted.org/packages/d4/55/a62a39e8f9da2a8b6002603475e3c57c870cd9c95fd4b94d4d9ac9036055/lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b", size = 4999346, upload-time = "2025-04-23T01:45:02.088Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/a393728ae001b92bb1a9e095e570bf71ec7f7fbae7688a4792222e56e5b9/lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563", size = 5627139, upload-time = "2025-04-23T01:45:04.582Z" },
{ url = "https://files.pythonhosted.org/packages/5e/5f/9dcaaad037c3e642a7ea64b479aa082968de46dd67a8293c541742b6c9db/lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5", size = 5465609, upload-time = "2025-04-23T01:45:07.649Z" },
{ url = "https://files.pythonhosted.org/packages/a7/0a/ebcae89edf27e61c45023005171d0ba95cb414ee41c045ae4caf1b8487fd/lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776", size = 5192285, upload-time = "2025-04-23T01:45:10.456Z" },
{ url = "https://files.pythonhosted.org/packages/42/ad/cc8140ca99add7d85c92db8b2354638ed6d5cc0e917b21d36039cb15a238/lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7", size = 3477507, upload-time = "2025-04-23T01:45:12.474Z" },
{ url = "https://files.pythonhosted.org/packages/e9/39/597ce090da1097d2aabd2f9ef42187a6c9c8546d67c419ce61b88b336c85/lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250", size = 3805104, upload-time = "2025-04-23T01:45:15.104Z" },
{ url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" },
{ url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" },
@@ -420,12 +375,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" },
{ url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" },
{ url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b0/e4d1cbb8c078bc4ae44de9c6a79fec4e2b4151b1b4d50af71d799e76b177/lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55", size = 3892319, upload-time = "2025-04-23T01:49:22.069Z" },
{ url = "https://files.pythonhosted.org/packages/5b/aa/e2bdefba40d815059bcb60b371a36fbfcce970a935370e1b367ba1cc8f74/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740", size = 4211614, upload-time = "2025-04-23T01:49:24.599Z" },
{ url = "https://files.pythonhosted.org/packages/3c/5f/91ff89d1e092e7cfdd8453a939436ac116db0a665e7f4be0cd8e65c7dc5a/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5", size = 4306273, upload-time = "2025-04-23T01:49:27.355Z" },
{ url = "https://files.pythonhosted.org/packages/be/7c/8c3f15df2ca534589717bfd19d1e3482167801caedfa4d90a575facf68a6/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37", size = 4208552, upload-time = "2025-04-23T01:49:29.949Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d8/9567afb1665f64d73fc54eb904e418d1138d7f011ed00647121b4dd60b38/lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571", size = 4331091, upload-time = "2025-04-23T01:49:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ab/fdbbd91d8d82bf1a723ba88ec3e3d76c022b53c391b0c13cad441cdb8f9e/lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4", size = 3487862, upload-time = "2025-04-23T01:49:36.296Z" },
]
[[package]]
@@ -442,6 +391,7 @@ name = "md2gost"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "data-flow-diagram" },
{ name = "docxcompose" },
{ name = "freetype-py" },
{ name = "latex2mathml" },
@@ -467,6 +417,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "data-flow-diagram", specifier = ">=1.16.0,<2" },
{ name = "docxcompose", specifier = ">=1.4.0,<2" },
{ name = "freetype-py", specifier = ">=2.4.0,<3" },
{ name = "latex2mathml", specifier = ">=3.76.0,<4" },
@@ -513,17 +464,6 @@ version = "10.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059, upload-time = "2024-07-01T09:48:43.583Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271, upload-time = "2024-07-01T09:45:22.07Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658, upload-time = "2024-07-01T09:45:25.292Z" },
{ url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075, upload-time = "2024-07-01T09:45:27.94Z" },
{ url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808, upload-time = "2024-07-01T09:45:30.305Z" },
{ url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290, upload-time = "2024-07-01T09:45:32.868Z" },
{ url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163, upload-time = "2024-07-01T09:45:35.279Z" },
{ url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100, upload-time = "2024-07-01T09:45:37.74Z" },
{ url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880, upload-time = "2024-07-01T09:45:39.89Z" },
{ url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218, upload-time = "2024-07-01T09:45:42.771Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487, upload-time = "2024-07-01T09:45:45.176Z" },
{ url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219, upload-time = "2024-07-01T09:45:47.274Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265, upload-time = "2024-07-01T09:45:49.812Z" },
{ url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655, upload-time = "2024-07-01T09:45:52.462Z" },
{ url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304, upload-time = "2024-07-01T09:45:55.006Z" },
@@ -557,13 +497,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603, upload-time = "2024-07-01T09:47:03.918Z" },
{ url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972, upload-time = "2024-07-01T09:47:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375, upload-time = "2024-07-01T09:47:09.065Z" },
{ url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889, upload-time = "2024-07-01T09:48:04.815Z" },
{ url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160, upload-time = "2024-07-01T09:48:07.206Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020, upload-time = "2024-07-01T09:48:09.66Z" },
{ url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539, upload-time = "2024-07-01T09:48:12.529Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125, upload-time = "2024-07-01T09:48:14.891Z" },
{ url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373, upload-time = "2024-07-01T09:48:17.601Z" },
{ url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661, upload-time = "2024-07-01T09:48:20.293Z" },
]
[[package]]
@@ -621,12 +554,10 @@ version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
@@ -647,9 +578,6 @@ name = "pywin32"
version = "312"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" },
{ url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" },
{ url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" },
{ url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" },
@@ -682,11 +610,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/dc/12f6b77802a4b5a56e3f03756d21cc9247f3cd6399ccd3c51b2753d72aa5/quickjs_ng-0.16.2.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c131d2fb5c9741ad2da9475807453dc98a31fde1df5f096e4037b1a7ae576025", size = 2527814, upload-time = "2026-09-01T12:00:03.318Z" },
{ url = "https://files.pythonhosted.org/packages/d2/c0/3d50214bb88361541712d15da7db0f291daa01294c540fc9f5f0d602c750/quickjs_ng-0.16.2.1-cp310-abi3-win32.whl", hash = "sha256:020eebb22ed98e3bd3ab44dc9225aff0186ee72df8a152d894b7ac55b079e295", size = 395822, upload-time = "2026-09-01T12:00:04.582Z" },
{ url = "https://files.pythonhosted.org/packages/57/f7/1b02fd4e531f31cdbe5fb2418b0b25587d3b721137fa9c7e457a003fd2d8/quickjs_ng-0.16.2.1-cp310-abi3-win_amd64.whl", hash = "sha256:7dd5f9fd78917c4c444c52eb2029f51610487bc219f5ca1b2766bcbd98b8afc3", size = 521986, upload-time = "2026-09-01T12:00:05.958Z" },
{ url = "https://files.pythonhosted.org/packages/9c/3b/ed008e94fd3683ee2aa68cb3675faf68b77e9d4922999888ade4cdca1b9b/quickjs_ng-0.16.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:7dacabcd25165e07397a19a5dc8877dc537792a6a00d47e4574922ee6ef74778", size = 475846, upload-time = "2026-09-01T12:00:07.3Z" },
{ url = "https://files.pythonhosted.org/packages/c5/50/46f4a7ea0a044ee1577e391f5a9188db5be848037305c4813487e003282e/quickjs_ng-0.16.2.1-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64e781335f02005a5df4f30276805c94a7d212d6769cd0118adce716e1012224", size = 522754, upload-time = "2026-09-01T12:00:08.724Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/fa9bf4a1616403faf0d2cde932b814d58ed32c3e350f4566f964156d1a1f/quickjs_ng-0.16.2.1-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a089906381c0479a73f14dd46ee31c2ad26874b715e92c130bd08f51637b3b8d", size = 597645, upload-time = "2026-09-01T12:00:10.26Z" },
{ url = "https://files.pythonhosted.org/packages/62/b8/48da371ee5942d547c9c0135f2be5ddca4dac1036530eeb41611f6be128e/quickjs_ng-0.16.2.1-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23daaec0e776f1e7c6fe0c55f396f06ff955635c4cc424e6e586158dca1436f", size = 555215, upload-time = "2026-09-01T12:00:11.602Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b5/e2208efdee95fa86b1292b314a778b213aed917c3771259cd0814104efa7/quickjs_ng-0.16.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4b2818745645dec00a7659b81788654b2409a102e0e33a452353f6089b6ca807", size = 522051, upload-time = "2026-09-01T12:00:13.118Z" },
{ url = "https://files.pythonhosted.org/packages/6b/1c/e5ab0fe0bd8fd668e91511b580ea2a16e04c0fd354ac9ccf58f8ad5c99d3/quickjs_ng-0.16.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f1c4b9741fca4cd29e0212b293a5d3a9ecda85a87d66df7bcfbcf44bef282fa2", size = 475770, upload-time = "2026-09-01T12:00:14.397Z" },
{ url = "https://files.pythonhosted.org/packages/4e/4c/16a9c034989b1dbea312d0d5fd9e71af2465f5203e5140e1740bf5d43278/quickjs_ng-0.16.2.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688942b54540df1bbca2c2c4f67a167df604df17bc76b95857b72507e195a31d", size = 522754, upload-time = "2026-09-01T12:00:15.97Z" },
{ url = "https://files.pythonhosted.org/packages/4c/63/6be5cc1d20d896f1069d362668362f689c55f5b2b2c88be3bc65fe070300/quickjs_ng-0.16.2.1-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:27771a34e74aa91fc739a60c6c8c5aa4eb1d38d8ca8a1fe03485bd91d1f27a86", size = 597645, upload-time = "2026-09-01T12:00:17.427Z" },
@@ -792,60 +715,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/05/e6/2f1cdca71adb5864a97ad06c0f0b509e9ab9592b3da83f2dd5708a61ec68/termaid-0.8.0-py3-none-any.whl", hash = "sha256:3b572ed705bbca77eeffcbc1906a73f1c8119613a0552bb2ff7463a2672b1cb3", size = 159955, upload-time = "2026-07-29T08:52:04.707Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
+9
View File
@@ -0,0 +1,9 @@
# word2md
Импорт **DOCX → Markdown** в диалекте md2gost.
```bash
python -m word2md report.docx -o report.md
```
Документация: [`docs/word2md.md`](../docs/word2md.md).
+7
View File
@@ -0,0 +1,7 @@
"""Word DOCX → Markdown (диалект md2gost)."""
__version__ = "0.1.0"
from .pipeline import ImportRequest, ImportResult, convert_docx
__all__ = ["ImportRequest", "ImportResult", "convert_docx", "__version__"]
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python
"""CLI: DOCX → Markdown (диалект md2gost)."""
from __future__ import annotations
import argparse
import sys
from .pipeline import ImportRequest, convert_docx
def main() -> None:
p = argparse.ArgumentParser(
prog="word2md",
description=(
"Импорт DOCX в Markdown диалекта md2gost "
"(заголовки # *, подписи %id, @Рисунок:id, таблицы, листинги)."
),
)
p.add_argument("filename", help="Исходный .docx")
p.add_argument(
"-o", "--output",
default=None,
help="Путь к .md (по умолчанию: <stem>.md рядом с docx)",
)
p.add_argument(
"--media-dir",
default=None,
help="Каталог для картинок (по умолчанию: <stem>_media рядом с .md)",
)
p.add_argument(
"--keep-toc-pages",
action=argparse.BooleanOptionalAction,
default=False,
help="Не выкидывать строки содержания Word (по умолчанию выкл.: только [TOC])",
)
p.add_argument(
"--pagebreaks",
action=argparse.BooleanOptionalAction,
default=False,
help="Писать --- на разрывах страниц Word (для --hr-pagebreak при обратной сборке)",
)
args = p.parse_args()
result = convert_docx(
ImportRequest(
filename=args.filename,
output=args.output,
media_dir=args.media_dir,
keep_toc_pages=args.keep_toc_pages,
emit_page_breaks=args.pagebreaks,
)
)
sys.exit(result.exit_code)
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
"""Intermediate IR blocks between DOCX walk and Markdown emit."""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class InlineSpan:
text: str
bold: bool = False
italic: bool = False
strike: bool = False
href: str | None = None # external URL
math: str | None = None # inline latex-ish
@dataclass
class HeadingBlock:
level: int
text: str
numbered: bool = True # False → # *TITLE
@dataclass
class ParagraphBlock:
spans: list[InlineSpan] = field(default_factory=list)
style: str = "Normal"
is_bibliography: bool = False
biblio_key: str | None = None
@dataclass
class CaptionBlock:
kind: str # figure | table | listing
number: str
text: str | None = None
is_continuation: bool = False
landscape: bool = False
unique_id: str = ""
@dataclass
class ImageBlock:
rel_path: str # relative to md file
alt: str = ""
caption_id: str | None = None
caption_text: str | None = None
@dataclass
class TableBlock:
rows: list[list[str]]
merges: list[list[tuple[str, str]]] | None = None # (v, h) per cell
caption_id: str | None = None
caption_text: str | None = None
landscape: bool = False
@dataclass
class ListingBlock:
lines: list[str]
language: str = ""
caption_id: str | None = None
caption_text: str | None = None
@dataclass
class EquationBlock:
latex: str
number: str | None = None
unique_id: str | None = None
@dataclass
class ListBlock:
ordered: bool
items: list[str]
level: int = 1
@dataclass
class TocPlaceholder:
"""Emitted as # *СОДЕРЖАНИЕ + [TOC]."""
@dataclass
class PageBreakBlock:
pass
@dataclass
class SkipNote:
"""Logged skip; not emitted."""
message: str
Block = (
HeadingBlock
| ParagraphBlock
| CaptionBlock
| ImageBlock
| TableBlock
| ListingBlock
| EquationBlock
| ListBlock
| TocPlaceholder
| PageBreakBlock
| SkipNote
)
+85
View File
@@ -0,0 +1,85 @@
"""Parse ГОСТ-style captions: Рисунок / Таблица / Листинг (+ продолжения)."""
from __future__ import annotations
import re
from dataclasses import dataclass
from md2gost.word_fix import parse_caption_text
_FIGURE_RE = re.compile(
r"^(?:Рисунок|Рис\.?)\s+(?P<number>[\d.]+)\s*(?:[—–\-]\s*(?P<title>.+))?$",
re.IGNORECASE,
)
_TABLE_FULL_RE = re.compile(
r"^(?:Продолжение\s+)?"
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
r"(?P<number>[\d.]+)"
r"(?:\s*[—–\-]\s*(?P<title>.+))?$",
re.IGNORECASE,
)
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
@dataclass
class ParsedCaption:
kind: str # figure | table | listing
number: str
text: str | None = None
is_continuation: bool = False
def caption_unique_id(kind: str, number: str) -> str:
"""Stable id from display number: 1.1 → fig1_1 / tbl1_1 / lst1_1."""
safe = str(number).replace(".", "_")
prefix = {"figure": "fig", "table": "tbl", "listing": "lst"}.get(kind, "obj")
return f"{prefix}{safe}"
def parse_any_caption(text: str) -> ParsedCaption | None:
"""Parse figure / table / listing captions (including «Продолжение…»)."""
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
if not raw:
return None
first = raw.split("\n", 1)[0].strip()
m = _FIGURE_RE.match(first)
if m:
title = (m.group("title") or "").strip().rstrip(".") or None
return ParsedCaption(kind="figure", number=m.group("number"), text=title)
info = parse_caption_text(first)
if info is not None:
title = None
m2 = _TABLE_FULL_RE.match(first)
if m2 and m2.group("title"):
title = m2.group("title").strip().rstrip(".") or None
else:
# Strip leading «Таблица N — » / «Продолжение…» for leftover title
rest = _TABLE_FULL_RE.sub("", first).strip()
if rest.startswith(("", "", "-")):
rest = rest[1:].strip()
title = rest.rstrip(".") or None if rest else None
return ParsedCaption(
kind=info.kind,
number=info.number,
text=title,
is_continuation=info.is_continuation,
)
m3 = _TABLE_FULL_RE.match(first)
if m3:
kind_raw = m3.group("kind").lower()
if kind_raw.startswith("табл"):
kind = "table"
elif kind_raw.startswith("лист"):
kind = "listing"
else:
return None
return ParsedCaption(
kind=kind,
number=m3.group("number"),
text=(m3.group("title") or "").strip().rstrip(".") or None,
is_continuation=bool(_CONTINUATION_RE.match(first)),
)
return None
+167
View File
@@ -0,0 +1,167 @@
"""Classify DOCX paragraphs/tables into IR kinds (style first, then heuristics)."""
from __future__ import annotations
import re
from md2gost.renderable.toc import SPECIAL_TITLES
from .captions import parse_any_caption
CAPTION_STYLE_KINDS = {
"Caption Figure": "figure",
"Название таблицы": "table",
"Caption Table": "table",
"Caption Listing": "listing",
"Caption": None, # need text heuristic
}
HEADING_STYLE_RE = re.compile(r"^(?:Heading|Заголовок)\s*(\d+)$", re.IGNORECASE)
TOC_STYLE_RE = re.compile(r"^toc\s*\d+$", re.IGNORECASE)
SPECIAL_UPPER = {t.upper() for t in SPECIAL_TITLES}
APPENDIX_RE = re.compile(
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
re.IGNORECASE,
)
NUMBERED_HEADING_RE = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.+)$")
PRACTICE_RE = re.compile(r"^Практическая\s+работа\b", re.IGNORECASE)
LIST_MARKER_RE = re.compile(r"^(?:[–—\-•▪]|(\d+)\.)\s*\t?\s*(.*)$")
BIBLIO_LINE_RE = re.compile(r"^\[(\d+(?:\.\d+)?)\]\s*[:.]?\s*(.+)$")
BODY_START_TITLES = SPECIAL_UPPER | {"СОДЕРЖАНИЕ"}
def style_name(paragraph) -> str:
try:
if paragraph.style is None:
return ""
return paragraph.style.name or ""
except Exception:
return ""
def is_toc_style(name: str) -> bool:
return bool(TOC_STYLE_RE.match((name or "").strip()))
def heading_level_from_style(name: str) -> int | None:
m = HEADING_STYLE_RE.match((name or "").strip())
if not m:
return None
try:
level = int(m.group(1))
except ValueError:
return None
return level if 1 <= level <= 9 else None
def is_code_style(name: str) -> bool:
n = (name or "").strip().lower()
return n == "code" or n.startswith("code ")
def is_bibliography_style(name: str) -> bool:
n = (name or "").strip().lower()
return n in ("bibliography", "bibliography heading")
def is_caption_style(name: str) -> bool:
return (name or "").strip() in CAPTION_STYLE_KINDS
def caption_kind_from_style(name: str) -> str | None:
return CAPTION_STYLE_KINDS.get((name or "").strip())
def is_special_title(text: str) -> bool:
t = (text or "").strip().upper()
if t in SPECIAL_UPPER:
return True
# allow «СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ» variants already in set
if t.startswith("СПИСОК ИСПОЛЬЗ"):
return True
return False
def is_body_start_heading(text: str, style: str = "") -> bool:
"""True if this heading marks the start of report body (skip title pages before it)."""
t = (text or "").strip()
if not t:
return False
if heading_level_from_style(style) or style.lower().startswith("heading"):
upper = t.upper()
if upper.lstrip("* ").upper() in BODY_START_TITLES or is_special_title(t):
return True
if PRACTICE_RE.match(t):
return True
if NUMBERED_HEADING_RE.match(t):
return True
if APPENDIX_RE.match(t):
return True
# Any Heading 1 after front matter
if heading_level_from_style(style) == 1:
return True
# Heuristic without style: special titles alone
if is_special_title(t) or PRACTICE_RE.match(t):
return True
return False
def classify_heading_text(text: str, style_level: int | None) -> tuple[int, str, bool]:
"""
Return (level, title_text, numbered).
numbered=False emit as # *TITLE
"""
raw = (text or "").strip()
level = style_level or 1
app = APPENDIX_RE.match(raw)
if app:
letter = app.group(1).upper()
name = (app.group(2) or "").strip()
title = f"Приложение {letter}" + (f" {name}" if name else "")
return 2, title, True
if is_special_title(raw):
return 1, raw.upper(), False
m = NUMBERED_HEADING_RE.match(raw)
if m:
# keep digits in text for manual numbering dialect
return level, raw, True
if PRACTICE_RE.match(raw):
return 1, raw, True
return level, raw, True
def look_like_list_item(text: str) -> tuple[bool, bool, str] | None:
"""
Detect md2gost-style list lines (\\t / 1.\\t) or plain markers.
Returns (ordered, marker_ok, body) or None.
"""
t = (text or "").strip()
if not t:
return None
if t[0] in ("", "", "-", "", "") and (len(t) == 1 or t[1] in "\t "):
body = t[1:].lstrip("\t ").strip()
return False, True, body
m = re.match(r"^(\d+)\.\s*\t?\s*(.+)$", t)
if m:
return True, True, m.group(2).strip()
return None
def parse_biblio_line(text: str) -> tuple[str, str] | None:
m = BIBLIO_LINE_RE.match((text or "").strip())
if not m:
return None
return m.group(1), m.group(2).strip()
def paragraph_has_numpr(paragraph) -> bool:
pPr = paragraph._element.pPr
if pPr is None:
return False
return pPr.numPr is not None
+249
View File
@@ -0,0 +1,249 @@
"""Emit md2gost dialect Markdown from IR blocks."""
from __future__ import annotations
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
HeadingBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
PageBreakBlock,
ParagraphBlock,
TableBlock,
TocPlaceholder,
)
# Characters that must stay literal after DOCX→MD (Word stores bare '*', not '\*').
# Do not escape '_': md2gost labels use it (@Рисунок:fig1_1).
_MD_SPECIAL = str.maketrans(
{
"\\": "\\\\",
"*": "\\*",
"`": "\\`",
}
)
def escape_md_inline(text: str) -> str:
"""Escape Markdown inline metacharacters in plain text from DOCX."""
if not text:
return ""
return text.translate(_MD_SPECIAL)
def _escape_md_cell(text: str) -> str:
return escape_md_inline(text or "").replace("|", "\\|").replace("\n", " ")
def _render_spans(spans: list[InlineSpan]) -> str:
parts: list[str] = []
for s in spans:
if s.math:
parts.append(f"${s.math}$")
continue
text = escape_md_inline(s.text or "")
if s.href:
parts.append(f"[{text}]({s.href})")
continue
if s.bold and s.italic:
text = f"***{text}***"
elif s.bold:
text = f"**{text}**"
elif s.italic:
text = f"*{text}*"
if s.strike:
text = f"~~{text}~~"
parts.append(text)
return "".join(parts)
def _emit_caption_line(unique_id: str, text: str | None = None, *, landscape: bool = False) -> str:
title = escape_md_inline((text or "").strip())
flags = " +landscape" if landscape else ""
if title:
return f"%{unique_id} {title}{flags}".rstrip()
return f"%{unique_id}{flags}".rstrip()
def _last_nonempty(chunks: list[str]) -> str:
for prev in reversed(chunks):
if prev.strip():
return prev
return ""
def _emit_table(tbl: TableBlock) -> str:
if not tbl.rows:
return ""
lines: list[str] = []
for r, row in enumerate(tbl.rows):
cells = []
for c, cell in enumerate(row):
if tbl.merges and r < len(tbl.merges) and c < len(tbl.merges[r]):
v, h = tbl.merges[r][c]
if h == "continue":
cells.append(">")
continue
if v == "continue":
cells.append("^")
continue
cells.append(_escape_md_cell(cell if cell not in ("^", ">") else cell))
lines.append("| " + " | ".join(cells) + " |")
if r == 0:
lines.append("| " + " | ".join("---" for _ in cells) + " |")
return "\n".join(lines)
def emit_markdown(blocks: list[Block], *, emit_page_breaks: bool = False) -> str:
chunks: list[str] = []
i = 0
while i < len(blocks):
b = blocks[i]
if isinstance(b, HeadingBlock):
hashes = "#" * max(1, min(b.level, 6))
title = escape_md_inline(b.text or "")
if not b.numbered:
# Dialect flag '*' after '# ' is structural — not escaped.
chunks.append(f"{hashes} *{title}")
else:
chunks.append(f"{hashes} {title}")
chunks.append("")
i += 1
continue
if isinstance(b, TocPlaceholder):
chunks.append("[TOC]")
chunks.append("")
i += 1
continue
if isinstance(b, CaptionBlock):
if b.is_continuation:
i += 1
continue
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if isinstance(nxt, ImageBlock) and b.kind == "figure":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
i += 1
continue
if isinstance(nxt, TableBlock) and b.kind == "table":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
if b.landscape:
nxt.landscape = True
chunks.append(_emit_caption_line(
nxt.caption_id or b.unique_id,
nxt.caption_text or b.text,
landscape=nxt.landscape,
))
chunks.append("")
i += 1
continue
if isinstance(nxt, ListingBlock) and b.kind == "listing":
nxt.caption_id = nxt.caption_id or b.unique_id
nxt.caption_text = nxt.caption_text or b.text
chunks.append(_emit_caption_line(nxt.caption_id or b.unique_id, nxt.caption_text))
chunks.append("")
i += 1
continue
chunks.append(_emit_caption_line(b.unique_id, b.text, landscape=b.landscape))
chunks.append("")
i += 1
continue
if isinstance(b, ImageBlock):
# Safety: caption still after image in IR
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if (
isinstance(nxt, CaptionBlock)
and nxt.kind == "figure"
and not nxt.is_continuation
):
b.caption_id = b.caption_id or nxt.unique_id
b.caption_text = b.caption_text or nxt.text
# skip the caption block
i += 1
title = ""
if b.caption_id:
title_body = escape_md_inline((b.caption_text or "").strip())
title = f" \"%{b.caption_id}" + (f" {title_body}" if title_body else "") + "\""
alt = escape_md_inline((b.alt or "рисунок").replace('"', "'"))
chunks.append(f"![{alt}]({b.rel_path}{title})")
chunks.append("")
i += 1
continue
if isinstance(b, TableBlock):
if b.caption_id:
prev = _last_nonempty(chunks)
if not prev.startswith(f"%{b.caption_id}"):
chunks.append(_emit_caption_line(b.caption_id, b.caption_text, landscape=b.landscape))
chunks.append("")
chunks.append(_emit_table(b))
chunks.append("")
i += 1
continue
if isinstance(b, ListingBlock):
if b.caption_id:
prev = _last_nonempty(chunks)
if not prev.startswith(f"%{b.caption_id}"):
chunks.append(_emit_caption_line(b.caption_id, b.caption_text))
chunks.append("")
lang = b.language or ""
body = "\n".join(line.rstrip("\n") for line in b.lines)
chunks.append(f"```{lang}")
chunks.append(body)
chunks.append("```")
chunks.append("")
i += 1
continue
if isinstance(b, EquationBlock):
if b.unique_id:
chunks.append(f"%{b.unique_id}")
chunks.append("$$")
chunks.append(b.latex.strip())
chunks.append("$$")
chunks.append("")
i += 1
continue
if isinstance(b, ListBlock):
for idx, item in enumerate(b.items, start=1):
body = escape_md_inline(item)
if b.ordered:
chunks.append(f"{idx}. {body}")
else:
chunks.append(f"- {body}")
chunks.append("")
i += 1
continue
if isinstance(b, ParagraphBlock):
line = _render_spans(b.spans).strip()
if line:
chunks.append(line)
chunks.append("")
i += 1
continue
if isinstance(b, PageBreakBlock):
if emit_page_breaks:
chunks.append("---")
chunks.append("")
i += 1
continue
i += 1
text = "\n".join(chunks)
while "\n\n\n" in text:
text = text.replace("\n\n\n", "\n\n")
return text.strip() + "\n"
+91
View File
@@ -0,0 +1,91 @@
"""Extract images from a DOCX package into a media folder."""
from __future__ import annotations
import hashlib
import os
import zipfile
from pathlib import Path
from docx.oxml.ns import qn
def _r_id_from_blip(blip) -> str | None:
if blip is None:
return None
return blip.get(qn("r:embed")) or blip.get(
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
)
def iter_blip_rids(paragraph_element) -> list[str]:
"""Collect relationship ids for drawings in a paragraph."""
if paragraph_element is None:
return []
rids: list[str] = []
for blip in paragraph_element.findall(".//" + qn("a:blip")):
rid = _r_id_from_blip(blip)
if rid:
rids.append(rid)
return rids
class MediaExtractor:
"""Copy word/media parts referenced by rIds into dest_dir."""
def __init__(self, docx_path: str | Path, dest_dir: str | Path, *, rel_prefix: str):
self.docx_path = Path(docx_path)
self.dest_dir = Path(dest_dir)
self.rel_prefix = rel_prefix.replace("\\", "/").rstrip("/")
self.dest_dir.mkdir(parents=True, exist_ok=True)
self._zf = zipfile.ZipFile(self.docx_path, "r")
self._cache: dict[str, str] = {} # rid -> relative path for md
self._used_names: set[str] = set()
def close(self) -> None:
self._zf.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def extract_rid(self, document_part, rid: str) -> str | None:
"""Return path relative to the .md file, or None on failure."""
if rid in self._cache:
return self._cache[rid]
try:
rel = document_part.rels[rid]
except KeyError:
return None
target = getattr(rel, "target_ref", None) or getattr(rel, "target_part", None)
if target is None:
return None
# target_ref like "media/image1.png"
if hasattr(rel, "target_part"):
part = rel.target_part
blob = part.blob
name = os.path.basename(part.partname)
else:
ref = str(target).lstrip("/")
zip_name = "word/" + ref if not ref.startswith("word/") else ref
try:
blob = self._zf.read(zip_name)
except KeyError:
return None
name = os.path.basename(ref)
stem, ext = os.path.splitext(name)
if not ext:
ext = ".png"
safe = f"{stem}{ext}"
if safe in self._used_names:
digest = hashlib.md5(blob).hexdigest()[:8]
safe = f"{stem}_{digest}{ext}"
self._used_names.add(safe)
out_path = self.dest_dir / safe
out_path.write_bytes(blob)
rel_path = f"{self.rel_prefix}/{safe}" if self.rel_prefix else safe
self._cache[rid] = rel_path
return rel_path
+149
View File
@@ -0,0 +1,149 @@
"""Best-effort OMML → readable LaTeX-like string for $$ / $ fences."""
from __future__ import annotations
_OMML_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
def _local(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[-1]
return tag
def omml_to_latex(element) -> str:
"""Flatten OMML tree to a readable formula string (not perfect LaTeX)."""
if element is None:
return ""
parts: list[str] = []
_walk(element, parts)
text = "".join(parts)
text = " ".join(text.split())
return text.strip()
def _walk(node, parts: list[str]) -> None:
tag = _local(node.tag)
if tag == "t":
parts.append(node.text or "")
return
if tag == "f": # fraction
num = _child(node, "num")
den = _child(node, "den")
parts.append(r"\frac{")
if num is not None:
_walk(num, parts)
parts.append("}{")
if den is not None:
_walk(den, parts)
parts.append("}")
return
if tag == "sSup":
base = _child(node, "e")
sup = _child(node, "sup")
if base is not None:
_walk(base, parts)
parts.append("^{")
if sup is not None:
_walk(sup, parts)
parts.append("}")
return
if tag == "sSub":
base = _child(node, "e")
sub = _child(node, "sub")
if base is not None:
_walk(base, parts)
parts.append("_{")
if sub is not None:
_walk(sub, parts)
parts.append("}")
return
if tag == "sSubSup":
base = _child(node, "e")
sub = _child(node, "sub")
sup = _child(node, "sup")
if base is not None:
_walk(base, parts)
parts.append("_{")
if sub is not None:
_walk(sub, parts)
parts.append("}^{")
if sup is not None:
_walk(sup, parts)
parts.append("}")
return
if tag == "rad":
deg = _child(node, "deg")
e = _child(node, "e")
if deg is not None and "".join(deg.itertext()).strip():
parts.append(r"\sqrt[")
_walk(deg, parts)
parts.append("]{")
else:
parts.append(r"\sqrt{")
if e is not None:
_walk(e, parts)
parts.append("}")
return
if tag == "nary":
for child in node:
_walk(child, parts)
return
if tag in ("oMath", "oMathPara", "e", "num", "den", "sup", "sub", "deg",
"fPr", "ctrlPr", "rPr", "sSupPr", "sSubPr", "sSubSupPr", "radPr",
"naryPr", "dPr", "boxPr", "argSz"):
for child in node:
_walk(child, parts)
return
if tag == "r":
for child in node:
_walk(child, parts)
return
if tag == "d": # delimiter
parts.append("(")
for child in node:
if _local(child.tag) != "dPr":
_walk(child, parts)
parts.append(")")
return
if tag in ("pPr", "rFonts", "sz", "szCs", "color", "jc"):
return
for child in node:
_walk(child, parts)
if node.tail:
parts.append(node.tail)
def _child(node, local_name: str):
for child in node:
if _local(child.tag) == local_name:
return child
return None
def find_omml(paragraph_element) -> list:
"""Return all m:oMath / m:oMathPara elements under a paragraph."""
if paragraph_element is None:
return []
found = []
for el in paragraph_element.iter():
tag = _local(el.tag)
if tag in ("oMath", "oMathPara"):
found.append(el)
return found
def paragraph_has_omml(paragraph_element) -> bool:
return bool(find_omml(paragraph_element))
+110
View File
@@ -0,0 +1,110 @@
"""DOCX → md2gost Markdown pipeline."""
from __future__ import annotations
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from docx import Document
from .emit import emit_markdown
from .media import MediaExtractor
from .refs import apply_reference_pass
from .walker import DocxWalker, postprocess_blocks
LogFn = Callable[[str], None]
@dataclass
class ImportRequest:
filename: str = ""
output: str | None = None
media_dir: str | None = None
keep_toc_pages: bool = False
emit_page_breaks: bool = False
@dataclass
class ImportResult:
ok: bool
exit_code: int = 0
output_path: str | None = None
media_dir: str | None = None
message: str = ""
def default_output_path(docx_path: str | Path) -> Path:
p = Path(docx_path)
return p.with_suffix(".md")
def default_media_dir(md_path: Path) -> Path:
return md_path.parent / f"{md_path.stem}_media"
def convert_docx(req: ImportRequest, log: LogFn | None = None) -> ImportResult:
"""Convert DOCX to md2gost dialect Markdown. Errors go into ImportResult."""
emit: LogFn = log if log is not None else print
def fail(code: int, message: str) -> ImportResult:
emit(message)
return ImportResult(False, code, message=message)
filename = (req.filename or "").strip()
if not filename:
return fail(2, "Укажите исходный .docx")
path = Path(filename)
if not path.is_file():
return fail(2, f"Файл не найден: {path}")
if path.suffix.lower() != ".docx":
return fail(1, "Исходный файл должен быть в формате .docx")
out = Path(req.output) if req.output else default_output_path(path)
if out.suffix.lower() != ".md":
out = out.with_suffix(".md")
out.parent.mkdir(parents=True, exist_ok=True)
media_path = Path(req.media_dir) if req.media_dir else default_media_dir(out)
rel_prefix = media_path.name
# If media dir is not sibling of md, use relative path from md parent
try:
rel_prefix = str(media_path.resolve().relative_to(out.parent.resolve())).replace("\\", "/")
except ValueError:
rel_prefix = str(media_path).replace("\\", "/")
try:
document = Document(str(path))
except Exception as exc:
return fail(1, f"Не удалось открыть DOCX: {exc}")
try:
with MediaExtractor(path, media_path, rel_prefix=rel_prefix) as media:
walker = DocxWalker(
document,
media,
keep_toc_pages=req.keep_toc_pages,
emit_page_breaks=req.emit_page_breaks,
log=emit,
)
blocks = walker.walk()
blocks = postprocess_blocks(blocks)
blocks = apply_reference_pass(blocks)
md_text = emit_markdown(blocks, emit_page_breaks=req.emit_page_breaks)
out.write_text(md_text, encoding="utf-8")
except Exception as exc:
emit(traceback.format_exc())
return fail(1, f"Ошибка импорта: {exc}")
msg = f"Markdown: {out}"
emit(msg)
if media_path.is_dir() and any(media_path.iterdir()):
emit(f"Медиа: {media_path}")
return ImportResult(
True,
0,
output_path=str(out.resolve()),
media_dir=str(media_path.resolve()) if media_path.exists() else None,
message=msg,
)
+170
View File
@@ -0,0 +1,170 @@
"""Second pass: replace «Рисунок 1.1» / «табл. 2» with @Тип:id."""
from __future__ import annotations
import re
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
ParagraphBlock,
TableBlock,
)
from .captions import caption_unique_id
# Number like 1 / 1.1 / 2.3.4 — do NOT use [\d.]+ (eats trailing sentence period).
_NUM = r"(?P<num>\d+(?:\.\d+)*)"
_REF_PATTERNS = [
(
re.compile(
rf"(?P<full>(?:Рисунк(?:е|а|у|ом)?|Рисунок|Рис\.?)\s+{_NUM})",
re.IGNORECASE,
),
"figure",
),
(
re.compile(
rf"(?P<full>(?:Таблиц[аыеу]|табл\.?)\s+{_NUM})",
re.IGNORECASE,
),
"table",
),
(
re.compile(
rf"(?P<full>(?:Листинг(?:а|у|е|ом)?|лист\.?)\s+{_NUM})",
re.IGNORECASE,
),
"listing",
),
(
re.compile(
rf"(?P<full>(?:Формул[аыеу]|форм\.?)\s+{_NUM})",
re.IGNORECASE,
),
"equation",
),
]
_TYPE_LABEL = {
"figure": "Рисунок",
"table": "Таблица",
"listing": "Листинг",
"equation": "Формула",
}
def collect_number_maps(blocks: list[Block]) -> dict[str, dict[str, str]]:
"""kind → {number → unique_id}."""
maps: dict[str, dict[str, str]] = {
"figure": {},
"table": {},
"listing": {},
"equation": {},
}
for b in blocks:
if isinstance(b, CaptionBlock) and not b.is_continuation:
maps[b.kind][b.number] = b.unique_id or caption_unique_id(b.kind, b.number)
elif isinstance(b, ImageBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "fig")
if num:
maps["figure"][num] = b.caption_id
elif isinstance(b, TableBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "tbl")
if num:
maps["table"][num] = b.caption_id
elif isinstance(b, ListingBlock) and b.caption_id:
num = _id_to_number(b.caption_id, "lst")
if num:
maps["listing"][num] = b.caption_id
elif isinstance(b, EquationBlock) and b.number and b.unique_id:
maps["equation"][b.number] = b.unique_id
return maps
def _id_to_number(uid: str, prefix: str) -> str | None:
if not uid.startswith(prefix):
return None
rest = uid[len(prefix):]
if not rest:
return None
return rest.replace("_", ".")
def rewrite_text(text: str, maps: dict[str, dict[str, str]]) -> str:
"""Replace object references; leave bibliography [1] alone."""
if not text:
return text
def repl_factory(kind: str):
label = _TYPE_LABEL[kind]
num_map = maps.get(kind) or {}
def repl(m: re.Match) -> str:
num = m.group("num")
uid = num_map.get(num)
if not uid:
return m.group(0)
return f"@{label}:{uid}"
return repl
out = text
for pattern, kind in _REF_PATTERNS:
out = pattern.sub(repl_factory(kind), out)
return out
def _rewrite_paragraph_spans(para: ParagraphBlock, maps: dict[str, dict[str, str]]) -> None:
"""Rewrite refs even when Word split «Таблице 5.1» across runs."""
spans = para.spans
if not spans:
return
new_spans: list[InlineSpan] = []
i = 0
while i < len(spans):
s = spans[i]
if s.math or s.href:
new_spans.append(s)
i += 1
continue
j = i
chunk: list[InlineSpan] = []
while j < len(spans) and not spans[j].math and not spans[j].href:
chunk.append(spans[j])
j += 1
joined = "".join(p.text or "" for p in chunk)
rewritten = rewrite_text(joined, maps)
if rewritten == joined:
new_spans.extend(chunk)
else:
first = chunk[0]
same_fmt = all(
p.bold == first.bold and p.italic == first.italic and p.strike == first.strike
for p in chunk
)
new_spans.append(
InlineSpan(
text=rewritten,
bold=first.bold if same_fmt else False,
italic=first.italic if same_fmt else False,
strike=first.strike if same_fmt else False,
)
)
i = j
para.spans = new_spans
def apply_reference_pass(blocks: list[Block]) -> list[Block]:
maps = collect_number_maps(blocks)
for b in blocks:
if isinstance(b, ParagraphBlock):
_rewrite_paragraph_spans(b, maps)
elif isinstance(b, ListBlock):
b.items = [rewrite_text(item, maps) for item in b.items]
return blocks
+140
View File
@@ -0,0 +1,140 @@
"""DOCX table → markdown rows with ^ / > merge markers."""
from __future__ import annotations
from docx.oxml.ns import qn
from docx.table import Table as DocxTable
def _cell_v_merge(tc) -> str:
"""Return none | restart | continue for vertical merge."""
tcPr = tc.tcPr
if tcPr is None:
return "none"
vMerge = tcPr.find(qn("w:vMerge"))
if vMerge is None:
return "none"
val = vMerge.get(qn("w:val"))
if val in (None, "continue"):
return "continue"
return "restart"
def _cell_grid_span(tc) -> int:
tcPr = tc.tcPr
if tcPr is None:
return 1
span = tcPr.find(qn("w:gridSpan"))
if span is None:
return 1
try:
return max(1, int(span.get(qn("w:val")) or "1"))
except ValueError:
return 1
def _cell_text(cell) -> str:
from .text import paragraph_plain
parts: list[str] = []
for p in cell.paragraphs:
t = paragraph_plain(p)
if t:
parts.append(t)
return " ".join(parts).replace("|", "\\|")
def table_to_grid(docx_table: DocxTable) -> tuple[list[list[str]], list[list[tuple[str, str]]]]:
"""
Expand a Word table into a rectangular grid.
merges[r][c] = (v, h) where v/h in {none, restart, continue}.
Horizontally spanned continue cells are filled with '>' semantics.
Vertically continued cells use '^'.
"""
rows_xml = docx_table._tbl.tr_lst
# First pass: determine max columns from grid spans
max_cols = 0
row_infos: list[list[tuple[object, int, str]]] = [] # (tc, span, vmerge)
for tr in rows_xml:
info: list[tuple[object, int, str]] = []
col = 0
for tc in tr.tc_lst:
span = _cell_grid_span(tc)
v = _cell_v_merge(tc)
info.append((tc, span, v))
col += span
max_cols = max(max_cols, col)
row_infos.append(info)
if max_cols == 0:
return [], []
texts: list[list[str]] = [["-" for _ in range(max_cols)] for _ in range(len(row_infos))]
merges: list[list[tuple[str, str]]] = [
[("none", "none") for _ in range(max_cols)] for _ in range(len(row_infos))
]
# Map tc elements to python-docx cells by index
for r, info in enumerate(row_infos):
c = 0
cell_idx = 0
for tc, span, v in info:
try:
cell = docx_table.rows[r].cells[cell_idx]
text = _cell_text(cell)
except Exception:
text = ""
cell_idx += 1
if v == "continue":
merges[r][c] = ("continue", "none")
texts[r][c] = "^"
else:
merges[r][c] = ("restart" if v == "restart" else "none", "none" if span == 1 else "restart")
texts[r][c] = text if text else " "
for k in range(1, span):
if c + k < max_cols:
merges[r][c + k] = ("none", "continue")
texts[r][c + k] = ">"
c += span
return texts, merges
def is_formula_table(docx_table: DocxTable) -> bool:
"""Heuristic: 1×2 table with Formula Content / Formula Numbering styles."""
try:
if len(docx_table.rows) != 1 or len(docx_table.columns) != 2:
return False
except Exception:
return False
styles: list[str] = []
for cell in docx_table.rows[0].cells:
for p in cell.paragraphs:
name = (p.style.name if p.style is not None else "") or ""
styles.append(name)
joined = " ".join(styles).lower()
return "formula" in joined
def formula_table_parts(docx_table: DocxTable) -> tuple[object | None, str | None]:
"""Return (omml_element_or_None, number_text_or_None) from a formula table."""
from .omml import find_omml, omml_to_latex
from .text import paragraph_plain
left = docx_table.rows[0].cells[0]
right = docx_table.rows[0].cells[1]
latex = ""
for p in left.paragraphs:
for om in find_omml(p._element):
latex = omml_to_latex(om)
if latex:
break
if latex:
break
if not latex:
latex = " ".join(paragraph_plain(p) for p in left.paragraphs).strip()
number = " ".join(paragraph_plain(p) for p in right.paragraphs).strip()
number = number.strip("() ").strip() or None
return latex, number
+78
View File
@@ -0,0 +1,78 @@
"""Extract visible text from OOXML runs/paragraphs (incl. w:noBreakHyphen)."""
from __future__ import annotations
from docx.oxml.ns import qn
def _local(tag: str) -> str:
if "}" in tag:
return tag.rsplit("}", 1)[-1]
return tag
def run_element_text(run_el) -> str:
"""
Text of a w:r including non-breaking hyphens.
md2gost writes ASCII '-' as w:noBreakHyphen (python-docx run.text skips it).
Soft hyphens are optional break hints omit them from Markdown.
"""
if run_el is None:
return ""
parts: list[str] = []
for child in run_el:
tag = _local(child.tag)
if tag == "t":
parts.append(child.text or "")
elif tag == "noBreakHyphen":
parts.append("-")
elif tag == "softHyphen":
continue
elif tag == "tab":
parts.append("\t")
elif tag == "br":
# page/line break inside run — treat as space for inline text
if child.get(qn("w:type")) == "page":
continue
parts.append("\n")
elif tag == "cr":
parts.append("\n")
return "".join(parts)
def hyperlink_element_text(hyperlink_el) -> str:
if hyperlink_el is None:
return ""
parts: list[str] = []
for run in hyperlink_el.findall(qn("w:r")):
parts.append(run_element_text(run))
return "".join(parts)
def paragraph_element_text(p_el) -> str:
"""Visible body text of w:p (runs + hyperlinks), without OMML."""
if p_el is None:
return ""
parts: list[str] = []
for child in p_el:
tag = child.tag
if tag == qn("w:r"):
parts.append(run_element_text(child))
elif tag == qn("w:hyperlink"):
parts.append(hyperlink_element_text(child))
return "".join(parts)
def paragraph_plain(paragraph) -> str:
"""Strip CR/bell from a python-docx Paragraph, keeping noBreakHyphen as '-'."""
return paragraph_raw(paragraph).strip()
def paragraph_raw(paragraph) -> str:
"""Full paragraph text with noBreakHyphen → '-', without strip (for Code lines)."""
try:
text = paragraph_element_text(paragraph._element)
except Exception:
text = paragraph.text or ""
return text.replace("\r", "").replace("\x07", "")
+596
View File
@@ -0,0 +1,596 @@
"""Walk DOCX body and produce IR blocks."""
from __future__ import annotations
import re
from typing import Callable
from docx import Document
from docx.oxml.ns import qn
from docx.table import Table as DocxTable
from docx.text.paragraph import Paragraph as DocxParagraph
from .blocks import (
Block,
CaptionBlock,
EquationBlock,
HeadingBlock,
ImageBlock,
InlineSpan,
ListBlock,
ListingBlock,
PageBreakBlock,
ParagraphBlock,
SkipNote,
TableBlock,
TocPlaceholder,
)
from .captions import caption_unique_id, parse_any_caption
from .classify import (
caption_kind_from_style,
classify_heading_text,
heading_level_from_style,
is_bibliography_style,
is_body_start_heading,
is_caption_style,
is_code_style,
is_special_title,
is_toc_style,
look_like_list_item,
parse_biblio_line,
paragraph_has_numpr,
style_name,
)
from .media import MediaExtractor, iter_blip_rids
from .omml import find_omml, omml_to_latex, paragraph_has_omml
from .tables import formula_table_parts, is_formula_table, table_to_grid
from .text import (
hyperlink_element_text,
paragraph_plain,
paragraph_raw,
run_element_text,
)
LogFn = Callable[[str], None]
def _para_plain(paragraph: DocxParagraph) -> str:
return paragraph_plain(paragraph)
def _iter_body_items(document: Document):
body = document.element.body
for child in body.iterchildren():
tag = child.tag
if tag == qn("w:p"):
yield "p", DocxParagraph(child, document)
elif tag == qn("w:tbl"):
yield "tbl", DocxTable(child, document)
elif tag == qn("w:sectPr"):
continue
else:
yield "other", child
def _inline_spans(paragraph: DocxParagraph) -> list[InlineSpan]:
"""Build inline spans from runs + hyperlinks + inline math."""
spans: list[InlineSpan] = []
p = paragraph._element
for child in p:
tag = child.tag
if tag == qn("w:r"):
text = run_element_text(child)
if child.find(qn("w:br")) is not None and not text.strip():
# lone page/line break run
continue
if not text:
continue
rPr = child.find(qn("w:rPr"))
bold = italic = strike = False
if rPr is not None:
bold = rPr.find(qn("w:b")) is not None
italic = rPr.find(qn("w:i")) is not None
strike = rPr.find(qn("w:strike")) is not None or rPr.find(qn("w:dstrike")) is not None
spans.append(InlineSpan(text=text, bold=bold, italic=italic, strike=strike))
elif tag == qn("w:hyperlink"):
anchor = child.get(qn("w:anchor"))
rid = child.get(qn("r:id"))
href = None
if rid:
try:
rel = paragraph.part.rels[rid]
href = rel.target_ref
except Exception:
href = None
inner = hyperlink_element_text(child)
if not inner:
continue
# Internal biblio links → keep plain [n] text (no md link)
if anchor and str(anchor).startswith("biblio_"):
spans.append(InlineSpan(text=inner))
elif href and not str(href).startswith("#"):
spans.append(InlineSpan(text=inner, href=href))
else:
spans.append(InlineSpan(text=inner))
elif tag.endswith("}oMath") or tag.endswith("}oMathPara"):
latex = omml_to_latex(child)
if latex:
spans.append(InlineSpan(text="", math=latex))
# Fallback if empty but paragraph has text
if not spans:
plain = _para_plain(paragraph)
if plain:
spans.append(InlineSpan(text=plain))
return spans
def _section_landscape(document: Document, element) -> bool:
"""Best-effort: check nearest following sectPr or document default."""
try:
# Walk forward siblings for sectPr
parent = element.getparent()
if parent is None:
return False
el = element.getnext()
while el is not None:
if el.tag == qn("w:sectPr"):
pgSz = el.find(qn("w:pgSz"))
if pgSz is not None:
w = int(pgSz.get(qn("w:w") or "0") or 0)
h = int(pgSz.get(qn("w:h") or "0") or 0)
orient = pgSz.get(qn("w:orient"))
if orient == "landscape" or (w and h and w > h):
return True
return False
el = el.getnext()
# last section
sect = document.sections[-1]
return bool(sect.page_width and sect.page_height and sect.page_width > sect.page_height)
except Exception:
return False
class DocxWalker:
def __init__(
self,
document: Document,
media: MediaExtractor,
*,
keep_toc_pages: bool = False,
emit_page_breaks: bool = False,
log: LogFn | None = None,
):
self.document = document
self.media = media
self.keep_toc_pages = keep_toc_pages
self.emit_page_breaks = emit_page_breaks
self.log = log or (lambda _m: None)
self._started = False
self._saw_toc_heading = False
self._pending_caption: CaptionBlock | None = None
self._code_buf: list[str] = []
self._code_caption: CaptionBlock | None = None
self._list_buf: list[str] | None = None
self._list_ordered: bool | None = None
def walk(self) -> list[Block]:
blocks: list[Block] = []
for kind, item in _iter_body_items(self.document):
if kind == "p":
blocks.extend(self._handle_paragraph(item))
elif kind == "tbl":
self._flush_code(blocks)
self._flush_list(blocks)
blocks.extend(self._handle_table(item))
else:
self.log(f"Пропуск неизвестного элемента body: {getattr(item, 'tag', item)}")
blocks.append(SkipNote(message=f"unknown body child {getattr(item, 'tag', '')}"))
self._flush_code(blocks)
self._flush_list(blocks)
return [b for b in blocks if not isinstance(b, SkipNote)]
def _flush_code(self, blocks: list[Block]) -> None:
if not self._code_buf:
self._code_caption = None
return
cap = self._code_caption
uid = cap.unique_id if cap else None
text = cap.text if cap else None
blocks.append(
ListingBlock(
lines=list(self._code_buf),
language="",
caption_id=uid,
caption_text=text,
)
)
self._code_buf.clear()
self._code_caption = None
self._pending_caption = None
def _flush_list(self, blocks: list[Block]) -> None:
if self._list_buf is None:
return
blocks.append(
ListBlock(
ordered=bool(self._list_ordered),
items=list(self._list_buf),
)
)
self._list_buf = None
self._list_ordered = None
def _handle_paragraph(self, paragraph: DocxParagraph) -> list[Block]:
out: list[Block] = []
style = style_name(paragraph)
text = _para_plain(paragraph)
el = paragraph._element
# Page break
if self.emit_page_breaks:
for br in el.findall(".//" + qn("w:br")):
if br.get(qn("w:type")) == "page":
self._flush_code(out)
self._flush_list(out)
out.append(PageBreakBlock())
# Skip until body start (title / assignment)
if not self._started:
if is_body_start_heading(text, style) or heading_level_from_style(style):
if is_body_start_heading(text, style) or heading_level_from_style(style) == 1:
self._started = True
elif not text:
return out
else:
return out
else:
return out
# TOC field lines
if is_toc_style(style):
if not self.keep_toc_pages:
return out
# keep as plain paragraphs if requested
self._flush_code(out)
self._flush_list(out)
out.append(ParagraphBlock(spans=_inline_spans(paragraph), style=style))
return out
# Native TOC field detection
if self._has_toc_field(el):
self._flush_code(out)
self._flush_list(out)
if not self.keep_toc_pages:
# only placeholder once after СОДЕРЖАНИЕ heading
return out
return out
level = heading_level_from_style(style)
if level is not None or (text and is_special_title(text) and not text.startswith("[")):
self._flush_code(out)
self._flush_list(out)
if not text:
return out
lvl, title, numbered = classify_heading_text(text, level or 1)
if title.upper() == "СОДЕРЖАНИЕ" or title.upper().startswith("СОДЕРЖАНИЕ"):
out.append(HeadingBlock(level=1, text="СОДЕРЖАНИЕ", numbered=False))
out.append(TocPlaceholder())
self._saw_toc_heading = True
self._pending_caption = None
return out
out.append(HeadingBlock(level=lvl, text=title, numbered=numbered))
self._pending_caption = None
return out
# Caption styles / heuristic captions
if is_caption_style(style) or parse_any_caption(text):
parsed = parse_any_caption(text)
kind = caption_kind_from_style(style) if is_caption_style(style) else None
if parsed is None and kind:
# style known but text odd — treat whole text as title
parsed = parse_any_caption(text) # may still fail
if parsed is not None:
self._flush_code(out)
self._flush_list(out)
if kind and parsed.kind != kind and kind != "figure":
# prefer text kind when style is generic Caption
pass
uid = caption_unique_id(parsed.kind, parsed.number)
cap = CaptionBlock(
kind=parsed.kind,
number=parsed.number,
text=parsed.text,
is_continuation=parsed.is_continuation,
unique_id=uid,
)
if parsed.is_continuation:
# Emit marker for postprocess_blocks to splice into previous table/listing.
# Do NOT stash in _pending_caption — that created a second %tbl / table.
self._pending_caption = None
out.append(cap)
return out
# Tables/listings: caption precedes object → keep pending.
# Figures (GOST): caption is under the picture → only CaptionBlock;
# postprocess folds Image+Caption. Stashing pending would glue this
# caption onto the *next* figure.
if parsed.kind in ("table", "listing"):
self._pending_caption = cap
else:
self._pending_caption = None
out.append(cap)
return out
# Code / listing lines
if is_code_style(style):
self._flush_list(out)
if self._pending_caption and self._pending_caption.kind == "listing":
self._code_caption = self._pending_caption
self._pending_caption = None
self._code_buf.append(paragraph_raw(paragraph))
return out
# Images in paragraph
rids = iter_blip_rids(el)
if rids:
self._flush_code(out)
self._flush_list(out)
# Caption-before-image is rare; GOST puts caption under the picture
# (folded later). Only consume pending if it is explicitly a figure.
cap = self._pending_caption
cid = text_c = None
if cap and cap.kind == "figure":
cid = cap.unique_id
text_c = cap.text
self._pending_caption = None
for rid in rids:
rel = self.media.extract_rid(paragraph.part, rid)
if not rel:
self.log(f"Не удалось извлечь картинку {rid}")
continue
out.append(
ImageBlock(
rel_path=rel,
alt=text or "",
caption_id=cid,
caption_text=text_c,
)
)
cid = text_c = None # only first drawing gets pre-caption
return out
# Bibliography
if is_bibliography_style(style) or parse_biblio_line(text):
self._flush_code(out)
self._flush_list(out)
biblio = parse_biblio_line(text)
if biblio:
key, body = biblio
out.append(
ParagraphBlock(
spans=[InlineSpan(text=f"[{key}]: {body}")],
style=style,
is_bibliography=True,
biblio_key=key,
)
)
return out
# Lists (Word numPr or md2gost markers)
if paragraph_has_numpr(paragraph):
self._flush_code(out)
body = text
# strip auto number text if present at start — keep as item body
ordered = True
try:
# python-docx doesn't expose easily; assume numbered if numPr
m = re.match(r"^\d+\.\s*(.*)$", text)
if m:
body = m.group(1)
else:
# bullet
ordered = False
body = re.sub(r"^[–—\-•▪]\s*", "", text)
except Exception:
pass
if self._list_buf is not None and self._list_ordered == ordered:
self._list_buf.append(body)
else:
self._flush_list(out)
self._list_buf = [body]
self._list_ordered = ordered
return []
list_hit = look_like_list_item(text)
if list_hit and (paragraph.paragraph_format.left_indent or "\t" in (paragraph.text or "")):
ordered, _ok, body = list_hit
self._flush_code(out)
if self._list_buf is not None and self._list_ordered == ordered:
self._list_buf.append(body)
else:
self._flush_list(out)
self._list_buf = [body]
self._list_ordered = ordered
return []
# Plain paragraph (maybe with inline math)
self._flush_code(out)
self._flush_list(out)
if not text and not paragraph_has_omml(el):
return out
spans = _inline_spans(paragraph)
# If whole paragraph is only omml (block-ish), emit equation
oms = find_omml(el)
if oms and not text.replace(" ", ""):
latex = omml_to_latex(oms[0])
if latex:
out.append(EquationBlock(latex=latex))
return out
out.append(ParagraphBlock(spans=spans, style=style))
self._pending_caption = None
return out
def _handle_table(self, table: DocxTable) -> list[Block]:
out: list[Block] = []
if not self._started:
# title-page layout tables — skip
return out
if is_formula_table(table):
latex, number = formula_table_parts(table)
uid = None
if number:
uid = caption_unique_id("figure", number).replace("fig", "eq")
# better: eq + number
uid = "eq" + str(number).replace(".", "_")
out.append(EquationBlock(latex=latex or "?", number=number, unique_id=uid))
self._pending_caption = None
return out
landscape = _section_landscape(self.document, table._tbl)
rows, merges = table_to_grid(table)
if not rows:
return out
cap = self._pending_caption
cid = text_c = None
if cap and cap.kind == "table" and not cap.is_continuation:
cid = cap.unique_id
text_c = cap.text
landscape = landscape or cap.landscape
self._pending_caption = None
elif cap and cap.is_continuation:
self._pending_caption = None
out.append(
TableBlock(
rows=rows,
merges=merges,
caption_id=cid,
caption_text=text_c,
landscape=landscape,
)
)
return out
@staticmethod
def _has_toc_field(p_element) -> bool:
for instr in p_element.findall(".//" + qn("w:instrText")):
raw = (instr.text or "").upper()
if "TOC" in raw:
return True
return False
def postprocess_blocks(blocks: list[Block]) -> list[Block]:
"""Merge continuation tables/listings; fold figure captions under images; drop dup TOC."""
result: list[Block] = []
saw_toc = False
i = 0
while i < len(blocks):
b = blocks[i]
if isinstance(b, TocPlaceholder):
if saw_toc:
i += 1
continue
saw_toc = True
result.append(b)
i += 1
continue
if isinstance(b, CaptionBlock) and b.is_continuation:
kind = b.kind
i += 1
while i < len(blocks) and isinstance(blocks[i], SkipNote):
i += 1
if i >= len(blocks):
break
nxt = blocks[i]
prev = _last_block_of_type(
result,
TableBlock if kind == "table" else ListingBlock if kind == "listing" else None,
)
if kind == "table" and isinstance(nxt, TableBlock) and isinstance(prev, TableBlock):
_merge_table_continuation(prev, nxt)
i += 1
continue
if kind == "listing" and isinstance(nxt, ListingBlock) and isinstance(prev, ListingBlock):
prev.lines.extend(nxt.lines)
i += 1
continue
result.append(nxt)
i += 1
continue
if isinstance(b, CaptionBlock) and not b.is_continuation:
result.append(b)
i += 1
continue
result.append(b)
i += 1
return _fold_figure_captions_after_images(result)
def _fold_figure_captions_after_images(blocks: list[Block]) -> list[Block]:
"""
In DOCX/GOST the caption is under the picture; in md2gost %id / title must
precede the image. Fold «Image + Caption(figure)» into one ImageBlock.
"""
out: list[Block] = []
i = 0
while i < len(blocks):
b = blocks[i]
nxt = blocks[i + 1] if i + 1 < len(blocks) else None
if (
isinstance(b, ImageBlock)
and isinstance(nxt, CaptionBlock)
and nxt.kind == "figure"
and not nxt.is_continuation
):
b.caption_id = b.caption_id or nxt.unique_id
b.caption_text = b.caption_text or nxt.text
out.append(b)
i += 2
continue
# Drop a lone figure caption that duplicates the previous image's id
if (
isinstance(b, CaptionBlock)
and b.kind == "figure"
and not b.is_continuation
and out
and isinstance(out[-1], ImageBlock)
and out[-1].caption_id == b.unique_id
):
if not out[-1].caption_text and b.text:
out[-1].caption_text = b.text
i += 1
continue
out.append(b)
i += 1
return out
def _last_block_of_type(blocks: list[Block], typ) -> Block | None:
if typ is None:
return None
for b in reversed(blocks):
if isinstance(b, typ):
return b
return None
def _merge_table_continuation(prev: TableBlock, nxt: TableBlock) -> None:
"""Append continuation rows; drop a repeated header row if Word duplicated it."""
rows = list(nxt.rows or [])
merges = list(nxt.merges) if nxt.merges is not None else None
if prev.rows and rows and prev.rows[0] == rows[0]:
rows = rows[1:]
if merges is not None:
merges = merges[1:]
prev.rows.extend(rows)
if prev.merges is not None and merges is not None:
prev.merges.extend(merges)
elif prev.merges is None and merges is not None and prev.rows:
# keep merges only if both sides had them; otherwise drop
pass