diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b65f6f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.bat text eol=crlf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore index 5c577f9..2a4116b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/PROJECT.md b/PROJECT.md index 091adca..9f26420 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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, источники 7–20. -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 без правки кода. diff --git a/ReadMe.md b/ReadMe.md index 5fade5e..286073d 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -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). diff --git a/_bpmn_preview.png b/_bpmn_preview.png deleted file mode 100644 index 05cf39e..0000000 Binary files a/_bpmn_preview.png and /dev/null differ diff --git a/_bpmn_sprites.puml b/_bpmn_sprites.puml deleted file mode 100644 index 541241d..0000000 --- a/_bpmn_sprites.puml +++ /dev/null @@ -1,3468 +0,0 @@ -' auto-generated geometric sprites (BPMN 2.0 notation) -sprite $evStart [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000000000000000000001B70000000 -000002C500000000000000000000000000000005C2000000 -000005C200000000000000000000000000000002C5000000 -000009900000000000000000000000000000000099000000 -00001B50000000000000000000000000000000005B100000 -00002C30000000000000000000000000000000003C200000 -00003C20000000000000000000000000000000002C300000 -00004C20000000000000000000000000000000002C400000 -00005C10000000000000000000000000000000001C500000 -00004C20000000000000000000000000000000002C400000 -00003C20000000000000000000000000000000002C300000 -00002C30000000000000000000000000000000003C200000 -00001B50000000000000000000000000000000005B100000 -000009900000000000000000000000000000000099000000 -000005C200000000000000000000000000000002C5000000 -000002C500000000000000000000000000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000000000000000000001B70000000 -000002C500000000000000000000000000000005C2000000 -000005C200000008888888888888888800000002C5000000 -000009900000008FFFFFFFFFFFFFFFFF8000000099000000 -00001B500000008FDA88888888888ADF800000005B100000 -00002C300000008F854000000000458F800000003C200000 -00003C200000008F815510000015518F800000002C300000 -00004C200000008F800451000154008F800000002C400000 -00005C100000008F800036202630008F800000001C500000 -00004C200000008F800002646200008F800000002C400000 -00003C200000008F800000252000008F800000002C300000 -00002C300000008F800000000000008F800000003C200000 -00001B500000008FB8888888888888BF800000005B100000 -000009900000008FFFFFFFFFFFFFFFFF8000000099000000 -000005C200000008888888888888888800000002C5000000 -000002C500000000000000000000000000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartTimer [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000133310000000000001B70000000 -000002C50000000000039BBBBB93000000000005C2000000 -000005C200000000006C8312138C600000000002C5000000 -000009900000000006B400151004B6000000000099000000 -00001B50000000003C40001510004C30000000005B100000 -00002C30000000009800001510000890000000003C200000 -00003C2000000001B3000015100003B1000000002C300000 -00004C2000000003B1000015111001B3000000002C400000 -00005C1000000003B0000015555300B3000000001C500000 -00004C2000000003B1000001111001B3000000002C400000 -00003C2000000001B3000000000003B1000000002C300000 -00002C30000000009800000000000890000000003C200000 -00001B50000000003C40000000004C30000000005B100000 -000009900000000006B400000004B6000000000099000000 -000005C200000000006C8310138C600000000002C5000000 -000002C50000000000039BBBBB93000000000005C2000000 -0000007B1000000000000133310000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartSignal [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000003000000000000001B70000000 -000002C500000000000000373000000000000005C2000000 -000005C200000000000001646100000000000002C5000000 -000009900000000000000351530000000000000099000000 -00001B50000000000000162026100000000000005B100000 -00002C30000000000000450005400000000000003C200000 -00003C20000000000001620002610000000000002C300000 -00004C20000000000004500000540000000000002C400000 -00005C10000000000026200000262000000000001C500000 -00004C20000000000054000000045000000000002C400000 -00003C20000000000261000000016200000000002C300000 -00002C30000000000540000000004500000000003C200000 -00001B50000000002610000000001620000000005B100000 -000009900000000054111111111114500000000099000000 -000005C200000002666666666666666200000002C5000000 -000002C500000000111111111111111000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartCond [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000004888888888884000000006C20000000 -0000007B100000004BFFFFFFFFFFFB400000001B70000000 -000002C5000000008FB888888888BF8000000005C2000000 -000005C2000000008F80000000008F8000000002C5000000 -00000990000000008F80000000008F800000000099000000 -00001B50000000008F80000000008F80000000005B100000 -00002C30000000008F82555555528F80000000003C200000 -00003C20000000008F80000000008F80000000002C300000 -00004C20000000008F80000000008F80000000002C400000 -00005C10000000008F82555555528F80000000001C500000 -00004C20000000008F80000000008F80000000002C400000 -00003C20000000008F80000000008F80000000002C300000 -00002C30000000008F82555555528F80000000003C200000 -00001B50000000008F80000000008F80000000005B100000 -00000990000000008F80000000008F800000000099000000 -000005C2000000008F80000000008F8000000002C5000000 -000002C5000000008FB888888888BF8000000005C2000000 -0000007B100000004BFFFFFFFFFFFB400000001B70000000 -0000002C6000000004888888888884000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartError [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000100000000000006C20000000 -0000007B1000000000000001510000000000001B70000000 -000002C500000000000000045000000000000005C2000000 -000005C200000000000000162000000000000002C5000000 -000009900000000000000045000000000000000099000000 -00001B50000000000000016300000000000000005B100000 -00002C30000000000000036100000000000000003C200000 -00003C20000000000000164111100000000000002C300000 -00004C20000000000000366666610000000000002C400000 -00005C10000000000000621114400000000000001C500000 -00004C20000000000002644426200000000000002C400000 -00003C20000000000002444555000000000000002C300000 -00002C30000000000000000662000000000000003C200000 -00001B50000000000000001660000000000000005B100000 -000009900000000000000027300000000000000099000000 -000005C200000000000000461000000000000002C5000000 -000002C500000000000000640000000000000005C2000000 -0000007B1000000000000151000000000000001B70000000 -0000002C6000000000000010000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartEsc [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000000000000000000001B70000000 -000002C500000000000000000000000000000005C2000000 -000005C200000000000000000000000000000002C5000000 -000009900000000000000000000000000000000099000000 -00001B50000000000000000000000000000000005B100000 -00002C30000000000000000100000000000000003C200000 -00003C20000000000000002620000000000000002C300000 -00004C20000000000000026662000000000000002C400000 -00005C10000000000000263636200000000000001C500000 -00004C20000000000002621612620000000000002C400000 -00003C20000000000026201610262000000000002C300000 -00002C30000000000263112621136200000000003C200000 -00001B50000000001666666666666610000000005B100000 -000009900000000001111126211111000000000099000000 -000005C200000000000000161000000000000002C5000000 -000002C500000000000000151000000000000005C2000000 -0000007B1000000000000001000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartMulti [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000000013100000000000001B70000000 -000002C500000000000001565100000000000005C2000000 -000005C200000000000036313630000000000002C5000000 -000009900000000000146200026410000000000099000000 -00001B50000000000255100000155200000000005B100000 -00002C30000000003630000000003630000000003C200000 -00003C20000000016200000000000261000000002C300000 -00004C20000000005300000000000350000000002C400000 -00005C10000000002500000000000520000000001C500000 -00004C20000000001610000000001610000000002C400000 -00003C20000000000530000000003500000000002C300000 -00002C30000000000350000000005300000000003C200000 -00001B50000000000161000000016100000000005B100000 -000009900000000000531111111350000000000099000000 -000005C200000000003666666666300000000002C5000000 -000002C500000000000111111111000000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartPar [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000012345432100000000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -0000000000000027CC95322122359CC72000000000000000 -00000000000016CB520000000000025BC610000000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000003B810000000000000000000000018B3000000000 -00000001AA100000000000000000000000001AA100000000 -00000006C30000000000000000000000000003C600000000 -0000002C6000000000000000000000000000006C20000000 -0000007B1000000000001356531000000000001B70000000 -000002C5000000000015BBA9ABB5100000000005C2000000 -000005C200000000019B5101015B910000000002C5000000 -000009900000000019A200151002A9100000000099000000 -00001B50000000005B20001610002B50000000005B100000 -00002C3000000001B5000016100005B1000000003C200000 -00003C2000000003B1000016100001B3000000002C300000 -00004C2000000005A0111126211110A5000000002C400000 -00005C10000000069156666666665196000000001C500000 -00004C2000000005A0111126211110A5000000002C400000 -00003C2000000003B1000016100001B3000000002C300000 -00002C3000000001B5000016100005B1000000003C200000 -00001B50000000005B20001610002B50000000005B100000 -000009900000000019A200151002A9100000000099000000 -000005C200000000019B5101015B910000000002C5000000 -000002C5000000000015BBA9ABB5100000000005C2000000 -0000007B1000000000001356531000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA100000000000000000000000001AA100000000 -000000003B810000000000000000000000018B3000000000 -0000000004B8100000000000000000000018B40000000000 -00000000004BA300000000000000000003AB400000000000 -000000000003AC6100000000000000016CA3000000000000 -00000000000016CB520000000000025BC610000000000000 -0000000000000027CC95322122359CC72000000000000000 -0000000000000000259BCCCCCCCB95200000000000000000 -000000000000000000012345432100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000002345432000000000000000000000 -000000000000000000116CCCCC6110000000000000000000 -000000000000000111101221221011110000000000000000 -000000000000164100000000000000014610000000000000 -000000000003AC4000000000000000004CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000000881000000000000000000000001880000000000 -000000001110000000000000000000000000111000000000 -000000011000000000000000000000000000001100000000 -000000010000000000000000000000000000000100000000 -000000110000000000000000000000000000000110000000 -000000100000000000000000000000000000000010000000 -000005910000000000000000000000000000000195000000 -000009900000000000000000000000000000000099000000 -00001B50000000000000000000000000000000005B100000 -00002C30000000000000000000000000000000003C200000 -00003C20000000000000000000000000000000002C300000 -00004C20000000000000000000000000000000002C400000 -000026100000000000000000000000000000000016200000 -000001000000000000000000000000000000000001000000 -000001000000000000000000000000000000000001000000 -000001000000000000000000000000000000000001000000 -000001000000000000000000000000000000000001000000 -000001100000000000000000000000000000000011000000 -000000310000000000000000000000000000000130000000 -000002C500000000000000000000000000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA000000000000000000000000000AA100000000 -000000003410000000000000000000000000143000000000 -000000000011000000000000000000000001100000000000 -000000000001100000000000000000000011000000000000 -000000000000112100000000000000012110000000000000 -000000000000018B520000000000025B8100000000000000 -0000000000000027CC95200000259CC72000000000000000 -0000000000000000259B6111116B95200000000000000000 -000000000000000000010000000100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartMsgNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000002345432000000000000000000000 -000000000000000000116CCCCC6110000000000000000000 -000000000000000111101221221011110000000000000000 -000000000000164100000000000000014610000000000000 -000000000003AC4000000000000000004CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000000881000000000000000000000001880000000000 -000000001110000000000000000000000000111000000000 -000000011000000000000000000000000000001100000000 -000000010000000000000000000000000000000100000000 -000000110000000000000000000000000000000110000000 -000000100000000000000000000000000000000010000000 -000005910000000888888888888888880000000195000000 -000009900000008FFFFFFFFFFFFFFFFF8000000099000000 -00001B500000008FDA88888888888ADF800000005B100000 -00002C300000008F854000000000458F800000003C200000 -00003C200000008F815510000015518F800000002C300000 -00004C200000008F800451000154008F800000002C400000 -000026100000008F800036202630008F8000000016200000 -000001000000008F800002646200008F8000000001000000 -000001000000008F800000252000008F8000000001000000 -000001000000008F800000000000008F8000000001000000 -000001000000008FB8888888888888BF8000000001000000 -000001100000008FFFFFFFFFFFFFFFFF8000000011000000 -000000310000000888888888888888880000000130000000 -000002C500000000000000000000000000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA000000000000000000000000000AA100000000 -000000003410000000000000000000000000143000000000 -000000000011000000000000000000000001100000000000 -000000000001100000000000000000000011000000000000 -000000000000112100000000000000012110000000000000 -000000000000018B520000000000025B8100000000000000 -0000000000000027CC95200000259CC72000000000000000 -0000000000000000259B6111116B95200000000000000000 -000000000000000000010000000100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartTimerNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000002345432000000000000000000000 -000000000000000000116CCCCC6110000000000000000000 -000000000000000111101221221011110000000000000000 -000000000000164100000000000000014610000000000000 -000000000003AC4000000000000000004CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000000881000000000000000000000001880000000000 -000000001110000000000000000000000000111000000000 -000000011000000000000000000000000000001100000000 -000000010000000000000000000000000000000100000000 -000000110000000000000133310000000000000110000000 -000000100000000000039BBBBB9300000000000010000000 -0000059100000000006C8312138C60000000000195000000 -000009900000000006B400151004B6000000000099000000 -00001B50000000003C40001510004C30000000005B100000 -00002C30000000009800001510000890000000003C200000 -00003C2000000001B3000015100003B1000000002C300000 -00004C2000000003B1000015111001B3000000002C400000 -0000261000000003B0000015555300B30000000016200000 -0000010000000003B1000001111001B30000000001000000 -0000010000000001B3000000000003B10000000001000000 -000001000000000098000000000008900000000001000000 -00000100000000003C40000000004C300000000001000000 -000001100000000006B400000004B6000000000011000000 -0000003100000000006C8310138C60000000000130000000 -000002C50000000000039BBBBB93000000000005C2000000 -0000007B1000000000000133310000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA000000000000000000000000000AA100000000 -000000003410000000000000000000000000143000000000 -000000000011000000000000000000000001100000000000 -000000000001100000000000000000000011000000000000 -000000000000112100000000000000012110000000000000 -000000000000018B520000000000025B8100000000000000 -0000000000000027CC95200000259CC72000000000000000 -0000000000000000259B6111116B95200000000000000000 -000000000000000000010000000100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evStartSignalNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000002345432000000000000000000000 -000000000000000000116CCCCC6110000000000000000000 -000000000000000111101221221011110000000000000000 -000000000000164100000000000000014610000000000000 -000000000003AC4000000000000000004CA3000000000000 -00000000004BA300000000000000000003AB400000000000 -0000000004B8100000000000000000000018B40000000000 -000000000881000000000000000000000001880000000000 -000000001110000000000000000000000000111000000000 -000000011000000000000000000000000000001100000000 -000000010000000000000000000000000000000100000000 -000000110000000000000003000000000000000110000000 -000000100000000000000037300000000000000010000000 -000005910000000000000164610000000000000195000000 -000009900000000000000351530000000000000099000000 -00001B50000000000000162026100000000000005B100000 -00002C30000000000000450005400000000000003C200000 -00003C20000000000001620002610000000000002C300000 -00004C20000000000004500000540000000000002C400000 -000026100000000000262000002620000000000016200000 -000001000000000000540000000450000000000001000000 -000001000000000002610000000162000000000001000000 -000001000000000005400000000045000000000001000000 -000001000000000026100000000016200000000001000000 -000001100000000054111111111114500000000011000000 -000000310000000266666666666666620000000130000000 -000002C500000000111111111111111000000005C2000000 -0000007B1000000000000000000000000000001B70000000 -0000002C6000000000000000000000000000006C20000000 -00000006C30000000000000000000000000003C600000000 -00000001AA000000000000000000000000000AA100000000 -000000003410000000000000000000000000143000000000 -000000000011000000000000000000000001100000000000 -000000000001100000000000000000000011000000000000 -000000000000112100000000000000012110000000000000 -000000000000018B520000000000025B8100000000000000 -0000000000000027CC95200000259CC72000000000000000 -0000000000000000259B6111116B95200000000000000000 -000000000000000000010000000100000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evInter [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000000000000000005B105B000000 -00002C303C200000000000000000000000002C303C200000 -00003B105B000000000000000000000000000B501B300000 -00005B00780000000000000000000000000008700B500000 -00006A00970000000000000000000000000007900A600000 -000069009600000000000000000000000000069009600000 -00006A00970000000000000000000000000007900A600000 -00005B00780000000000000000000000000008700B500000 -00003B105B000000000000000000000000000B501B300000 -00002C303C200000000000000000000000002C303C200000 -00000B501B500000000000000000000000005B105B000000 -000007A006B1000000000000000000000001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000888888888888888880001B600A7000000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -00002C303C20008FDA88888888888ADF80002C303C200000 -00003B105B00008F854000000000458F80000B501B300000 -00005B007800008F815510000015518F800008700B500000 -00006A009700008F800451000154008F800007900A600000 -000069009600008F800036202630008F8000069009600000 -00006A009700008F800002646200008F800007900A600000 -00005B007800008F800000252000008F800008700B500000 -00003B105B00008F800000000000008F80000B501B300000 -00002C303C20008FB8888888888888BF80002C303C200000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -000007A006B1000888888888888888880001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchTimer [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000013331000000002B500890000000 -000003C202B5000000039BBBBB9300000005B202C3000000 -000007A006B10000006C8312138C60000001B600A7000000 -00000B501B50000006B400151004B60000005B105B000000 -00002C303C2000003C40001510004C3000002C303C200000 -00003B105B000000980000151000089000000B501B300000 -00005B0078000001B3000015100003B1000008700B500000 -00006A0097000003B1000015111001B3000007900A600000 -0000690096000003B0000015555300B30000069009600000 -00006A0097000003B1000001111001B3000007900A600000 -00005B0078000001B3000000000003B1000008700B500000 -00003B105B000000980000000000089000000B501B300000 -00002C303C2000003C40000000004C3000002C303C200000 -00000B501B50000006B400000004B60000005B105B000000 -000007A006B10000006C8310138C60000001B600A7000000 -000003C202B5000000039BBBBB9300000005B202C3000000 -00000098005B20000000013331000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchSignal [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000300000000002B500890000000 -000003C202B5000000000037300000000005B202C3000000 -000007A006B1000000000164610000000001B600A7000000 -00000B501B500000000003515300000000005B105B000000 -00002C303C200000000016202610000000002C303C200000 -00003B105B000000000045000540000000000B501B300000 -00005B00780000000001620002610000000008700B500000 -00006A00970000000004500000540000000007900A600000 -000069009600000000262000002620000000069009600000 -00006A00970000000054000000045000000007900A600000 -00005B00780000000261000000016200000008700B500000 -00003B105B000000054000000000450000000B501B300000 -00002C303C200000261000000000162000002C303C200000 -00000B501B500000541111111111145000005B105B000000 -000007A006B1000266666666666666620001B600A7000000 -000003C202B5000011111111111111100005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchError [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000100000000199103C30000000 -00000098005B20000000000151000000002B500890000000 -000003C202B5000000000004500000000005B202C3000000 -000007A006B1000000000016200000000001B600A7000000 -00000B501B500000000000450000000000005B105B000000 -00002C303C200000000001630000000000002C303C200000 -00003B105B000000000003610000000000000B501B300000 -00005B00780000000000164111100000000008700B500000 -00006A00970000000000366666610000000007900A600000 -000069009600000000006211144000000000069009600000 -00006A00970000000002644426200000000007900A600000 -00005B00780000000002444555000000000008700B500000 -00003B105B000000000000066200000000000B501B300000 -00002C303C200000000000166000000000002C303C200000 -00000B501B500000000000273000000000005B105B000000 -000007A006B1000000000046100000000001B600A7000000 -000003C202B5000000000064000000000005B202C3000000 -00000098005B20000000015100000000002B500890000000 -0000003C3019910000000010000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchEsc [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000000000000000005B105B000000 -00002C303C200000000000000000000000002C303C200000 -00003B105B000000000000010000000000000B501B300000 -00005B00780000000000002620000000000008700B500000 -00006A00970000000000026662000000000007900A600000 -000069009600000000002636362000000000069009600000 -00006A00970000000002621612620000000007900A600000 -00005B00780000000026201610262000000008700B500000 -00003B105B000000026311262113620000000B501B300000 -00002C303C200000166666666666661000002C303C200000 -00000B501B500000011111262111110000005B105B000000 -000007A006B1000000000016100000000001B600A7000000 -000003C202B5000000000015100000000005B202C3000000 -00000098005B20000000000100000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchCond [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910004888888888884000199103C30000000 -00000098005B20004BFFFFFFFFFFFB40002B500890000000 -000003C202B500008FB888888888BF800005B202C3000000 -000007A006B100008F80000000008F800001B600A7000000 -00000B501B5000008F80000000008F8000005B105B000000 -00002C303C2000008F80000000008F8000002C303C200000 -00003B105B0000008F82555555528F8000000B501B300000 -00005B00780000008F80000000008F80000008700B500000 -00006A00970000008F80000000008F80000007900A600000 -00006900960000008F82555555528F800000069009600000 -00006A00970000008F80000000008F80000007900A600000 -00005B00780000008F80000000008F80000008700B500000 -00003B105B0000008F82555555528F8000000B501B300000 -00002C303C2000008F80000000008F8000002C303C200000 -00000B501B5000008F80000000008F8000005B105B000000 -000007A006B100008F80000000008F800001B600A7000000 -000003C202B500008FB888888888BF800005B202C3000000 -00000098005B20004BFFFFFFFFFFFB40002B500890000000 -0000003C3019910004888888888884000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchLink [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000001000000000005B105B000000 -00002C303C200000000000016200000000002C303C200000 -00003B105B000000000000002620000000000B501B300000 -00005B00780000000000000002620000000008700B500000 -00006A00970000011111111111362000000007900A600000 -000069009600001566666666666661000000069009600000 -00006A00970000011111111111362000000007900A600000 -00005B00780000000000000002620000000008700B500000 -00003B105B000000000000002620000000000B501B300000 -00002C303C200000000000016200000000002C303C200000 -00000B501B500000000000001000000000005B105B000000 -000007A006B1000000000000000000000001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchComp [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000100000001000000000005B202C3000000 -000007A006B1000541000026200000000001B600A7000000 -00000B501B500005551000266300000000005B105B000000 -00002C303C200005346200252640000000002C303C200000 -00003B105B000005203630250155100000000B501B300000 -00005B00780000052002543500145200000008700B500000 -00006A00970000052000155500003630000007900A600000 -000069009600000520000155000003610000069009600000 -00006A00970000052000155500003630000007900A600000 -00005B00780000052002543500145200000008700B500000 -00003B105B000005203630250155100000000B501B300000 -00002C303C200005346200252640000000002C303C200000 -00000B501B500005551000266300000000005B105B000000 -000007A006B1000541000026200000000001B600A7000000 -000003C202B5000100000001000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchCancel [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000001000000000001000005B202C3000000 -000007A006B1000016300000000036100001B600A7000000 -00000B501B500000036300000003630000005B105B000000 -00002C303C200000003630000036300000002C303C200000 -00003B105B000000000363000363000000000B501B300000 -00005B00780000000000363036300000000008700B500000 -00006A00970000000000036463000000000007900A600000 -000069009600000000000048400000000000069009600000 -00006A00970000000000036463000000000007900A600000 -00005B00780000000000363036300000000008700B500000 -00003B105B000000000363000363000000000B501B300000 -00002C303C200000003630000036300000002C303C200000 -00000B501B500000036300000003630000005B105B000000 -000007A006B1000016300000000036100001B600A7000000 -000003C202B5000001000000000001000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchMulti [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000001310000000002B500890000000 -000003C202B5000000000156510000000005B202C3000000 -000007A006B1000000003631363000000001B600A7000000 -00000B501B500000001462000264100000005B105B000000 -00002C303C200000025510000015520000002C303C200000 -00003B105B000000363000000000363000000B501B300000 -00005B00780000016200000000000261000008700B500000 -00006A00970000005300000000000350000007900A600000 -000069009600000025000000000005200000069009600000 -00006A00970000001610000000001610000007900A600000 -00005B00780000000530000000003500000008700B500000 -00003B105B000000035000000000530000000B501B300000 -00002C303C200000016100000001610000002C303C200000 -00000B501B500000005311111113500000005B105B000000 -000007A006B1000000366666666630000001B600A7000000 -000003C202B5000000011111111100000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evCatchPar [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000135653100000002B500890000000 -000003C202B500000015BBA9ABB510000005B202C3000000 -000007A006B10000019B5101015B91000001B600A7000000 -00000B501B50000019A200151002A91000005B105B000000 -00002C303C2000005B20001610002B5000002C303C200000 -00003B105B000001B5000016100005B100000B501B300000 -00005B0078000003B1000016100001B3000008700B500000 -00006A0097000005A0111126211110A5000007900A600000 -000069009600000691566666666651960000069009600000 -00006A0097000005A0111126211110A5000007900A600000 -00005B0078000003B1000016100001B3000008700B500000 -00003B105B000001B5000016100005B100000B501B300000 -00002C303C2000005B20001610002B5000002C303C200000 -00000B501B50000019A200151002A91000005B105B000000 -000007A006B10000019B5101015B91000001B600A7000000 -000003C202B500000015BBA9ABB510000005B202C3000000 -00000098005B20000000135653100000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000888888888888888880001B600A7000000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -00002C303C20008FFFFFFFFFFFFFFFFF80002C303C200000 -00003B105B00008FFFFFFFFFFFFFFFFF80000B501B300000 -00005B007800008FFFFFFFFFFFFFFFFF800008700B500000 -00006A009700008FFFFFFFFFFFFFFFFF800007900A600000 -000069009600008FFFFFFFFFFFFFFFFF8000069009600000 -00006A009700008FFFFFFFFFFFFFFFFF800007900A600000 -00005B007800008FFFFFFFFFFFFFFFFF800008700B500000 -00003B105B00008FFFFFFFFFFFFFFFFF80000B501B300000 -00002C303C20008FFFFFFFFFFFFFFFFF80002C303C200000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -000007A006B1000888888888888888880001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowSignal [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000300000000002B500890000000 -000003C202B500000000003F300000000005B202C3000000 -000007A006B10000000001AFA10000000001B600A7000000 -00000B501B500000000006FFF600000000005B105B000000 -00002C303C20000000001CFFFC10000000002C303C200000 -00003B105B00000000006FFFFF60000000000B501B300000 -00005B00780000000001CFFFFFC10000000008700B500000 -00006A00970000000006FFFFFFF60000000007900A600000 -0000690096000000002DFFFFFFFD20000000069009600000 -00006A0097000000007FFFFFFFFF7000000007900A600000 -00005B007800000002DFFFFFFFFFD200000008700B500000 -00003B105B00000009FFFFFFFFFFF90000000B501B300000 -00002C303C2000002FFFFFFFFFFFFF2000002C303C200000 -00000B501B5000009FFFFFFFFFFFFF9000005B105B000000 -000007A006B10002FFFFFFFFFFFFFFF20001B600A7000000 -000003C202B5000011111111111111100005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowEsc [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000000000000000005B105B000000 -00002C303C200000000000000000000000002C303C200000 -00003B105B000000000000010000000000000B501B300000 -00005B00780000000000002620000000000008700B500000 -00006A00970000000000026662000000000007900A600000 -000069009600000000002636362000000000069009600000 -00006A00970000000002621612620000000007900A600000 -00005B00780000000026201610262000000008700B500000 -00003B105B000000026311262113620000000B501B300000 -00002C303C200000166666666666661000002C303C200000 -00000B501B500000011111262111110000005B105B000000 -000007A006B1000000000016100000000001B600A7000000 -000003C202B5000000000015100000000005B202C3000000 -00000098005B20000000000100000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowLink [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000001000000000005B105B000000 -00002C303C200000000000016200000000002C303C200000 -00003B105B000000000000002620000000000B501B300000 -00005B00780000000000000002620000000008700B500000 -00006A00970000011111111111362000000007900A600000 -000069009600001566666666666661000000069009600000 -00006A00970000011111111111362000000007900A600000 -00005B00780000000000000002620000000008700B500000 -00003B105B000000000000002620000000000B501B300000 -00002C303C200000000000016200000000002C303C200000 -00000B501B500000000000001000000000005B105B000000 -000007A006B1000000000000000000000001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowComp [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000100000001000000000005B202C3000000 -000007A006B1000541000026200000000001B600A7000000 -00000B501B500005551000266300000000005B105B000000 -00002C303C200005346200252640000000002C303C200000 -00003B105B000005203630250155100000000B501B300000 -00005B00780000052002543500145200000008700B500000 -00006A00970000052000155500003630000007900A600000 -000069009600000520000155000003610000069009600000 -00006A00970000052000155500003630000007900A600000 -00005B00780000052002543500145200000008700B500000 -00003B105B000005203630250155100000000B501B300000 -00002C303C200005346200252640000000002C303C200000 -00000B501B500005551000266300000000005B105B000000 -000007A006B1000541000026200000000001B600A7000000 -000003C202B5000100000001000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evThrowMulti [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000001310000000002B500890000000 -000003C202B5000000000156510000000005B202C3000000 -000007A006B1000000003631363000000001B600A7000000 -00000B501B500000001462000264100000005B105B000000 -00002C303C200000025510000015520000002C303C200000 -00003B105B000000363000000000363000000B501B300000 -00005B00780000016200000000000261000008700B500000 -00006A00970000005300000000000350000007900A600000 -000069009600000025000000000005200000069009600000 -00006A00970000001610000000001610000007900A600000 -00005B00780000000530000000003500000008700B500000 -00003B105B000000035000000000530000000B501B300000 -00002C303C200000016100000001610000002C303C200000 -00000B501B500000005311111113500000005B105B000000 -000007A006B1000000366666666630000001B600A7000000 -000003C202B5000000011111111100000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEnd [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000000000000000016BD83000000 -000005AE9400000000000000000000000000049EA5000000 -000017CC7100000000000000000000000000017CC7100000 -000039EA4000000000000000000000000000004AE9300000 -00004AE830000000000000000000000000000038EA400000 -00015BD720000000000000000000000000000027DB510000 -00016CC610000000000000000000000000000016CC610000 -00017CC610000000000000000000000000000016CC710000 -00017DB610000000000000000000000000000016BD710000 -00017CC610000000000000000000000000000016CC710000 -00016CC610000000000000000000000000000016CC610000 -00015BD720000000000000000000000000000027DB510000 -00004AE830000000000000000000000000000038EA400000 -000039EA4000000000000000000000000000004AE9300000 -000017CC7100000000000000000000000000017CC7100000 -000005AE9400000000000000000000000000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000000000000000016BD83000000 -000005AE9400000000000000000000000000049EA5000000 -000017CC7100000888888888888888880000017CC7100000 -000039EA4000008FFFFFFFFFFFFFFFFF8000004AE9300000 -00004AE83000008FFFFFFFFFFFFFFFFF80000038EA400000 -00015BD72000008FFFFFFFFFFFFFFFFF80000027DB510000 -00016CC61000008FFFFFFFFFFFFFFFFF80000016CC610000 -00017CC61000008FFFFFFFFFFFFFFFFF80000016CC710000 -00017DB61000008FFFFFFFFFFFFFFFFF80000016BD710000 -00017CC61000008FFFFFFFFFFFFFFFFF80000016CC710000 -00016CC61000008FFFFFFFFFFFFFFFFF80000016CC610000 -00015BD72000008FFFFFFFFFFFFFFFFF80000027DB510000 -00004AE83000008FFFFFFFFFFFFFFFFF80000038EA400000 -000039EA4000008FFFFFFFFFFFFFFFFF8000004AE9300000 -000017CC7100000888888888888888880000017CC7100000 -000005AE9400000000000000000000000000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndError [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000010000000000049EB51000000 -0000038DB61000000000000151000000000016BD83000000 -000005AE9400000000000004500000000000049EA5000000 -000017CC7100000000000016200000000000017CC7100000 -000039EA4000000000000045000000000000004AE9300000 -00004AE830000000000001630000000000000038EA400000 -00015BD720000000000003610000000000000027DB510000 -00016CC610000000000016411110000000000016CC610000 -00017CC610000000000036666661000000000016CC710000 -00017DB610000000000062111440000000000016BD710000 -00017CC610000000000264442620000000000016CC710000 -00016CC610000000000244455500000000000016CC610000 -00015BD720000000000000066200000000000027DB510000 -00004AE830000000000000166000000000000038EA400000 -000039EA4000000000000027300000000000004AE9300000 -000017CC7100000000000046100000000000017CC7100000 -000005AE9400000000000064000000000000049EA5000000 -0000038DB61000000000015100000000000016BD83000000 -0000015BE94000000000001000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndEsc [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000000000000000016BD83000000 -000005AE9400000000000000000000000000049EA5000000 -000017CC7100000000000000000000000000017CC7100000 -000039EA4000000000000000000000000000004AE9300000 -00004AE830000000000000000000000000000038EA400000 -00015BD720000000000000010000000000000027DB510000 -00016CC610000000000000262000000000000016CC610000 -00017CC610000000000002666200000000000016CC710000 -00017DB610000000000026363620000000000016BD710000 -00017CC610000000000262161262000000000016CC710000 -00016CC610000000002620161026200000000016CC610000 -00015BD720000000026311262113620000000027DB510000 -00004AE830000000166666666666661000000038EA400000 -000039EA4000000001111126211111000000004AE9300000 -000017CC7100000000000016100000000000017CC7100000 -000005AE9400000000000015100000000000049EA5000000 -0000038DB61000000000000100000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndCancel [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000000000000000016BD83000000 -000005AE9400000001000000000001000000049EA5000000 -000017CC7100000016300000000036100000017CC7100000 -000039EA4000000003630000000363000000004AE9300000 -00004AE830000000003630000036300000000038EA400000 -00015BD720000000000363000363000000000027DB510000 -00016CC610000000000036303630000000000016CC610000 -00017CC610000000000003646300000000000016CC710000 -00017DB610000000000000484000000000000016BD710000 -00017CC610000000000003646300000000000016CC710000 -00016CC610000000000036303630000000000016CC610000 -00015BD720000000000363000363000000000027DB510000 -00004AE830000000003630000036300000000038EA400000 -000039EA4000000003630000000363000000004AE9300000 -000017CC7100000016300000000036100000017CC7100000 -000005AE9400000001000000000001000000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndComp [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000000000000000016BD83000000 -000005AE9400000100000001000000000000049EA5000000 -000017CC7100000541000026200000000000017CC7100000 -000039EA4000000555100026630000000000004AE9300000 -00004AE830000005346200252640000000000038EA400000 -00015BD720000005203630250155100000000027DB510000 -00016CC610000005200254350014520000000016CC610000 -00017CC610000005200015550000363000000016CC710000 -00017DB610000005200001550000036100000016BD710000 -00017CC610000005200015550000363000000016CC710000 -00016CC610000005200254350014520000000016CC610000 -00015BD720000005203630250155100000000027DB510000 -00004AE830000005346200252640000000000038EA400000 -000039EA4000000555100026630000000000004AE9300000 -000017CC7100000541000026200000000000017CC7100000 -000005AE9400000100000001000000000000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndSignal [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000300000000000016BD83000000 -000005AE940000000000003F300000000000049EA5000000 -000017CC71000000000001AFA10000000000017CC7100000 -000039EA40000000000006FFF60000000000004AE9300000 -00004AE83000000000001CFFFC10000000000038EA400000 -00015BD72000000000006FFFFF60000000000027DB510000 -00016CC6100000000001CFFFFFC1000000000016CC610000 -00017CC6100000000006FFFFFFF6000000000016CC710000 -00017DB610000000002DFFFFFFFD200000000016BD710000 -00017CC610000000007FFFFFFFFF700000000016CC710000 -00016CC61000000002DFFFFFFFFFD20000000016CC610000 -00015BD72000000009FFFFFFFFFFF90000000027DB510000 -00004AE8300000002FFFFFFFFFFFFF2000000038EA400000 -000039EA400000009FFFFFFFFFFFFF900000004AE9300000 -000017CC71000002FFFFFFFFFFFFFFF20000017CC7100000 -000005AE9400000011111111111111100000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evEndMulti [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000001310000000000016BD83000000 -000005AE9400000000000156510000000000049EA5000000 -000017CC7100000000003631363000000000017CC7100000 -000039EA4000000000146200026410000000004AE9300000 -00004AE830000000025510000015520000000038EA400000 -00015BD720000000363000000000363000000027DB510000 -00016CC610000001620000000000026100000016CC610000 -00017CC610000000530000000000035000000016CC710000 -00017DB610000000250000000000052000000016BD710000 -00017CC610000000161000000000161000000016CC710000 -00016CC610000000053000000000350000000016CC610000 -00015BD720000000035000000000530000000027DB510000 -00004AE830000000016100000001610000000038EA400000 -000039EA4000000000531111111350000000004AE9300000 -000017CC7100000000366666666630000000017CC7100000 -000005AE9400000000011111111100000000049EA5000000 -0000038DB61000000000000000000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evTerminate [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000001345677765431000000000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000026AEC841000000000000000148CEA62000000000 -000000016AEB73000000000000000000037BEA6100000000 -000000049DC7300000000000000000000037CD9400000000 -00000038CD830000000000000000000000038DC830000000 -0000015BE94000000000000000000000000049EB51000000 -0000038DB61000000000000100000000000016BD83000000 -000005AE940000000000379A973000000000049EA5000000 -000017CC710000000027CFFFFFC720000000017CC7100000 -000039EA40000000029EFFFFFFFE92000000004AE9300000 -00004AE83000000007EFFFFFFFFFE70000000038EA400000 -00015BD7200000003CFFFFFFFFFFFC3000000027DB510000 -00016CC6100000007FFFFFFFFFFFFF7000000016CC610000 -00017CC6100000009FFFFFFFFFFFFF9000000016CC710000 -00017DB610000001AFFFFFFFFFFFFFA100000016BD710000 -00017CC6100000009FFFFFFFFFFFFF9000000016CC710000 -00016CC6100000007FFFFFFFFFFFFF7000000016CC610000 -00015BD7200000003CFFFFFFFFFFFC3000000027DB510000 -00004AE83000000007EFFFFFFFFFE70000000038EA400000 -000039EA40000000029EFFFFFFFE92000000004AE9300000 -000017CC710000000027CFFFFFC720000000017CC7100000 -000005AE940000000000379A973000000000049EA5000000 -0000038DB61000000000000100000000000016BD83000000 -0000015BE94000000000000000000000000049EB51000000 -00000038CD830000000000000000000000038DC830000000 -000000049DC7300000000000000000000037CD9400000000 -000000016AEB73000000000000000000037BEA6100000000 -0000000026AEC841000000000000000148CEA62000000000 -00000000026ADD9641000000000001469DDA620000000000 -0000000000269CEB974321111123479BEC96200000000000 -00000000000148BDECA876666678ACEDB841000000000000 -0000000000000358ACEEDCCBCCDEECA85300000000000000 -0000000000000013579ABCCDCCBA97531000000000000000 -000000000000000001345677765431000000000000000000 -000000000000000000001111111000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000888888888888888880001B600A7000000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -00002C303C20008FDA88888888888ADF80002C303C200000 -00003B105B00008F854000000000458F80000B501B300000 -00005B007800008F815510000015518F800008700B500000 -00006A009700008F800451000154008F800007900A600000 -000069009600008F800036202630008F8000069009600000 -00006A009700008F800002646200008F800007900A600000 -00005B007800008F800000252000008F800008700B500000 -00003B105B00008F800000000000008F80000B501B300000 -00002C303C20008FB8888888888888BF80002C303C200000 -00000B501B50008FFFFFFFFFFFFFFFFF80005B105B000000 -000007A006B1000888888888888888880001B600A7000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundTimer [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000013331000000002B500890000000 -000003C202B5000000039BBBBB9300000005B202C3000000 -000007A006B10000006C8312138C60000001B600A7000000 -00000B501B50000006B400151004B60000005B105B000000 -00002C303C2000003C40001510004C3000002C303C200000 -00003B105B000000980000151000089000000B501B300000 -00005B0078000001B3000015100003B1000008700B500000 -00006A0097000003B1000015111001B3000007900A600000 -0000690096000003B0000015555300B30000069009600000 -00006A0097000003B1000001111001B3000007900A600000 -00005B0078000001B3000000000003B1000008700B500000 -00003B105B000000980000000000089000000B501B300000 -00002C303C2000003C40000000004C3000002C303C200000 -00000B501B50000006B400000004B60000005B105B000000 -000007A006B10000006C8310138C60000001B600A7000000 -000003C202B5000000039BBBBB9300000005B202C3000000 -00000098005B20000000013331000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundError [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000100000000199103C30000000 -00000098005B20000000000151000000002B500890000000 -000003C202B5000000000004500000000005B202C3000000 -000007A006B1000000000016200000000001B600A7000000 -00000B501B500000000000450000000000005B105B000000 -00002C303C200000000001630000000000002C303C200000 -00003B105B000000000003610000000000000B501B300000 -00005B00780000000000164111100000000008700B500000 -00006A00970000000000366666610000000007900A600000 -000069009600000000006211144000000000069009600000 -00006A00970000000002644426200000000007900A600000 -00005B00780000000002444555000000000008700B500000 -00003B105B000000000000066200000000000B501B300000 -00002C303C200000000000166000000000002C303C200000 -00000B501B500000000000273000000000005B105B000000 -000007A006B1000000000046100000000001B600A7000000 -000003C202B5000000000064000000000005B202C3000000 -00000098005B20000000015100000000002B500890000000 -0000003C3019910000000010000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundEsc [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000000000000000000000005B202C3000000 -000007A006B1000000000000000000000001B600A7000000 -00000B501B500000000000000000000000005B105B000000 -00002C303C200000000000000000000000002C303C200000 -00003B105B000000000000010000000000000B501B300000 -00005B00780000000000002620000000000008700B500000 -00006A00970000000000026662000000000007900A600000 -000069009600000000002636362000000000069009600000 -00006A00970000000002621612620000000007900A600000 -00005B00780000000026201610262000000008700B500000 -00003B105B000000026311262113620000000B501B300000 -00002C303C200000166666666666661000002C303C200000 -00000B501B500000011111262111110000005B105B000000 -000007A006B1000000000016100000000001B600A7000000 -000003C202B5000000000015100000000005B202C3000000 -00000098005B20000000000100000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundSignal [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000300000000002B500890000000 -000003C202B5000000000037300000000005B202C3000000 -000007A006B1000000000164610000000001B600A7000000 -00000B501B500000000003515300000000005B105B000000 -00002C303C200000000016202610000000002C303C200000 -00003B105B000000000045000540000000000B501B300000 -00005B00780000000001620002610000000008700B500000 -00006A00970000000004500000540000000007900A600000 -000069009600000000262000002620000000069009600000 -00006A00970000000054000000045000000007900A600000 -00005B00780000000261000000016200000008700B500000 -00003B105B000000054000000000450000000B501B300000 -00002C303C200000261000000000162000002C303C200000 -00000B501B500000541111111111145000005B105B000000 -000007A006B1000266666666666666620001B600A7000000 -000003C202B5000011111111111111100005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundCancel [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000001000000000001000005B202C3000000 -000007A006B1000016300000000036100001B600A7000000 -00000B501B500000036300000003630000005B105B000000 -00002C303C200000003630000036300000002C303C200000 -00003B105B000000000363000363000000000B501B300000 -00005B00780000000000363036300000000008700B500000 -00006A00970000000000036463000000000007900A600000 -000069009600000000000048400000000000069009600000 -00006A00970000000000036463000000000007900A600000 -00005B00780000000000363036300000000008700B500000 -00003B105B000000000363000363000000000B501B300000 -00002C303C200000003630000036300000002C303C200000 -00000B501B500000036300000003630000005B105B000000 -000007A006B1000016300000000036100001B600A7000000 -000003C202B5000001000000000001000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundComp [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910000000000000000000199103C30000000 -00000098005B20000000000000000000002B500890000000 -000003C202B5000100000001000000000005B202C3000000 -000007A006B1000541000026200000000001B600A7000000 -00000B501B500005551000266300000000005B105B000000 -00002C303C200005346200252640000000002C303C200000 -00003B105B000005203630250155100000000B501B300000 -00005B00780000052002543500145200000008700B500000 -00006A00970000052000155500003630000007900A600000 -000069009600000520000155000003610000069009600000 -00006A00970000052000155500003630000007900A600000 -00005B00780000052002543500145200000008700B500000 -00003B105B000005203630250155100000000B501B300000 -00002C303C200005346200252640000000002C303C200000 -00000B501B500005551000266300000000005B105B000000 -000007A006B1000541000026200000000001B600A7000000 -000003C202B5000100000001000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019910000000000000000000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundCond [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -0000000000000039CA53100000135AC93000000000000000 -00000000000028C82000000000000028C820000000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000005C710026BCB87678BCB620017C500000000000 -0000000005C50015BB52000000025BB51005C50000000000 -000000004C50029B510000000000015B92005C4000000000 -00000002B7002B9200000000000000029B2007B200000000 -00000008A102B810000000000000000018B201A800000000 -0000003C3019910004888888888884000199103C30000000 -00000098005B20004BFFFFFFFFFFFB40002B500890000000 -000003C202B500008FB888888888BF800005B202C3000000 -000007A006B100008F80000000008F800001B600A7000000 -00000B501B5000008F80000000008F8000005B105B000000 -00002C303C2000008F80000000008F8000002C303C200000 -00003B105B0000008F82555555528F8000000B501B300000 -00005B00780000008F80000000008F80000008700B500000 -00006A00970000008F80000000008F80000007900A600000 -00006900960000008F82555555528F800000069009600000 -00006A00970000008F80000000008F80000007900A600000 -00005B00780000008F80000000008F80000008700B500000 -00003B105B0000008F82555555528F8000000B501B300000 -00002C303C2000008F80000000008F8000002C303C200000 -00000B501B5000008F80000000008F8000005B105B000000 -000007A006B100008F80000000008F800001B600A7000000 -000003C202B500008FB888888888BF800005B202C3000000 -00000098005B20004BFFFFFFFFFFFB40002B500890000000 -0000003C3019910004888888888884000199103C30000000 -00000008A102B810000000000000000018B201A800000000 -00000002B7002B9200000000000000029B2007B200000000 -000000004C50029B510000000000015B92005C4000000000 -0000000005C50015BB52000000025BB51005C50000000000 -00000000005C710026BCB87678BCB620017C500000000000 -000000000004BA3000135799975310003AB4000000000000 -00000000000028C82000000000000028C820000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BCBBA9ABBCB7300000000000000000 -000000000000000000023566653200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundMsgNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000003566653000000000000000000000 -000000000000000001116BA9AB6111000000000000000000 -000000000000000111000000000001110000000000000000 -000000000000284100000000000000014820000000000000 -000000000004BA2000000799970000002AB4000000000000 -00000000005C71000011187678111000017C500000000000 -0000000005C5001511000000000001151005C50000000000 -000000000950029B300000000000003B9200590000000000 -0000000011002B9200000000000000029B20011000000000 -000000011000B810000000000000000018B0001100000000 -000000010001310000000000000000000131000100000000 -000000110001000000000000000000000001000110000000 -000000100010000000000000000000000000100010000000 -000007800010000888888888888888880000100087000000 -00000B501620008FFFFFFFFFFFFFFFFF800026105B000000 -00002C303C20008FDA88888888888ADF80002C303C200000 -00003B105B00008F854000000000458F80000B501B300000 -00005B007800008F815510000015518F800008700B500000 -00006A009700008F800451000154008F800007900A600000 -000035005300008F800036202630008F8000035005300000 -000001001100008F800002646200008F8000011001000000 -000001001100008F800000252000008F8000011001000000 -000001000100008F800000000000008F8000010001000000 -000001000100008FB8888888888888BF8000010001000000 -000001000540008FFFFFFFFFFFFFFFFF8000450001000000 -0000013006B1000888888888888888880001B60031000000 -000003C202B5000000000000000000000005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019700000000000000000000079103C30000000 -00000008A10111000000000000000000011101A800000000 -00000002B70001100000000000000000110007B200000000 -000000004400001121000000000001211000044000000000 -0000000000100000BB52000000025BB00000100000000000 -000000000001100026BCB11111BCB6200011000000000000 -000000000000111000135111115310001110000000000000 -000000000000019820000000000000289100000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BC6111116CB7300000000000000000 -000000000000000000020001000200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundTimerNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000003566653000000000000000000000 -000000000000000001116BA9AB6111000000000000000000 -000000000000000111000000000001110000000000000000 -000000000000284100000000000000014820000000000000 -000000000004BA2000000799970000002AB4000000000000 -00000000005C71000011187678111000017C500000000000 -0000000005C5001511000000000001151005C50000000000 -000000000950029B300000000000003B9200590000000000 -0000000011002B9200000000000000029B20011000000000 -000000011000B810000000000000000018B0001100000000 -000000010001310000000000000000000131000100000000 -000000110001000000000133310000000001000110000000 -000000100010000000039BBBBB9300000000100010000000 -0000078000100000006C8312138C60000000100087000000 -00000B501620000006B400151004B600000026105B000000 -00002C303C2000003C40001510004C3000002C303C200000 -00003B105B000000980000151000089000000B501B300000 -00005B0078000001B3000015100003B1000008700B500000 -00006A0097000003B1000015111001B3000007900A600000 -0000350053000003B0000015555300B30000035005300000 -0000010011000003B1000001111001B30000011001000000 -0000010011000001B3000000000003B10000011001000000 -000001000100000098000000000008900000010001000000 -00000100010000003C40000000004C300000010001000000 -000001000540000006B400000004B6000000450001000000 -0000013006B10000006C8310138C60000001B60031000000 -000003C202B5000000039BBBBB9300000005B202C3000000 -00000098005B20000000013331000000002B500890000000 -0000003C3019700000000000000000000079103C30000000 -00000008A10111000000000000000000011101A800000000 -00000002B70001100000000000000000110007B200000000 -000000004400001121000000000001211000044000000000 -0000000000100000BB52000000025BB00000100000000000 -000000000001100026BCB11111BCB6200011000000000000 -000000000000111000135111115310001110000000000000 -000000000000019820000000000000289100000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BC6111116CB7300000000000000000 -000000000000000000020001000200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundEscNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000003566653000000000000000000000 -000000000000000001116BA9AB6111000000000000000000 -000000000000000111000000000001110000000000000000 -000000000000284100000000000000014820000000000000 -000000000004BA2000000799970000002AB4000000000000 -00000000005C71000011187678111000017C500000000000 -0000000005C5001511000000000001151005C50000000000 -000000000950029B300000000000003B9200590000000000 -0000000011002B9200000000000000029B20011000000000 -000000011000B810000000000000000018B0001100000000 -000000010001310000000000000000000131000100000000 -000000110001000000000000000000000001000110000000 -000000100010000000000000000000000000100010000000 -000007800010000000000000000000000000100087000000 -00000B50162000000000000000000000000026105B000000 -00002C303C200000000000000000000000002C303C200000 -00003B105B000000000000010000000000000B501B300000 -00005B00780000000000002620000000000008700B500000 -00006A00970000000000026662000000000007900A600000 -000035005300000000002636362000000000035005300000 -000001001100000000026216126200000000011001000000 -000001001100000000262016102620000000011001000000 -000001000100000002631126211362000000010001000000 -000001000100000016666666666666100000010001000000 -000001000540000001111126211111000000450001000000 -0000013006B1000000000016100000000001B60031000000 -000003C202B5000000000015100000000005B202C3000000 -00000098005B20000000000100000000002B500890000000 -0000003C3019700000000000000000000079103C30000000 -00000008A10111000000000000000000011101A800000000 -00000002B70001100000000000000000110007B200000000 -000000004400001121000000000001211000044000000000 -0000000000100000BB52000000025BB00000100000000000 -000000000001100026BCB11111BCB6200011000000000000 -000000000000111000135111115310001110000000000000 -000000000000019820000000000000289100000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BC6111116CB7300000000000000000 -000000000000000000020001000200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundSignalNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000003566653000000000000000000000 -000000000000000001116BA9AB6111000000000000000000 -000000000000000111000000000001110000000000000000 -000000000000284100000000000000014820000000000000 -000000000004BA2000000799970000002AB4000000000000 -00000000005C71000011187678111000017C500000000000 -0000000005C5001511000000000001151005C50000000000 -000000000950029B300000000000003B9200590000000000 -0000000011002B9200000000000000029B20011000000000 -000000011000B810000000000000000018B0001100000000 -000000010001310000000000000000000131000100000000 -000000110001000000000003000000000001000110000000 -000000100010000000000037300000000000100010000000 -000007800010000000000164610000000000100087000000 -00000B50162000000000035153000000000026105B000000 -00002C303C200000000016202610000000002C303C200000 -00003B105B000000000045000540000000000B501B300000 -00005B00780000000001620002610000000008700B500000 -00006A00970000000004500000540000000007900A600000 -000035005300000000262000002620000000035005300000 -000001001100000000540000000450000000011001000000 -000001001100000002610000000162000000011001000000 -000001000100000005400000000045000000010001000000 -000001000100000026100000000016200000010001000000 -000001000540000054111111111114500000450001000000 -0000013006B1000266666666666666620001B60031000000 -000003C202B5000011111111111111100005B202C3000000 -00000098005B20000000000000000000002B500890000000 -0000003C3019700000000000000000000079103C30000000 -00000008A10111000000000000000000011101A800000000 -00000002B70001100000000000000000110007B200000000 -000000004400001121000000000001211000044000000000 -0000000000100000BB52000000025BB00000100000000000 -000000000001100026BCB11111BCB6200011000000000000 -000000000000111000135111115310001110000000000000 -000000000000019820000000000000289100000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BC6111116CB7300000000000000000 -000000000000000000020001000200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $evBoundCondNI [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000003566653000000000000000000000 -000000000000000001116BA9AB6111000000000000000000 -000000000000000111000000000001110000000000000000 -000000000000284100000000000000014820000000000000 -000000000004BA2000000799970000002AB4000000000000 -00000000005C71000011187678111000017C500000000000 -0000000005C5001511000000000001151005C50000000000 -000000000950029B300000000000003B9200590000000000 -0000000011002B9200000000000000029B20011000000000 -000000011000B810000000000000000018B0001100000000 -000000010001310004888888888884000131000100000000 -00000011000100004BFFFFFFFFFFFB400001000110000000 -00000010001000008FB888888888BF800000100010000000 -00000780001000008F80000000008F800000100087000000 -00000B50162000008F80000000008F80000026105B000000 -00002C303C2000008F80000000008F8000002C303C200000 -00003B105B0000008F82555555528F8000000B501B300000 -00005B00780000008F80000000008F80000008700B500000 -00006A00970000008F80000000008F80000007900A600000 -00003500530000008F82555555528F800000035005300000 -00000100110000008F80000000008F800000011001000000 -00000100110000008F80000000008F800000011001000000 -00000100010000008F82555555528F800000010001000000 -00000100010000008F80000000008F800000010001000000 -00000100054000008F80000000008F800000450001000000 -0000013006B100008F80000000008F800001B60031000000 -000003C202B500008FB888888888BF800005B202C3000000 -00000098005B20004BFFFFFFFFFFFB40002B500890000000 -0000003C3019700004888888888884000079103C30000000 -00000008A10111000000000000000000011101A800000000 -00000002B70001100000000000000000110007B200000000 -000000004400001121000000000001211000044000000000 -0000000000100000BB52000000025BB00000100000000000 -000000000001100026BCB11111BCB6200011000000000000 -000000000000111000135111115310001110000000000000 -000000000000019820000000000000289100000000000000 -0000000000000039CA53100000135AC93000000000000000 -000000000000000037BC6111116CB7300000000000000000 -000000000000000000020001000200000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwXor [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000000000000000004C40000000000000 -000000000004C400000000000000000004C4000000000000 -00000000004C40010000000000000001004C400000000000 -0000000004C4001630000000000000361004C40000000000 -000000004C400003630000000000036300004C4000000000 -00000004C40000003630000000003630000004C400000000 -0000004C4000000003630000000363000000004C40000000 -000004C400000000003630000036300000000004C4000000 -00004C40000000000003630003630000000000004C400000 -0004C4000000000000003631363000000000000004C40000 -004C400000000000000003646300000000000000004C4000 -01C60000000000000000014841000000000000000006C100 -004C400000000000000003646300000000000000004C4000 -0004C4000000000000003631363000000000000004C40000 -00004C40000000000003630003630000000000004C400000 -000004C400000000003630000036300000000004C4000000 -0000004C4000000003630000000363000000004C40000000 -00000004C40000003630000000003630000004C400000000 -000000004C400003630000000000036300004C4000000000 -0000000004C4001630000000000000361004C40000000000 -00000000004C40010000000000000001004C400000000000 -000000000004C400000000000000000004C4000000000000 -0000000000004C4000000000000000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwAnd [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000000000000000004C40000000000000 -000000000004C400000000020000000004C4000000000000 -00000000004C40000000002620000000004C400000000000 -0000000004C4000000000026200000000004C40000000000 -000000004C400000000000262000000000004C4000000000 -00000004C40000000000002620000000000004C400000000 -0000004C4000000000000026200000000000004C40000000 -000004C400000000000000262000000000000004C4000000 -00004C40000000000000002620000000000000004C400000 -0004C4000000000000000026200000000000000004C40000 -004C400000000022222222262222222220000000004C4000 -01C60000000002666666666666666666620000000006C100 -004C400000000022222222262222222220000000004C4000 -0004C4000000000000000026200000000000000004C40000 -00004C40000000000000002620000000000000004C400000 -000004C400000000000000262000000000000004C4000000 -0000004C4000000000000026200000000000004C40000000 -00000004C40000000000002620000000000004C400000000 -000000004C400000000000262000000000004C4000000000 -0000000004C4000000000026200000000004C40000000000 -00000000004C40000000002620000000004C400000000000 -000000000004C400000000020000000004C4000000000000 -0000000000004C4000000000000000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwOr [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000000000000000004C40000000000000 -000000000004C400000000000000000004C4000000000000 -00000000004C40000000000000000000004C400000000000 -0000000004C4000000000123210000000004C40000000000 -000000004C40000000027BCCCB72000000004C4000000000 -00000004C4000000004BB63336BB4000000004C400000000 -0000004C4000000004C710000017C4000000004C40000000 -000004C4000000002B71000000017B2000000004C4000000 -00004C40000000007B10000000001B70000000004C400000 -0004C40000000001B6000000000006B10000000004C40000 -004C400000000002C3000000000003C200000000004C4000 -01C6000000000003C3000000000003C3000000000006C100 -004C400000000002C3000000000003C200000000004C4000 -0004C40000000001B6000000000006B10000000004C40000 -00004C40000000007B10000000001B70000000004C400000 -000004C4000000002B71000000017B2000000004C4000000 -0000004C4000000004C710000017C4000000004C40000000 -00000004C4000000004BB63336BB4000000004C400000000 -000000004C40000000027BCCCB72000000004C4000000000 -0000000004C4000000000123210000000004C40000000000 -00000000004C40000000000000000000004C400000000000 -000000000004C400000000000000000004C4000000000000 -0000000000004C4000000000000000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwComplex [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000000000000000004C40000000000000 -000000000004C400000000000000000004C4000000000000 -00000000004C40000000000000000000004C400000000000 -0000000004C4000000000001000000000004C40000000000 -000000004C400000000000151000000000004C4000000000 -00000004C40000000010001610001000000004C400000000 -0000004C4000000001520016100251000000004C40000000 -000004C400000000002620161026200000000004C4000000 -00004C40000000000002621612620000000000004C400000 -0004C4000000000000002636362000000000000004C40000 -004C400000000000111113666311111000000000004C4000 -01C60000000000015666666866666651000000000006C100 -004C400000000000111113666311111000000000004C4000 -0004C4000000000000002636362000000000000004C40000 -00004C40000000000002621612620000000000004C400000 -000004C400000000002620161026200000000004C4000000 -0000004C4000000001520016100251000000004C40000000 -00000004C40000000010001610001000000004C400000000 -000000004C400000000000151000000000004C4000000000 -0000000004C4000000000001000000000004C40000000000 -00000000004C40000000000000000000004C400000000000 -000000000004C400000000000000000004C4000000000000 -0000000000004C4000000000000000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwEvent [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000002467642000004C40000000000000 -000000000004C400014ABA878ABA410004C4000000000000 -00000000004C40002AB510000015BA20004C400000000000 -0000000004C40003B7100013100017B30004C40000000000 -000000004C40002B600001565100006B20004C4000000000 -00000004C40001A70000363136300007A10004C400000000 -0000004C400004B10014620002641001B400004C40000000 -000004C400000A5002551000001552005A000004C4000000 -00004C4000002B1036300000000036301B2000004C400000 -0004C40000004A0162000000000002610A40000004C40000 -004C400000006800530000000000035008600000004C4000 -01C60000000077002500000000000520077000000006C100 -004C400000006800161000000000161008600000004C4000 -0004C40000004A0005300000000035000A40000004C40000 -00004C4000002B1003500000000053001B2000004C400000 -000004C400000A5001610000000161005A000004C4000000 -0000004C400004B10053111111135001B400004C40000000 -00000004C40001A70036666666663007A10004C400000000 -000000004C40002B600111111111006B20004C4000000000 -0000000004C40003B7100000000017B30004C40000000000 -00000000004C40002AB510000015BA20004C400000000000 -000000000004C400014ABA878ABA410004C4000000000000 -0000000000004C4000002467642000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $gwParEvent [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000001000000000000000000000000 -00000000000000000000004C400000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -000000000000000000004C404C4000000000000000000000 -00000000000000000004C40004C400000000000000000000 -0000000000000000004C4000004C40000000000000000000 -000000000000000004C400000004C4000000000000000000 -00000000000000004C40000000004C400000000000000000 -0000000000000004C4000000000004C40000000000000000 -000000000000004C400000000000004C4000000000000000 -00000000000004C40000000000000004C400000000000000 -0000000000004C4000000000000000004C40000000000000 -000000000004C400000000000000000004C4000000000000 -00000000004C40000000000000000000004C400000000000 -0000000004C4000000001356531000000004C40000000000 -000000004C4000000015BBA9ABB5100000004C4000000000 -00000004C4000000019B5101015B9100000004C400000000 -0000004C4000000019A200151002A9100000004C40000000 -000004C4000000005B20001610002B5000000004C4000000 -00004C4000000001B5000016100005B1000000004C400000 -0004C40000000003B1000016100001B30000000004C40000 -004C400000000005A0111126211110A500000000004C4000 -01C60000000000069156666666665196000000000006C100 -004C400000000005A0111126211110A500000000004C4000 -0004C40000000003B1000016100001B30000000004C40000 -00004C4000000001B5000016100005B1000000004C400000 -000004C4000000005B20001610002B5000000004C4000000 -0000004C4000000019A200151002A9100000004C40000000 -00000004C4000000019B5101015B9100000004C400000000 -000000004C4000000015BBA9ABB5100000004C4000000000 -0000000004C4000000001356531000000004C40000000000 -00000000004C40000000000000000000004C400000000000 -000000000004C400000000000000000004C4000000000000 -0000000000004C4000000000000000004C40000000000000 -00000000000004C40000000000000004C400000000000000 -000000000000004C400000000000004C4000000000000000 -0000000000000004C4000000000004C40000000000000000 -00000000000000004C40000000004C400000000000000000 -000000000000000004C400000004C4000000000000000000 -0000000000000000004C4000004C40000000000000000000 -00000000000000000004C40004C400000000000000000000 -000000000000000000004C404C4000000000000000000000 -0000000000000000000004C6C40000000000000000000000 -00000000000000000000004C400000000000000000000000 -000000000000000000000001000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icUser [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000034300000000000000000000000 -0000000000000000000019CEC91000000000000000000000 -000000000000000000008DFFFD8000000000000000000000 -00000000000000000001BFFFFFB100000000000000000000 -00000000000000000002CFFFFFC200000000000000000000 -00000000000000000001BEFFFEB100000000000000000000 -000000000000000000005DEFED5000000000000000000000 -0000000000000000000004ABA40000000000000000000000 -000000000000000000030000000300000000000000000000 -000000000000000000260000000620000000000000000000 -000000000000000000440000000440000000000000000000 -000000000000000001610000000161000000000000000000 -000000000000000002500000000052000000000000000000 -000000000000000005311111111135000000000000000000 -000000000000000016666666666666100000000000000000 -000000000000000001111111111111000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icService [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000003000000000000000000000000 -000000000000000000000016100000000000000000000000 -000000000000000001200016100021000000000000000000 -000000000000000002620147410262000000000000000000 -000000000000000000265BCCCB5620000000000000000000 -00000000000000000005C73537C500000000000000000000 -0000000000000000001B7400047B10000000000000000000 -0000000000000000114C3000003C41100000000000000000 -0000000000000003667C5000005C76630000000000000000 -0000000000000000114C3000003C41100000000000000000 -0000000000000000001B7400047B10000000000000000000 -00000000000000000005C73537C500000000000000000000 -000000000000000000265BCCCB5620000000000000000000 -000000000000000002620147410262000000000000000000 -000000000000000001200016100021000000000000000000 -000000000000000000000016100000000000000000000000 -000000000000000000000003000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icScript [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000004888888888884000000000000000000 -00000000000000004BFFFFFFFFFFFB400000000000000000 -00000000000000008FB888888888BF800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F82555555528F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F82555555528F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F82555555528F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008F80000000008F800000000000000000 -00000000000000008FB888888888BF800000000000000000 -00000000000000004BFFFFFFFFFFFB400000000000000000 -000000000000000004888888888884000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icManual [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000010000000000000000000000000 -000000000000000000000151003000000000000000000000 -000000000000000000100160016100000000000000000000 -000000000000000001510260026000100000000000000000 -000000000000000003500350035001510000000000000000 -000000000000000005300440044002600000000000000000 -000000000000000016100530053003500000000000000000 -000000000000000035000620052005300000000000000000 -000000000000000053112621262116200000000000000000 -000000000000000166666666666666610000000000000000 -000000000000000011111111111111100000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icRule [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000011111111111111100000000000000000 -000000000000000156666666666666510000000000000000 -000000000000000161111116111111610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161111116111111610000000000000000 -000000000000000166666666666666610000000000000000 -000000000000000161111116111111610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161000016100001610000000000000000 -000000000000000161111116111111610000000000000000 -000000000000000156666666666666510000000000000000 -000000000000000011111111111111100000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icPlusbox [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000048888888888888400000000000000000 -0000000000000004BFFFFFFFFFFFFFB40000000000000000 -0000000000000008FB88888888888BF80000000000000000 -0000000000000008F8000003000008F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8011126211108F80000000000000000 -0000000000000008F8366666666638F80000000000000000 -0000000000000008F8011126211108F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8000016100008F80000000000000000 -0000000000000008F8000003000008F80000000000000000 -0000000000000008FB88888888888BF80000000000000000 -0000000000000004BFFFFFFFFFFFFFB40000000000000000 -000000000000000048888888888888400000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icMsg [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000888888888888888880000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FDA88888888888ADF8000000000000000 -000000000000008F854000000000458F8000000000000000 -000000000000008F815510000015518F8000000000000000 -000000000000008F800451000154008F8000000000000000 -000000000000008F800036202630008F8000000000000000 -000000000000008F800002646200008F8000000000000000 -000000000000008F800000252000008F8000000000000000 -000000000000008F800000000000008F8000000000000000 -000000000000008FB8888888888888BF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000000888888888888888880000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icMsgf [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000888888888888888880000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000008FFFFFFFFFFFFFFFFF8000000000000000 -000000000000000888888888888888880000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} - -sprite $icLoop [48x48/16] { -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000002578752000000000000000000000 -00000000000000000039CB989BC930000000000000000000 -000000000000000005C931000139C5000000000000000000 -00000000000000005D61000000016D500000000000000000 -0000000000000003C6000000000006C30000000000000000 -000000000000000991000000000001990000000000000000 -000000000000002C300000000000003C2000000000000000 -000000000000005B100000000000001B5000000000000000 -000000000000007900000000000000097000000000000000 -000000000000008800000000000000088000000000000000 -000000000000007900000000000000097000000000000000 -000000000000005B100000000000011B5000000000000000 -000000000000002C300000000000265C2000000000000000 -000000000000000991000000000264A90000000000000000 -0000000000000003C6000000002636C62000000000000000 -00000000000000005D61000002636D526200000000000000 -000000000000000005C931001659C5002610000000000000 -00000000000000000039CB989BC930000100000000000000 -000000000000000000002578752000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -000000000000000000000000000000000000000000000000 -} diff --git a/_method_extract.txt b/_method_extract.txt new file mode 100644 index 0000000..aa2767a --- /dev/null +++ b/_method_extract.txt @@ -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 +СВЕДЕНИЯ ОБ АВТОРАХ + +Бергер Екатерина Геннадьевна, к.э.н., доцент кафедры практической и при- +кладной информатики Института информационных технологий +Кириллина Юлия Владимировна, к.э.н., доцент, доцент кафедры практиче- +ской и прикладной информатики Института информационных технологий +Лагунова Анна Дмитриевна, к.э.н., доцент кафедры практической и при- +кладной информатики Института информационных технологий + \ No newline at end of file diff --git a/build-exe-fast.bat b/build-exe-fast.bat index d911526..3cfcd8b 100644 --- a/build-exe-fast.bat +++ b/build-exe-fast.bat @@ -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 diff --git a/build-exe-uvx.bat b/build-exe-uvx.bat new file mode 100644 index 0000000..7cf8b45 --- /dev/null +++ b/build-exe-uvx.bat @@ -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 diff --git a/build-exe.bat b/build-exe.bat index 40bacbc..4f11ba3 100644 --- a/build-exe.bat +++ b/build-exe.bat @@ -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 diff --git a/build-find-python311.bat b/build-find-python311.bat new file mode 100644 index 0000000..01fc8f7 --- /dev/null +++ b/build-find-python311.bat @@ -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 diff --git a/delete_this.txt b/delete_this.txt new file mode 100644 index 0000000..6990bea --- /dev/null +++ b/delete_this.txt @@ -0,0 +1,4 @@ +tests/_idef0_preview.png +tests/_idef0_preview.svg +tests/_idef0_preview_a0.png +tests/_idef0_preview_a0.svg diff --git a/docs/README.md b/docs/README.md index 41fc472..e63163d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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/). diff --git a/docs/archimate.md b/docs/archimate.md new file mode 100644 index 0000000..e52c9be --- /dev/null +++ b/docs/archimate.md @@ -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 (§2–3): + +- **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 <> +archimate #Application "Портал" as c <> +c --> p +@enduml +``` + +Sprites: `<<$archimate/business-process>>` или `jar:archimate/...`. +**Не смешивайте** макросы и сырой `archimate` на одной схеме. + +### 7.3. Темы + +```plantuml +!theme archimate-standard from +' 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: гл. 1–2, начало гл. 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 `) + +### Примеры композиции (не словарь языка) + +- 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`). diff --git a/docs/cli-gui.md b/docs/cli-gui.md index 76d957f..9fb69a3 100644 --- a/docs/cli-gui.md +++ b/docs/cli-gui.md @@ -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`. diff --git a/docs/markdown.md b/docs/markdown.md index 9cae72d..d349547 100644 --- a/docs/markdown.md +++ b/docs/markdown.md @@ -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). + ## Запрещено в отчёте - «рис.», «табл.» diff --git a/docs/prompts.md b/docs/prompts.md index 0713cd7..b6e97f3 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -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». ## Подробнее о синтаксисе diff --git a/docs/quickstart.md b/docs/quickstart.md index 65f5eb3..7467ce4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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) diff --git a/docs/schemes.md b/docs/schemes.md index 071a314..694a3e7 100644 --- a/docs/schemes.md +++ b/docs/schemes.md @@ -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- `` / `` ``` `` | Схема из `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`, связи `-->`, `<>`, `<>`. -### 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 делает это сама при сохранении изменённой встроенной схемы). diff --git a/docs/title.md b/docs/title.md new file mode 100644 index 0000000..03f60a1 --- /dev/null +++ b/docs/title.md @@ -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` рядом с приложением (см. каталог схем). Не коммитьте его с чужими ФИО в общие репозитории отчётов — это локальные данные студента. diff --git a/docs/word2md.md b/docs/word2md.md new file mode 100644 index 0000000..b57871c --- /dev/null +++ b/docs/word2md.md @@ -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»**. + +Картинки пишутся в каталог `_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`. diff --git a/md2gost.fast.spec b/md2gost.fast.spec index e5e295e..b9020f3 100644 --- a/md2gost.fast.spec +++ b/md2gost.fast.spec @@ -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) diff --git a/md2gost.spec b/md2gost.spec index ba7e145..501d405 100644 --- a/md2gost.spec +++ b/md2gost.spec @@ -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) diff --git a/md2gost.user.json b/md2gost.user.json new file mode 100644 index 0000000..0eea062 --- /dev/null +++ b/md2gost.user.json @@ -0,0 +1,14 @@ +{ + "student": "Тарасов Игорь Алексеевич", + "group": "ИНБО-31-23", + "metadata": { + "author_source": "student", + "author": "", + "last_modified_by": "", + "title": "", + "subject": "", + "keywords": "", + "comments": "Создано при помощи md2gost (ТЗ МИРЭА)", + "category": "" + } +} diff --git a/md2gost/README.md b/md2gost/README.md index 02d5cf7..df3b61a 100644 --- a/md2gost/README.md +++ b/md2gost/README.md @@ -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 diff --git a/md2gost/TitleTemplate.docx b/md2gost/TitleTemplate.docx new file mode 100644 index 0000000..f007e28 Binary files /dev/null and b/md2gost/TitleTemplate.docx differ diff --git a/md2gost/__main__.py b/md2gost/__main__.py index f84fbb9..9a41977 100644 --- a/md2gost/__main__.py +++ b/md2gost/__main__.py @@ -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)), diff --git a/md2gost/biblio_processor.py b/md2gost/biblio_processor.py index e93ed8d..9e4cc9a 100644 --- a/md2gost/biblio_processor.py +++ b/md2gost/biblio_processor.py @@ -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 diff --git a/md2gost/converter.py b/md2gost/converter.py index b6a9a8d..2afcc90 100644 --- a/md2gost/converter.py +++ b/md2gost/converter.py @@ -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) diff --git a/md2gost/dfd.py b/md2gost/dfd.py new file mode 100644 index 0000000..78413d6 --- /dev/null +++ b/md2gost/dfd.py @@ -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("", "", 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("", "", 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 diff --git a/md2gost/diagram_renderer.py b/md2gost/diagram_renderer.py index 1e46205..7e561b4 100644 --- a/md2gost/diagram_renderer.py +++ b/md2gost/diagram_renderer.py @@ -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: diff --git a/md2gost/diagram_schemes.py b/md2gost/diagram_schemes.py index f1f2c78..50064be 100644 --- a/md2gost/diagram_schemes.py +++ b/md2gost/diagram_schemes.py @@ -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: diff --git a/md2gost/diagrams/BPMN.puml b/md2gost/diagrams/BPMN.puml deleted file mode 100644 index 02b8c5b..0000000 --- a/md2gost/diagrams/BPMN.puml +++ /dev/null @@ -1,1126 +0,0 @@ -' md2gost BPMN 2.0 macros for PlantUML -' Notation: OMG BPMN 2.0.2 (formal/2013-12-09), Ch. 7 graphical elements, -' Ch. 10 Process (events, activities, gateways), Ch. 9 Collaboration (pools, message flow). -' PlantUML cannot attach boundary events to the activity border or draw a true -' double-circle; thickness + stereotypes approximate start / intermediate / end. -' Gateways use hexagon — in this renderer it reads as a BPMN diamond. -' -' Usage (scheme ```bpmn / ```uml-bpmn wraps @startuml + this include): -' Pool(company, "Компания") { -' Lane(op, "Оператор") { -' Start(s) -' UserTask(t, "Проверить") -' XOR(gw) -' End(e) -' Flow(s, t) -' Flow(t, gw) -' CondFlow(gw, e, "нет") -' } -' } - -!ifndef BPMN_PUML -!define BPMN_PUML - -hide stereotype -skinparam shadowing false -skinparam linetype polyline -skinparam nodesep 28 -skinparam ranksep 42 -skinparam defaultTextAlignment center -skinparam wrapWidth 140 -skinparam ArrowColor #212121 -skinparam Padding 2 - -' --------------------------------------------------------------------------- -' Sprites (event / task markers, BPMN 2.0 Table 7.2–7.4) -' --------------------------------------------------------------------------- - -sprite $bpmnMsg [16x11/16] { -0000000000000000 -0FFFFFFFFFFFFFF0 -0F000000000000F0 -0FF0000000000FF0 -0F0FF000000FF0F0 -0F000FFFFFF000F0 -0F0000FFFF0000F0 -0F000000000000F0 -0F000000000000F0 -0FFFFFFFFFFFFFF0 -0000000000000000 -} - -sprite $bpmnMsgFill [16x11/16] { -0000000000000000 -0FFFFFFFFFFFFFF0 -0FFFFFFFFFFFFFF0 -0FFFFFFFFFFFFFF0 -0FFFFFFFFFFFFFF0 -0FFF0FFFFFF0FFF0 -0FF000FFFF000FF0 -0F00000FF00000F0 -0F000000000000F0 -0FFFFFFFFFFFFFF0 -0000000000000000 -} - -sprite $bpmnTimer [16x10/16] { -000000FFFF000000 -0000FF0000FF0000 -000F000FF000F000 -00F0000FF0000F00 -00F0000FF0000F00 -00F0000FFFF00F00 -00F0000000000F00 -000F00000000F000 -0000FF0000FF0000 -000000FFFF000000 -} - -sprite $bpmnError [16x9/16] { -0000000FF0000000 -000000FF00000000 -00000FFFFF000000 -0000FF0000000000 -000FFFFF00000000 -000000FF00000000 -00000FF000000000 -0000FF0000000000 -000FF00000000000 -} - -sprite $bpmnSignal [16x8/16] { -00000000F0000000 -0000000FFF000000 -000000FF0FF00000 -00000FF000FF0000 -0000FF00000FF000 -000FF0000000FF00 -00FF000000000FF0 -0FFFFFFFFFFFFFFF -} - -sprite $bpmnSignalFill [16x8/16] { -00000000F0000000 -0000000FFF000000 -000000FFFFF00000 -00000FFFFFFF0000 -0000FFFFFFFFF000 -000FFFFFFFFFFF00 -00FFFFFFFFFFFFF0 -0FFFFFFFFFFFFFFF -} - -sprite $bpmnTerminate [16x9/16] { -00000FFFFFF00000 -000FFFFFFFFFF000 -00FFFFFFFFFFFF00 -0FFFFFFFFFFFFFF0 -0FFFFFFFFFFFFFF0 -0FFFFFFFFFFFFFF0 -00FFFFFFFFFFFF00 -000FFFFFFFFFF000 -00000FFFFFF00000 -} - -sprite $bpmnEscalation [16x9/16] { -00000000F0000000 -0000000FFF000000 -000000FF0FF00000 -00000FF000FF0000 -0000FFFFFFFFF000 -000000F000F00000 -000000F000F00000 -000000F000F00000 -000000FFFFF00000 -} - -sprite $bpmnComp [16x7/16] { -00F0000F00000000 -0FF000FF00000000 -FFF00FFF00000000 -FFFF0FFFF0000000 -FFF00FFF00000000 -0FF000FF00000000 -00F0000F00000000 -} - -sprite $bpmnLink [16x7/16] { -0000000000000000 -00000000000F0000 -00000000000FF000 -0FFFFFFFFFFFFF00 -00000000000FF000 -00000000000F0000 -0000000000000000 -} - -sprite $bpmnCond [16x10/16] { -0FFFFFFFFFF00000 -0F00000000FF0000 -0F00000000F0F000 -0F00000000FFF000 -0F00FFFF0000F000 -0F0000000000F000 -0F00FFFF0000F000 -0F0000000000F000 -0F00FFFF0000F000 -0FFFFFFFFFFFF000 -} - -sprite $bpmnMulti [16x8/16] { -00000000F0000000 -000000FF0FF00000 -00000F00000F0000 -0000F0000000F000 -000F000000000F00 -000F000000000F00 -0000F0000000F000 -00000FFFFFFF0000 -} - -sprite $bpmnParMulti [16x9/16] { -00000FFFFFF00000 -000FF000000FF000 -00F0000FF0000F00 -0F00000FF00000F0 -0F00FFFFFFFF00F0 -0F00000FF00000F0 -00F0000FF0000F00 -000FF000000FF000 -00000FFFFFF00000 -} - -sprite $bpmnCancel [16x11/16] { -0FF0000000000FF0 -00FF00000000FF00 -000FF000000FF000 -0000FF0000FF0000 -00000FF00FF00000 -000000FFFF000000 -00000FF00FF00000 -0000FF0000FF0000 -000FF000000FF000 -00FF00000000FF00 -0FF0000000000FF0 -} - -sprite $bpmnUser [16x10/16] { -000000FFFF000000 -00000FFFFFF00000 -00000FFFFFF00000 -000000FFFF000000 -0000000000000000 -0000FFFFFFFF0000 -000FF000000FF000 -000FF000000FF000 -000FF000000FF000 -000FF000000FF000 -} - -sprite $bpmnService [16x9/16] { -000000FFFF000000 -0000FFF00FFF0000 -000FF0FFFF0FF000 -00FF00FFFF00FF00 -00F0000000000F00 -00FF00FFFF00FF00 -000FF0FFFF0FF000 -0000FFF00FFF0000 -000000FFFF000000 -} - -sprite $bpmnScript [16x9/16] { -0FFFFFFFFFF00000 -0F00000000FF0000 -0F00000000F0F000 -0F00FFFF00FFF000 -0F0000000000F000 -0F00FFFF0000F000 -0F0000000000F000 -0F00FFFF0000F000 -0FFFFFFFFFFFF000 -} - -sprite $bpmnManual [16x7/16] { -000000000FF00000 -00000000FFFF0000 -0000FF0FF0FF0000 -000FFFFFFFFFF000 -000FFFFFFFFFF000 -0000FFFFFFFF0000 -00000FFFFFF00000 -} - -sprite $bpmnRule [16x7/16] { -0FFFFFFFFFFFFFF0 -0F000F000F0000F0 -0FFFFFFFFFFFFFF0 -0F000F000F0000F0 -0FFFFFFFFFFFFFF0 -0F000F000F0000F0 -0FFFFFFFFFFFFFF0 -} - -sprite $bpmnPlusBox [12x9/16] { -FFFFFFFFFFFF -F0000000000F -F0000FF0000F -F0000FF0000F -F0FFFFFFFF0F -F0000FF0000F -F0000FF0000F -F0000000000F -FFFFFFFFFFFF -} - -sprite $bpmnLoop [16x7/16] { -0000FFFFFF000000 -000F000000F00000 -00F000000000000F -0F00000000000FF0 -0F000000000F0000 -00F0000000F00000 -000FFFFFF0000000 -} - -sprite $bpmnAdhoc [16x5/16] { -0000000000000000 -0FF0000FFF000FF0 -000FF0F000FF0000 -00000F00000F0000 -0000000000000000 -} - -sprite $bpmnMIPar [13x5/16] { -00F000F000F00 -00F000F000F00 -00F000F000F00 -00F000F000F00 -00F000F000F00 -} - -sprite $bpmnMISeq [13x7/16] { -0000000000000 -0FFFFFFFFFFF0 -0000000000000 -0FFFFFFFFFFF0 -0000000000000 -0FFFFFFFFFFF0 -0000000000000 -} - -' --------------------------------------------------------------------------- -' Skin (colours are conventional, not mandated by the spec) -' --------------------------------------------------------------------------- - -skinparam circle<> { - BackgroundColor #C8E6C9 - BorderColor #2E7D32 - BorderThickness 1.5 - FontSize 10 -} - -skinparam circle<> { - BackgroundColor #C8E6C9 - BorderColor #2E7D32 - BorderThickness 1.5 - BorderStyle dashed - FontSize 10 -} - -skinparam circle<> { - BackgroundColor #FFF3E0 - BorderColor #EF6C00 - BorderThickness 2 - FontSize 10 -} - -skinparam circle<> { - BackgroundColor #FFE0B2 - BorderColor #E65100 - BorderThickness 2 - FontSize 10 -} - -skinparam circle<> { - BackgroundColor #FFCDD2 - BorderColor #B71C1C - BorderThickness 3.5 - FontSize 10 -} - -skinparam circle<> { - BackgroundColor #FFF3E0 - BorderColor #EF6C00 - BorderThickness 2 - FontSize 9 -} - -skinparam circle<> { - BackgroundColor #FFF3E0 - BorderColor #EF6C00 - BorderThickness 2 - BorderStyle dashed - FontSize 9 -} - -skinparam hexagon<> { - BackgroundColor #FFF8E1 - BorderColor #F9A825 - BorderThickness 1.5 - FontSize 12 -} - -skinparam rectangle<> { - RoundCorner 22 - BackgroundColor #FFFFFF - BorderColor #1565C0 - BorderThickness 1.4 - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 22 - BackgroundColor #FAFAFA - BorderColor #1565C0 - BorderThickness 1.4 - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 22 - BackgroundColor #FFFFFF - BorderColor #0D47A1 - BorderThickness 3 - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 22 - BackgroundColor #FAFAFA - BorderColor #0D47A1 - BorderThickness 2.5 - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 22 - BackgroundColor #FAFAFA - BorderColor #6A1B9A - BorderThickness 1.4 - BorderStyle dashed - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 0 - BackgroundColor #F5F5F5 - BorderColor #212121 - BorderThickness 1.6 - FontSize 12 - FontStyle bold -} - -skinparam rectangle<> { - RoundCorner 0 - BackgroundColor #FFFFFF - BorderColor #757575 - BorderThickness 1 - FontSize 11 -} - -skinparam rectangle<> { - RoundCorner 0 - BackgroundColor #EEEEEE - BorderColor #212121 - BorderThickness 1.6 - FontSize 12 - FontStyle bold -} - -skinparam rectangle<> { - RoundCorner 8 - BackgroundColor transparent - BorderColor #9E9E9E - BorderThickness 1.2 - BorderStyle dashed - FontSize 10 -} - -skinparam file<> { - BackgroundColor #E3F2FD - BorderColor #1565C0 - FontSize 10 -} - -skinparam database<> { - BackgroundColor #E8F5E9 - BorderColor #2E7D32 - FontSize 10 -} - -skinparam note { - BackgroundColor #FFFDE7 - BorderColor #F9A825 - FontSize 10 -} - -' --------------------------------------------------------------------------- -' Internals -' --------------------------------------------------------------------------- - -!unquoted procedure $bpmnCircle($alias, $stereo, $icon, $label) - !if ($icon == "") - !if ($label == "") - circle " " as $alias <<$stereo>> - !else - circle "$label" as $alias <<$stereo>> - !endif - !else - !if ($label == "") - circle "<$$icon>" as $alias <<$stereo>> - !else - circle "<$$icon>\n$label" as $alias <<$stereo>> - !endif - !endif -!endprocedure - -!unquoted procedure $bpmnGW($alias, $mark, $label) - !if ($label == "") - hexagon "$mark" as $alias <> - !else - hexagon "$mark\n$label" as $alias <> - !endif -!endprocedure - -!unquoted procedure $bpmnRect($alias, $stereo, $icon, $label, $marker="") - !if ($icon == "") - !if ($marker == "") - rectangle "$label" as $alias <<$stereo>> - !else - rectangle "$label\n\n<$$marker>" as $alias <<$stereo>> - !endif - !else - !if ($marker == "") - rectangle "<$$icon> $label" as $alias <<$stereo>> - !else - rectangle "<$$icon> $label\n\n<$$marker>" as $alias <<$stereo>> - !endif - !endif -!endprocedure - -' --------------------------------------------------------------------------- -' Swimlanes — BPMN 2.0 §9.2 Pool / §9.3 Lane -' Pool(...) { ... } Lane(...) { ... } -' --------------------------------------------------------------------------- - -!unquoted procedure Pool($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -!unquoted procedure Lane($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -!unquoted procedure BlackBoxPool($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -!unquoted procedure Participant($alias, $label) - BlackBoxPool($alias, $label) -!endprocedure - -!unquoted procedure Group($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -' --------------------------------------------------------------------------- -' Start events — §10.5.2 (None, Message, Timer, Conditional, Signal, Error, -' Escalation, Multiple, Parallel Multiple). Error/Escalation only in event sub-process. -' --------------------------------------------------------------------------- - -!unquoted procedure Start($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "", $label) -!endprocedure - -!unquoted procedure StartEvent($alias, $label="") - Start($alias, $label) -!endprocedure - -!unquoted procedure StartMessage($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnMsg", $label) -!endprocedure - -!unquoted procedure StartTimer($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnTimer", $label) -!endprocedure - -!unquoted procedure StartCondition($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnCond", $label) -!endprocedure - -!unquoted procedure StartConditional($alias, $label="") - StartCondition($alias, $label) -!endprocedure - -!unquoted procedure StartSignal($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnSignal", $label) -!endprocedure - -!unquoted procedure StartError($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnError", $label) -!endprocedure - -!unquoted procedure StartEscalation($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure StartMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnMulti", $label) -!endprocedure - -!unquoted procedure StartParallelMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnStart", "bpmnParMulti", $label) -!endprocedure - -!unquoted procedure StartNI($alias, $label="") - $bpmnCircle($alias, "bpmnStartNI", "", $label) -!endprocedure - -!unquoted procedure StartMessageNI($alias, $label="") - $bpmnCircle($alias, "bpmnStartNI", "bpmnMsg", $label) -!endprocedure - -!unquoted procedure StartTimerNI($alias, $label="") - $bpmnCircle($alias, "bpmnStartNI", "bpmnTimer", $label) -!endprocedure - -!unquoted procedure StartSignalNI($alias, $label="") - $bpmnCircle($alias, "bpmnStartNI", "bpmnSignal", $label) -!endprocedure - -' --------------------------------------------------------------------------- -' Intermediate catching / throwing — §10.5.4 -' --------------------------------------------------------------------------- - -!unquoted procedure Intermediate($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "", $label) -!endprocedure - -!unquoted procedure CatchMessage($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnMsg", $label) -!endprocedure - -!unquoted procedure ThrowMessage($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnMsgFill", $label) -!endprocedure - -!unquoted procedure CatchTimer($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnTimer", $label) -!endprocedure - -!unquoted procedure CatchSignal($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnSignal", $label) -!endprocedure - -!unquoted procedure ThrowSignal($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnSignalFill", $label) -!endprocedure - -!unquoted procedure CatchError($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnError", $label) -!endprocedure - -!unquoted procedure CatchEscalation($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure ThrowEscalation($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure CatchCondition($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnCond", $label) -!endprocedure - -!unquoted procedure CatchLink($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnLink", $label) -!endprocedure - -!unquoted procedure ThrowLink($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnLink", $label) -!endprocedure - -!unquoted procedure CatchCompensation($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnComp", $label) -!endprocedure - -!unquoted procedure ThrowCompensation($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnComp", $label) -!endprocedure - -!unquoted procedure CatchCancel($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnCancel", $label) -!endprocedure - -!unquoted procedure CatchMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnMulti", $label) -!endprocedure - -!unquoted procedure ThrowMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnThrow", "bpmnMulti", $label) -!endprocedure - -!unquoted procedure CatchParallelMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnInter", "bpmnParMulti", $label) -!endprocedure - -' --------------------------------------------------------------------------- -' Boundary events — interrupting (solid) / non-interrupting (dashed). Attach() -' --------------------------------------------------------------------------- - -!unquoted procedure BoundaryMessage($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnMsg", $label) -!endprocedure - -!unquoted procedure BoundaryTimer($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnTimer", $label) -!endprocedure - -!unquoted procedure BoundaryError($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnError", $label) -!endprocedure - -!unquoted procedure BoundaryEscalation($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure BoundarySignal($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnSignal", $label) -!endprocedure - -!unquoted procedure BoundaryCancel($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnCancel", $label) -!endprocedure - -!unquoted procedure BoundaryCompensation($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnComp", $label) -!endprocedure - -!unquoted procedure BoundaryCondition($alias, $label="") - $bpmnCircle($alias, "bpmnBoundary", "bpmnCond", $label) -!endprocedure - -!unquoted procedure BoundaryMessageNI($alias, $label="") - $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnMsg", $label) -!endprocedure - -!unquoted procedure BoundaryTimerNI($alias, $label="") - $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnTimer", $label) -!endprocedure - -!unquoted procedure BoundaryEscalationNI($alias, $label="") - $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure BoundarySignalNI($alias, $label="") - $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnSignal", $label) -!endprocedure - -!unquoted procedure BoundaryConditionNI($alias, $label="") - $bpmnCircle($alias, "bpmnBoundaryNI", "bpmnCond", $label) -!endprocedure - -!unquoted procedure Attach($activity, $event) - $activity -[hidden]down- $event -!endprocedure - -!unquoted procedure Attach_R($activity, $event) - $activity -[hidden]right- $event -!endprocedure - -' --------------------------------------------------------------------------- -' End events — §10.5.3 (None, Message, Error, Escalation, Cancel, Compensation, -' Signal, Multiple, Terminate) -' --------------------------------------------------------------------------- - -!unquoted procedure End($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "", $label) -!endprocedure - -!unquoted procedure EndEvent($alias, $label="") - End($alias, $label) -!endprocedure - -!unquoted procedure EndMessage($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnMsgFill", $label) -!endprocedure - -!unquoted procedure EndError($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnError", $label) -!endprocedure - -!unquoted procedure EndEscalation($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnEscalation", $label) -!endprocedure - -!unquoted procedure EndCancel($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnCancel", $label) -!endprocedure - -!unquoted procedure EndCompensation($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnComp", $label) -!endprocedure - -!unquoted procedure EndSignal($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnSignalFill", $label) -!endprocedure - -!unquoted procedure EndMultiple($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnMulti", $label) -!endprocedure - -!unquoted procedure EndTerminate($alias, $label="") - $bpmnCircle($alias, "bpmnEnd", "bpmnTerminate", $label) -!endprocedure - -!unquoted procedure Terminate($alias, $label="") - EndTerminate($alias, $label) -!endprocedure - -' --------------------------------------------------------------------------- -' Activities — §10.3 Task types + markers (loop, MI, compensation, ad-hoc) -' --------------------------------------------------------------------------- - -!unquoted procedure Task($alias, $label, $type="none", $marker="") - !if ($type == "user") - $bpmnRect($alias, "bpmnTask", "bpmnUser", $label, $marker) - !elseif ($type == "service") - $bpmnRect($alias, "bpmnTask", "bpmnService", $label, $marker) - !elseif ($type == "script") - $bpmnRect($alias, "bpmnTask", "bpmnScript", $label, $marker) - !elseif ($type == "manual") - $bpmnRect($alias, "bpmnTask", "bpmnManual", $label, $marker) - !elseif ($type == "send") - $bpmnRect($alias, "bpmnTask", "bpmnMsgFill", $label, $marker) - !elseif ($type == "receive") - $bpmnRect($alias, "bpmnTask", "bpmnMsg", $label, $marker) - !elseif ($type == "rule") - $bpmnRect($alias, "bpmnTask", "bpmnRule", $label, $marker) - !elseif ($type == "businessrule") - $bpmnRect($alias, "bpmnTask", "bpmnRule", $label, $marker) - !else - $bpmnRect($alias, "bpmnTask", "", $label, $marker) - !endif -!endprocedure - -!unquoted procedure UserTask($alias, $label, $marker="") - Task($alias, $label, "user", $marker) -!endprocedure - -!unquoted procedure ServiceTask($alias, $label, $marker="") - Task($alias, $label, "service", $marker) -!endprocedure - -!unquoted procedure ScriptTask($alias, $label, $marker="") - Task($alias, $label, "script", $marker) -!endprocedure - -!unquoted procedure ManualTask($alias, $label, $marker="") - Task($alias, $label, "manual", $marker) -!endprocedure - -!unquoted procedure SendTask($alias, $label, $marker="") - Task($alias, $label, "send", $marker) -!endprocedure - -!unquoted procedure ReceiveTask($alias, $label, $marker="") - Task($alias, $label, "receive", $marker) -!endprocedure - -!unquoted procedure BusinessRuleTask($alias, $label, $marker="") - Task($alias, $label, "rule", $marker) -!endprocedure - -!unquoted procedure LoopTask($alias, $label) - Task($alias, $label, "none", "bpmnLoop") -!endprocedure - -!unquoted procedure SubProcess($alias, $label) - $bpmnRect($alias, "bpmnSubProcess", "", $label, "bpmnPlusBox") -!endprocedure - -!unquoted procedure SubProcessExpanded($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -!unquoted procedure CallActivity($alias, $label) - $bpmnRect($alias, "bpmnCall", "", $label, "bpmnPlusBox") -!endprocedure - -!unquoted procedure Transaction($alias, $label) - $bpmnRect($alias, "bpmnTransaction", "", $label, "bpmnPlusBox") -!endprocedure - -!unquoted procedure EventSubProcess($alias, $label) - rectangle "$label" as $alias <> -!endprocedure - -!unquoted procedure AdHocSubProcess($alias, $label) - $bpmnRect($alias, "bpmnSubProcess", "", $label, "bpmnAdhoc") -!endprocedure - -' --------------------------------------------------------------------------- -' Gateways — §10.6 (Exclusive, Parallel, Inclusive, Complex, Event-Based) -' --------------------------------------------------------------------------- - -!unquoted procedure Exclusive($alias, $label="") - $bpmnGW($alias, "×", $label) -!endprocedure - -!unquoted procedure XOR($alias, $label="") - Exclusive($alias, $label) -!endprocedure - -!unquoted procedure Xor($alias, $label="") - Exclusive($alias, $label) -!endprocedure - -!unquoted procedure Gateway($alias, $label="") - Exclusive($alias, $label) -!endprocedure - -!unquoted procedure Parallel($alias, $label="") - $bpmnGW($alias, "+", $label) -!endprocedure - -!unquoted procedure AND($alias, $label="") - Parallel($alias, $label) -!endprocedure - -!unquoted procedure And($alias, $label="") - Parallel($alias, $label) -!endprocedure - -!unquoted procedure Inclusive($alias, $label="") - $bpmnGW($alias, "○", $label) -!endprocedure - -!unquoted procedure OR($alias, $label="") - Inclusive($alias, $label) -!endprocedure - -!unquoted procedure Or($alias, $label="") - Inclusive($alias, $label) -!endprocedure - -!unquoted procedure ComplexGW($alias, $label="") - $bpmnGW($alias, "*", $label) -!endprocedure - -!unquoted procedure Complex($alias, $label="") - ComplexGW($alias, $label) -!endprocedure - -!unquoted procedure EventBased($alias, $label="") - $bpmnGW($alias, "◇", $label) -!endprocedure - -!unquoted procedure EventBasedGateway($alias, $label="") - EventBased($alias, $label) -!endprocedure - -!unquoted procedure ExclusiveEventBased($alias, $label="") - $bpmnGW($alias, "◇", $label) -!endprocedure - -!unquoted procedure ParallelEventBased($alias, $label="") - $bpmnGW($alias, "⊕", $label) -!endprocedure - -' --------------------------------------------------------------------------- -' Connecting objects — §8.4.13 Sequence Flow, Message Flow, Association -' Default = slash; conditional = mini-diamond (label prefixes as stand-in) -' --------------------------------------------------------------------------- - -!unquoted procedure Flow($from, $to, $label="") - !if ($label == "") - $from --> $to - !else - $from --> $to : $label - !endif -!endprocedure - -!unquoted procedure Flow_R($from, $to, $label="") - !if ($label == "") - $from -right-> $to - !else - $from -right-> $to : $label - !endif -!endprocedure - -!unquoted procedure Flow_L($from, $to, $label="") - !if ($label == "") - $from -left-> $to - !else - $from -left-> $to : $label - !endif -!endprocedure - -!unquoted procedure Flow_D($from, $to, $label="") - !if ($label == "") - $from -down-> $to - !else - $from -down-> $to : $label - !endif -!endprocedure - -!unquoted procedure Flow_U($from, $to, $label="") - !if ($label == "") - $from -up-> $to - !else - $from -up-> $to : $label - !endif -!endprocedure - -!unquoted procedure SequenceFlow($from, $to, $label="") - Flow($from, $to, $label) -!endprocedure - -!unquoted procedure CondFlow($from, $to, $cond) - $from --> $to : ◇ $cond -!endprocedure - -!unquoted procedure DefaultFlow($from, $to, $label="") - !if ($label == "") - $from --> $to : ╱ - !else - $from --> $to : ╱ $label - !endif -!endprocedure - -!unquoted procedure MessageFlow($from, $to, $label="") - !if ($label == "") - $from ..> $to - !else - $from ..> $to : $label - !endif -!endprocedure - -!unquoted procedure MessageFlow_R($from, $to, $label="") - !if ($label == "") - $from -[dashed]right-> $to - !else - $from -[dashed]right-> $to : $label - !endif -!endprocedure - -!unquoted procedure Assoc($from, $to, $label="") - !if ($label == "") - $from .. $to - !else - $from .. $to : $label - !endif -!endprocedure - -!unquoted procedure Association($from, $to, $label="") - Assoc($from, $to, $label) -!endprocedure - -!unquoted procedure DataAssoc($from, $to, $label="") - !if ($label == "") - $from ..> $to - !else - $from ..> $to : $label - !endif -!endprocedure - -' --------------------------------------------------------------------------- -' Data / artifacts — §7.1.1.7 Data, Group, Text Annotation -' --------------------------------------------------------------------------- - -!unquoted procedure DataObject($alias, $label) - file "$label" as $alias <> -!endprocedure - -!unquoted procedure DataInput($alias, $label) - file "▷ $label" as $alias <> -!endprocedure - -!unquoted procedure DataOutput($alias, $label) - file "$label ▷" as $alias <> -!endprocedure - -!unquoted procedure Collection($alias, $label) - file "$label\n<$bpmnMIPar>" as $alias <> -!endprocedure - -!unquoted procedure DataStore($alias, $label) - database "$label" as $alias <> -!endprocedure - -!unquoted procedure Annotation($alias, $text) - note "$text" as $alias -!endprocedure - -!unquoted procedure TextAnnotation($alias, $text) - Annotation($alias, $text) -!endprocedure - -' --------------------------------------------------------------------------- -' Layout helpers -' --------------------------------------------------------------------------- - -!unquoted procedure Lay_R($from, $to) - $from -[hidden]right-> $to -!endprocedure - -!unquoted procedure Lay_L($from, $to) - $from -[hidden]left-> $to -!endprocedure - -!unquoted procedure Lay_D($from, $to) - $from -[hidden]down-> $to -!endprocedure - -!unquoted procedure Lay_U($from, $to) - $from -[hidden]up-> $to -!endprocedure - -!procedure BPMN_HORIZONTAL() - left to right direction -!endprocedure - -!procedure BPMN_VERTICAL() - top to bottom direction -!endprocedure - -!procedure BPMN_ORTHO() - skinparam linetype ortho -!endprocedure - -!procedure BPMN_POLYLINE() - skinparam linetype polyline -!endprocedure - -!procedure BPMN_LEGEND() -legend right - BPMN 2.0 - |= Элемент |= Смысл | - | тонкий круг | Start Event | - | двойная толщина | Intermediate | - | жирный круг | End Event | - | ● внутри конца | Terminate | - | скруглённый прямоугольник | Activity / Task | - | ромб × | Exclusive (XOR) | - | ромб + | Parallel (AND) | - | ромб ○ | Inclusive (OR) | - | ромб ◇ | Event-Based | - | сплошная стрелка | Sequence Flow | - | пунктир | Message Flow | - | ╱ | Default Flow | - | ◇ на стрелке | Conditional Flow | -endlegend -!endprocedure - -!endif diff --git a/md2gost/diagrams/schemes.json b/md2gost/diagrams/schemes.json index e786982..bd3578e 100644 --- a/md2gost/diagrams/schemes.json +++ b/md2gost/diagrams/schemes.json @@ -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). 8–18 элементов. Сначала элементы, потом 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 \n!theme archimate-standard from \n", "postfix": "\n@enduml" } } diff --git a/md2gost/dnd.py b/md2gost/dnd.py index 7ce61cb..dade595 100644 --- a/md2gost/dnd.py +++ b/md2gost/dnd.py @@ -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: diff --git a/md2gost/doc_metadata.py b/md2gost/doc_metadata.py new file mode 100644 index 0000000..d2500fa --- /dev/null +++ b/md2gost/doc_metadata.py @@ -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 diff --git a/md2gost/gui.py b/md2gost/gui.py index b8f0d05..4617fd7 100644 --- a/md2gost/gui.py +++ b/md2gost/gui.py @@ -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("", 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) diff --git a/md2gost/help_content.py b/md2gost/help_content.py index e191f7b..48d9d97 100644 --- a/md2gost/help_content.py +++ b/md2gost/help_content.py @@ -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- / из файла схем (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- / из файла схем (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", "Стиль: человечный текст, не отчёт ИИ"), ) diff --git a/md2gost/idef0.py b/md2gost/idef0.py new file mode 100644 index 0000000..bf2005d --- /dev/null +++ b/md2gost/idef0.py @@ -0,0 +1,2185 @@ +"""IDEF0 diagrams from a simple text DSL (FIPS 183 / IEEE 1320.1). + +Fence language: ```idef0 (alias ```uml-idef0). +Rendered locally with Pillow — no PlantUML / Kroki. +""" + +from __future__ import annotations + +import logging +import math +import os +import re +import xml.sax.saxutils +from dataclasses import dataclass, field +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont + +_log = logging.getLogger(__name__) + +CACHE_KEY_PREFIX = "idef0-v6\n" + +# --- model ----------------------------------------------------------------- + +Side = str # "I" | "C" | "O" | "M" | "CALL" +_SIDES = frozenset({"I", "C", "O", "M", "CALL"}) + +_SIDE_FROM_TOKEN = { + "i": "I", + "in": "I", + "input": "I", + "c": "C", + "ctrl": "C", + "control": "C", + "^": "C", + "o": "O", + "out": "O", + "output": "O", + "m": "M", + "mech": "M", + "mechanism": "M", + "v": "M", + "call": "CALL", +} + +_STATUS_CANON = { + "working": "WORKING", + "draft": "DRAFT", + "recommended": "RECOMMENDED", + "publication": "PUBLICATION", + "рабочий": "WORKING", + "черновик": "DRAFT", + "рекомендован": "RECOMMENDED", + "рекомендовано": "RECOMMENDED", + "публикация": "PUBLICATION", +} + +_STATUSES = ("WORKING", "DRAFT", "RECOMMENDED", "PUBLICATION") + +# Longer keys first. +_META_ALIASES: list[tuple[str, str]] = [ + ("used at", "used_at"), + ("used_at", "used_at"), + ("used-at", "used_at"), + ("reader date", "reader_date"), + ("reader_date", "reader_date"), + ("reader-date", "reader_date"), + ("author", "author"), + ("project", "project"), + ("date", "date"), + ("revision", "rev"), + ("rev", "rev"), + ("status", "status"), + ("context", "context"), + ("node", "node"), + ("title", "title"), + ("number", "number"), + ("notes", "notes"), + ("reader", "reader"), + ("purpose", "purpose"), + ("viewpoint", "viewpoint"), + ("page", "page"), + ("form", "form"), + ("автор", "author"), + ("проект", "project"), + ("дата", "date"), + ("узел", "node"), + ("название", "title"), + ("номер", "number"), + ("статус", "status"), + ("контекст", "context"), + ("цель", "purpose"), + ("точка зрения", "viewpoint"), +] + +_BOX_ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") +_A_NODE_RE = re.compile(r"^A-?\d+[A-Za-z0-9]*$", re.I) +_BOX_DEF_BRACKET = re.compile( + r"^\[([A-Za-z][A-Za-z0-9_-]*)\]\s*(.*)$", +) +_BOX_DEF_KW = re.compile( + r"^box\s+([A-Za-z][A-Za-z0-9_-]*)\s*(.*)$", + re.I, +) +_NOT_BOX_REST = re.compile( + r"^(?:in|input|out|output|ctrl|control|mech|mechanism|call|[ICOM])\b|^<-|^->|^\^|^v\b", + re.I, +) +_ICOM_TAIL = re.compile(r"\[([ICOM]\d+)\]\s*$", re.I) +_KW_ARROW = re.compile( + r"^(\(?)\s*(?:([A-Za-z][A-Za-z0-9_-]*)\s+)?" + r"(\(?)(in|input|out|output|ctrl|control|mech|mechanism|call|[ICOM])\b(\)?)" + r"\s*(.*)$", + re.I, +) + + +class Idef0ParseError(ValueError): + """Invalid IDEF0 DSL.""" + + +@dataclass +class Idef0Box: + id: str + name: str + number: str = "" # lower-right corner; default = id + + +@dataclass +class Idef0End: + """One end of an arrow: a box port, or the diagram boundary.""" + + box: str | None + side: Side + tunnel: bool = False + + +@dataclass +class Idef0Arrow: + sources: list[Idef0End] + targets: list[Idef0End] + label: str = "" + icom: str = "" # e.g. I1, C2 — drawn near the unconnected end + + +@dataclass +class Idef0Diagram: + title: str = "" + node: str = "" + number: str = "1" + author: str = "" + project: str = "" + date: str = "" + rev: str = "" + status: str = "" + context: str = "" + used_at: str = "" + reader: str = "" + reader_date: str = "" + notes: str = "" + purpose: str = "" + viewpoint: str = "" + page: str = "" + form: str = "kit" # kit | plain + boxes: list[Idef0Box] = field(default_factory=list) + arrows: list[Idef0Arrow] = field(default_factory=list) + + def box_ids(self) -> dict[str, Idef0Box]: + return {b.id: b for b in self.boxes} + + +# --- parse ----------------------------------------------------------------- + +def _strip_comment(line: str) -> str: + if line.lstrip().startswith("#"): + return "" + return line + + +def _unquote(text: str) -> str: + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'": + return text[1:-1].strip() + return text + + +def _match_meta(line: str) -> tuple[str, str] | None: + raw = line.strip() + low = raw.lower() + for prefix, key in _META_ALIASES: + if not low.startswith(prefix): + continue + rest = raw[len(prefix) :] + if rest == "": + return key, "" + if rest[0] in ":\t ": + return key, _unquote(rest.lstrip(":\t ")) + return None + + +def _looks_like_box_id(name: str, known: set[str]) -> bool: + if name in known: + return True + if _A_NODE_RE.match(name): + return True + if re.match(r"^[1-6]$", name): + return True + return False + + +def _parse_side_token(token: str | None, default: Side) -> Side: + if not token: + return default + key = token.strip().lower().lstrip(".") + if key in _SIDE_FROM_TOKEN: + return _SIDE_FROM_TOKEN[key] + if key.upper() in _SIDES: + return key.upper() # type: ignore[return-value] + return default + + +def _parse_end_token(token: str, default_side: Side, known: set[str]) -> Idef0End | None: + text = token.strip() + if not text: + return None + tunnel = False + if text.startswith("(") and text.endswith(")") and len(text) > 2: + tunnel = True + text = text[1:-1].strip() + elif text.startswith("("): + tunnel = True + text = text[1:].strip() + elif text.endswith(")"): + tunnel = True + text = text[:-1].strip() + m = re.match( + r"^([A-Za-z][A-Za-z0-9_-]*)(?:\.([A-Za-z]+))?$", + text, + ) + if not m or not _looks_like_box_id(m.group(1), known): + return None + return Idef0End(box=m.group(1), side=_parse_side_token(m.group(2), default_side), tunnel=tunnel) + + +def _consume_ends( + text: str, + default_side: Side, + known: set[str], +) -> tuple[list[Idef0End], str]: + """Parse a comma-separated box-ref list from the start of text.""" + rest = text.strip() + ends: list[Idef0End] = [] + while rest: + m = re.match( + r"^\s*(\(?[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z]+)?\)?)\s*(,\s*)?", + rest, + ) + if not m: + break + end = _parse_end_token(m.group(1), default_side, known) + if end is None: + break + ends.append(end) + rest = rest[m.end() :] + if not m.group(2): + # no comma — only continue if next token is clearly another ref + break + return ends, rest.strip() + + +def _split_label_icom(text: str) -> tuple[str, str]: + text = text.strip() + if text.startswith(":"): + text = text[1:].strip() + icom = "" + m = _ICOM_TAIL.search(text) + if m: + icom = m.group(1).upper() + text = text[: m.start()].strip() + return _unquote(text), icom + + +def _split_arrow_op(line: str) -> tuple[str, str, str] | None: + for op in ("<-", "-->", "->"): + i = line.find(op) + if i >= 0: + return line[:i], op, line[i + len(op) :] + parts = line.split() + for idx, tok in enumerate(parts): + if tok in ("^", "v", "V"): + left = " ".join(parts[:idx]) + right = " ".join(parts[idx + 1 :]) + return left, tok, right + return None + + +def _ensure_box(diagram: Idef0Diagram, box_id: str, name: str = "") -> None: + ids = {b.id for b in diagram.boxes} + if box_id in ids: + if name: + for b in diagram.boxes: + if b.id == box_id and not b.name: + b.name = name + return + diagram.boxes.append(Idef0Box(id=box_id, name=name or box_id, number=box_id)) + + +def _current_box_id(diagram: Idef0Diagram) -> str: + if diagram.boxes: + return diagram.boxes[-1].id + return "A0" + + +def _boundary_end(side: Side, tunnel: bool = False) -> Idef0End: + return Idef0End(box=None, side=side, tunnel=tunnel) + + +def _parse_arrow_line(line: str, diagram: Idef0Diagram, lineno: int) -> None: + known = {b.id for b in diagram.boxes} + raw = line.strip() + tun_border = False + tun_box = False + work = raw + if work.startswith("(") and not work.startswith("(A") and not re.match(r"^\([A-Za-z]", work): + tun_border = True + work = work[1:].lstrip() + # keyword form: [box] in/out/ctrl/mech/call label + km = _KW_ARROW.match(work) + if km: + tun_kw_b = bool(km.group(1) or km.group(3)) + box_id = km.group(2) or _current_box_id(diagram) + kw = km.group(4) + tun_kw_box = bool(km.group(5)) + rest = km.group(6) + side = _parse_side_token(kw, "I") + label, icom = _split_label_icom(rest) + _ensure_box(diagram, box_id) + box_end = Idef0End(box=box_id, side="M" if side == "CALL" else side, tunnel=tun_kw_box or tun_box) + if side in ("I", "C", "M"): + diagram.arrows.append( + Idef0Arrow( + sources=[_boundary_end(side, tun_kw_b or tun_border)], + targets=[box_end], + label=label, + icom=icom, + ) + ) + else: # O or CALL — leave the box + out_side: Side = "CALL" if side == "CALL" else "O" + box_end.side = out_side + diagram.arrows.append( + Idef0Arrow( + sources=[box_end], + targets=[_boundary_end(out_side, tun_kw_b or tun_border)], + label=label, + icom=icom, + ) + ) + return + + split = _split_arrow_op(work) + if not split: + raise Idef0ParseError(f"IDEF0: не удалось разобрать строку {lineno}: {raw}") + left, op, right = split + left, right = left.strip(), right.strip() + if left.endswith("("): + tun_border = True + left = left[:-1].strip() + if right.startswith(")"): + tun_box = True + right = right[1:].strip() + if op in ("<-",): + side: Side = "I" + elif op in ("^",): + side = "C" + elif op in ("v", "V"): + side = "M" + else: + side = "O" + + if side == "O": + srcs, left_rest = _consume_ends(left, "O", known) + if left_rest: + # leftover on the left is unexpected for output + if not srcs: + raise Idef0ParseError(f"IDEF0: не удалось разобрать строку {lineno}: {raw}") + if not srcs: + bid = _current_box_id(diagram) + _ensure_box(diagram, bid) + srcs = [Idef0End(box=bid, side="O", tunnel=tun_box)] + else: + for e in srcs: + if e.side == "O" and "." not in (left or ""): + e.side = "O" + e.tunnel = e.tunnel or tun_box + for e in srcs: + if e.box: + _ensure_box(diagram, e.box) + + tgts, tgt_rest = _consume_ends(right, "I", known) + if tgts: + label, icom = _split_label_icom(tgt_rest) + for t in tgts: + if t.box: + _ensure_box(diagram, t.box) + t.tunnel = t.tunnel or tun_border + diagram.arrows.append(Idef0Arrow(sources=srcs, targets=tgts, label=label, icom=icom)) + return + label, icom = _split_label_icom(right) + diagram.arrows.append( + Idef0Arrow( + sources=srcs, + targets=[_boundary_end("O", tun_border)], + label=label, + icom=icom, + ) + ) + return + + # Input / control / mechanism into a box (from boundary or from other boxes) + srcs, src_rest = _consume_ends(left, "O", known) + if srcs and not src_rest: + # A1 -> already handled; here A1 ^ or "A2, A3 <-" wait, left of <- is the box + pass + + if op == "<-": + # `A1, A2 <- X` is unusual; `A1 <- label` or `A2 <- A1` (A1 output into A2) + # If right starts with box refs: internal into this side + box_left, left_rest = _consume_ends(left or "", "I", known) + if not box_left: + bid = _current_box_id(diagram) + _ensure_box(diagram, bid) + box_left = [Idef0End(box=bid, side="I", tunnel=tun_box)] + else: + for e in box_left: + e.side = "I" + e.tunnel = e.tunnel or tun_box + if e.box: + _ensure_box(diagram, e.box) + from_boxes, from_rest = _consume_ends(right, "O", known) + if from_boxes: + label, icom = _split_label_icom(from_rest) + for e in from_boxes: + e.side = "O" + if e.box: + _ensure_box(diagram, e.box) + diagram.arrows.append( + Idef0Arrow(sources=from_boxes, targets=box_left, label=label, icom=icom) + ) + return + label, icom = _split_label_icom(right) + diagram.arrows.append( + Idef0Arrow( + sources=[_boundary_end("I", tun_border)], + targets=box_left, + label=label, + icom=icom, + ) + ) + return + + # ^ or v : `A1 ^ label` or `A1 ^ A2` (A2 as source? rare) — treat right as label + # unless right is box refs (output of those boxes feeding this control/mech) + box_left, _ = _consume_ends(left or "", side, known) + if not box_left: + bid = _current_box_id(diagram) + _ensure_box(diagram, bid) + box_left = [Idef0End(box=bid, side=side, tunnel=tun_box)] + else: + for e in box_left: + e.side = side + e.tunnel = e.tunnel or tun_box + if e.box: + _ensure_box(diagram, e.box) + from_boxes, from_rest = _consume_ends(right, "O", known) + if from_boxes: + label, icom = _split_label_icom(from_rest) + for e in from_boxes: + e.side = "O" + if e.box: + _ensure_box(diagram, e.box) + diagram.arrows.append( + Idef0Arrow(sources=from_boxes, targets=box_left, label=label, icom=icom) + ) + return + label, icom = _split_label_icom(right) + diagram.arrows.append( + Idef0Arrow( + sources=[_boundary_end(side, tun_border)], + targets=box_left, + label=label, + icom=icom, + ) + ) + + +def _parse_box_line(line: str) -> Idef0Box | None: + m = _BOX_DEF_BRACKET.match(line) + if m: + return Idef0Box(id=m.group(1), name=_unquote(m.group(2)) or m.group(1), number=m.group(1)) + m = _BOX_DEF_KW.match(line) + if m: + return Idef0Box(id=m.group(1), name=_unquote(m.group(2)) or m.group(1), number=m.group(1)) + # Shorthand: A0 Name (must look like an IDEF0 node id) + m = re.match(r"^(A-?\d+[A-Za-z0-9]*)\s+(.+)$", line) + if m and "->" not in line and "<-" not in line: + rest = m.group(2).strip() + if rest and rest[0] not in "^vV" and not _NOT_BOX_REST.match(rest) and not _KW_ARROW.match(line): + if rest.startswith("(") or rest.startswith(")"): + return None + return Idef0Box(id=m.group(1), name=_unquote(rest), number=m.group(1)) + return None + + +def _canon_status(value: str) -> str: + token = value.strip().split()[0] if value.strip() else "" + return _STATUS_CANON.get(token.lower(), token.upper()) + + +def parse_idef0(source: str) -> Idef0Diagram: + """Parse an ```idef0 body into a diagram model.""" + diagram = Idef0Diagram() + pending: list[tuple[int, str]] = [] + saw_content = False + if not (source or "").strip(): + raise Idef0ParseError("IDEF0: пустой блок") + + for lineno, raw in enumerate(source.splitlines(), 1): + line = _strip_comment(raw).strip() + if not line: + continue + saw_content = True + meta = _match_meta(line) + if meta: + key, val = meta + if key == "status": + val = _canon_status(val) + if hasattr(diagram, key): + setattr(diagram, key, val) + continue + box = _parse_box_line(line) + if box: + _ensure_box(diagram, box.id, box.name) + for b in diagram.boxes: + if b.id == box.id: + b.name = box.name or b.name + b.number = box.number or b.number + continue + pending.append((lineno, line)) + + if not saw_content: + raise Idef0ParseError("IDEF0: пустой блок") + + if not diagram.boxes: + _ensure_box(diagram, "A0", diagram.title or "A0") + + for lineno, line in pending: + _parse_arrow_line(line, diagram, lineno) + + for arrow in diagram.arrows: + if not re.match(r"^O\d+$", arrow.icom or "", re.I): + continue + has_c = any(t.box and t.side == "C" for t in arrow.targets) + has_bound = any(t.box is None for t in arrow.targets) + if has_c and not has_bound: + arrow.targets.append(_boundary_end("O")) + + if not diagram.node: + if len(diagram.boxes) == 1 and diagram.boxes[0].id.upper() in {"A0", "A-0", "0"}: + diagram.node = "A-0" + else: + diagram.node = "A0" + if not diagram.title and diagram.boxes: + diagram.title = diagram.boxes[0].name + if not diagram.context and diagram.node.upper() in {"A-0", "A0"}: + if diagram.node.upper() == "A-0": + diagram.context = "TOP" + if not diagram.page: + diagram.page = diagram.number or "1" + return diagram + + +# --- fonts / geometry ------------------------------------------------------ + +def _font_candidates(bold: bool) -> list[Path]: + names_win = ( + ("arialbd.ttf", "segoeuib.ttf", "calibrib.ttf") + if bold + else ("arial.ttf", "segoeui.ttf", "calibri.ttf") + ) + names_unix = ( + ("DejaVuSans-Bold.ttf", "LiberationSans-Bold.ttf", "FreeSansBold.ttf") + if bold + else ("DejaVuSans.ttf", "LiberationSans-Regular.ttf", "FreeSans.ttf") + ) + out: list[Path] = [] + windir = os.environ.get("WINDIR") or r"C:\Windows" + fonts_win = Path(windir) / "Fonts" + for n in names_win: + out.append(fonts_win / n) + for root in ( + Path("/usr/share/fonts/truetype/dejavu"), + Path("/usr/share/fonts/truetype/liberation"), + Path("/usr/share/fonts/truetype/freefont"), + Path("/usr/share/fonts/TTF"), + Path("/usr/share/fonts/truetype/msttcorefonts"), + ): + for n in names_unix: + out.append(root / n) + return out + + +_FONT_CACHE: dict[tuple[int, bool], ImageFont.ImageFont] = {} + + +def _load_font(size: int, bold: bool = False) -> ImageFont.ImageFont: + size = max(8, int(size)) + key = (size, bold) + hit = _FONT_CACHE.get(key) + if hit is not None: + return hit + for path in _font_candidates(bold): + if path.is_file(): + try: + font = ImageFont.truetype(str(path), size=size) + _FONT_CACHE[key] = font + return font + except OSError: + continue + font = ImageFont.load_default() + _FONT_CACHE[key] = font + return font + + +def _text_wh(font: ImageFont.ImageFont, text: str) -> tuple[int, int]: + if not text: + return 0, 0 + if hasattr(font, "getbbox"): + l, t, r, b = font.getbbox(text) + return max(0, r - l), max(0, b - t) + if hasattr(font, "getsize"): + w, h = font.getsize(text) # type: ignore[attr-defined] + return int(w), int(h) + return len(text) * 8, 12 + + +def _wrap(text: str, font: ImageFont.ImageFont, max_width: int) -> list[str]: + text = (text or "").strip() + if not text: + return [""] + words = text.split() + lines: list[str] = [] + cur = "" + for word in words: + trial = word if not cur else f"{cur} {word}" + if _text_wh(font, trial)[0] <= max_width or not cur: + cur = trial + else: + lines.append(cur) + cur = word + if cur: + lines.append(cur) + return lines or [""] + + +# --- drawing ops ----------------------------------------------------------- + +@dataclass +class _Rect: + x: float + y: float + w: float + h: float + + @property + def r(self) -> float: + return self.x + self.w + + @property + def b(self) -> float: + return self.y + self.h + + @property + def cx(self) -> float: + return self.x + self.w / 2 + + @property + def cy(self) -> float: + return self.y + self.h / 2 + + def contains_point(self, px: float, py: float, pad: float = 0.0) -> bool: + return self.x - pad <= px <= self.r + pad and self.y - pad <= py <= self.b + pad + + def inflate(self, p: float) -> "_Rect": + return _Rect(self.x - p, self.y - p, self.w + 2 * p, self.h + 2 * p) + + def overlap_area(self, other: "_Rect") -> float: + ix = max(0.0, min(self.r, other.r) - max(self.x, other.x)) + iy = max(0.0, min(self.b, other.b) - max(self.y, other.y)) + return ix * iy + + +@dataclass +class _BoxGeom: + box: Idef0Box + rect: _Rect + slots: dict[str, list[tuple[int, Idef0Arrow, Idef0End]]] = field(default_factory=dict) + + +class _Scene: + def __init__(self, width: int, height: int): + self.width = width + self.height = height + self.ops: list[tuple] = [] + + def line(self, x1, y1, x2, y2, width: float = 1.5) -> None: + self.ops.append(("line", float(x1), float(y1), float(x2), float(y2), float(width))) + + def polyline(self, pts: list[tuple[float, float]], width: float = 1.5) -> None: + if len(pts) < 2: + return + self.ops.append(("polyline", [(float(x), float(y)) for x, y in pts], float(width))) + + def rect( + self, + x, + y, + w, + h, + width: float = 1.5, + fill: str | None = None, + outline: str | None = "#000000", + ) -> None: + self.ops.append( + ("rect", float(x), float(y), float(w), float(h), float(width), fill, outline) + ) + + def polygon( + self, + pts: list[tuple[float, float]], + fill: str = "#000000", + outline: str = "#000000", + width: float = 1, + ) -> None: + self.ops.append( + ("polygon", [(float(x), float(y)) for x, y in pts], fill, outline, float(width)) + ) + + def text( + self, + x, + y, + text: str, + size: float, + *, + bold: bool = False, + anchor: str = "lt", + fill: str = "#000000", + ) -> None: + if not text: + return + self.ops.append(("text", float(x), float(y), text, float(size), bold, anchor, fill)) + + def arc(self, bbox: tuple[float, float, float, float], start: float, end: float, width: float) -> None: + self.ops.append(("arc", tuple(float(v) for v in bbox), float(start), float(end), float(width))) + + def to_png(self) -> bytes: + img = Image.new("RGB", (self.width, self.height), "#ffffff") + draw = ImageDraw.Draw(img) + for op in self.ops: + kind = op[0] + if kind == "line": + _, x1, y1, x2, y2, w = op + draw.line([(x1, y1), (x2, y2)], fill="#000000", width=max(1, int(round(w)))) + elif kind == "polyline": + _, pts, w = op + draw.line(pts, fill="#000000", width=max(1, int(round(w)))) + elif kind == "rect": + _, x, y, w, h, lw, fill, outline = op + xy = [x, y, x + w, y + h] + stroke_w = max(1, int(round(lw))) if outline else 0 + draw.rectangle(xy, fill=fill, outline=outline, width=stroke_w) + elif kind == "polygon": + _, pts, fill, outline, w = op + draw.polygon(pts, fill=fill, outline=outline) + elif kind == "text": + _, x, y, text, size, bold, anchor, fill = op + font = _load_font(int(round(size)), bold) + draw.text((x, y), text, font=font, fill=fill, anchor=anchor) + elif kind == "arc": + _, bbox, start, end, w = op + draw.arc(bbox, start, end, fill="#000000", width=max(1, int(round(w)))) + from io import BytesIO + + buf = BytesIO() + img.save(buf, format="PNG", optimize=True) + return buf.getvalue() + + def to_svg(self) -> str: + esc = xml.sax.saxutils.escape + parts = [ + f'', + '', + ] + for op in self.ops: + kind = op[0] + if kind == "line": + _, x1, y1, x2, y2, w = op + parts.append( + f'' + ) + elif kind == "polyline": + _, pts, w = op + d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) + parts.append( + f'' + ) + elif kind == "rect": + _, x, y, w, h, lw, fill, outline = op + fill_a = fill or "none" + stroke = outline or "none" + sw = 0 if outline is None else lw + parts.append( + f'' + ) + elif kind == "polygon": + _, pts, fill, outline, w = op + d = " ".join(f"{x:.1f},{y:.1f}" for x, y in pts) + parts.append( + f'' + ) + elif kind == "text": + _, x, y, text, size, bold, anchor, fill = op + ta, dx, dy = _svg_anchor(anchor) + weight = "700" if bold else "400" + parts.append( + f'' + f"{esc(text)}" + ) + elif kind == "arc": + _, bbox, start, end, w = op + parts.append(_svg_arc(bbox, start, end, w)) + parts.append("") + return "\n".join(parts) + "\n" + + +def _svg_anchor(anchor: str) -> tuple[str, str, str]: + """Map PIL anchors to SVG text-anchor + baseline tweak.""" + a = (anchor or "lt").lower() + horiz = a[0] if a else "l" + vert = a[1] if len(a) > 1 else "t" + ta = {"l": "start", "m": "middle", "r": "end"}.get(horiz, "start") + # PIL anchor is the point; SVG y is alphabetic baseline ≈ 0.8 em from top + dy = {"t": "0.9em", "m": "0.35em", "b": "0"}.get(vert, "0.9em") + return ta, "0", dy + + +def _svg_arc(bbox, start: float, end: float, width: float) -> str: + x0, y0, x1, y1 = bbox + cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 + rx, ry = abs(x1 - x0) / 2, abs(y1 - y0) / 2 + import math + + def pt(deg: float) -> tuple[float, float]: + rad = math.radians(deg) + return cx + rx * math.cos(rad), cy + ry * math.sin(rad) + + sx, sy = pt(start) + ex, ey = pt(end) + large = 1 if abs(end - start) % 360 > 180 else 0 + return ( + f'' + ) + + +# --- layout + render ------------------------------------------------------- + +def _arrowhead(scene: _Scene, tip: tuple[float, float], direction: str, size: float, lw: float) -> None: + """direction = where the arrow points: E W N S.""" + x, y = tip + s = size + if direction == "E": + pts = [(x, y), (x - s, y - s * 0.42), (x - s, y + s * 0.42)] + elif direction == "W": + pts = [(x, y), (x + s, y - s * 0.42), (x + s, y + s * 0.42)] + elif direction == "S": + pts = [(x, y), (x - s * 0.42, y - s), (x + s * 0.42, y - s)] + else: + pts = [(x, y), (x - s * 0.42, y + s), (x + s * 0.42, y + s)] + scene.polygon(pts, fill="#000000", outline="#000000", width=lw) + + +def _dir_for_into(side: Side) -> str: + return {"I": "E", "C": "S", "O": "E", "M": "N", "CALL": "S"}[side] + + +def _dir_for_out(side: Side) -> str: + return {"I": "W", "C": "N", "O": "E", "M": "S", "CALL": "S"}[side] + + +def _stub_dir_from_box(side: Side, leaving: bool = True) -> str: + """Outward from a box face (first/last elbow of the route).""" + return {"I": "W", "C": "N", "O": "E", "M": "S", "CALL": "S"}[side] + + +def _end_stub_dir(end: Idef0End) -> str: + """Direction from the endpoint toward the interior of the route.""" + if end.box: + return _stub_dir_from_box(end.side) + # Boundary: step into the diagram, not into the title block. + return {"I": "E", "C": "S", "O": "W", "M": "N", "CALL": "N"}[end.side] + + +def _offset(pt: tuple[float, float], direction: str, dist: float) -> tuple[float, float]: + x, y = pt + if direction == "E": + return x + dist, y + if direction == "W": + return x - dist, y + if direction == "N": + return x, y - dist + return x, y + dist + + +def _port_point(geom: _BoxGeom, side: Side, index: int, count: int) -> tuple[float, float]: + r = geom.rect + n = max(count, 1) + if n == 1: + t = 0.5 + else: + pad = min(0.22, 0.48 / n) + t = pad + (1.0 - 2.0 * pad) * (index / (n - 1)) + if side in ("I",): + return r.x, r.y + r.h * t + if side in ("O",): + return r.r, r.y + r.h * t + if side in ("C",): + return r.x + r.w * t, r.y + return r.x + r.w * t, r.b + + +def _boundary_point(work: _Rect, side: Side, along: float) -> tuple[float, float]: + if side == "I": + return work.x, along + if side == "O": + return work.r, along + if side == "C": + return along, work.y + if side == "CALL": + return along, work.b + return along, work.b + + +def _dedupe_pts(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: + out: list[tuple[float, float]] = [] + for p in pts: + if not out or abs(out[-1][0] - p[0]) > 0.8 or abs(out[-1][1] - p[1]) > 0.8: + out.append(p) + return out + + +def _simplify_ortho(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: + """Dedupe, merge colinear runs, drop overshoot/backtracking on the same axis.""" + pts = _dedupe_pts(pts) + if len(pts) < 3: + return pts + merged: list[tuple[float, float]] = [pts[0]] + for i in range(1, len(pts) - 1): + a = merged[-1] + b = pts[i] + c = pts[i + 1] + colinear = (abs(a[0] - b[0]) < 1.2 and abs(b[0] - c[0]) < 1.2) or ( + abs(a[1] - b[1]) < 1.2 and abs(b[1] - c[1]) < 1.2 + ) + if colinear: + continue + merged.append(b) + merged.append(pts[-1]) + return _dedupe_pts(merged) + + +def _polyline_backtracks(pts: list[tuple[float, float]]) -> bool: + pts = _simplify_ortho(pts) + for a, b, c in zip(pts, pts[1:], pts[2:]): + if abs(a[0] - b[0]) < 1.2 and abs(b[0] - c[0]) < 1.2: + if (b[1] - a[1]) * (c[1] - b[1]) < -1.0: + return True + if abs(a[1] - b[1]) < 1.2 and abs(b[1] - c[1]) < 1.2: + if (b[0] - a[0]) * (c[0] - b[0]) < -1.0: + return True + return False + + +def _seg_aabb(a: tuple[float, float], b: tuple[float, float], r: _Rect) -> bool: + dx, dy = b[0] - a[0], b[1] - a[1] + t0, t1 = 0.0, 1.0 + for p, q in ( + (-dx, a[0] - r.x), + (dx, r.r - a[0]), + (-dy, a[1] - r.y), + (dy, r.b - a[1]), + ): + if abs(p) < 1e-9: + if q < 0: + return False + continue + t = q / p + if p < 0: + if t > t1: + return False + if t > t0: + t0 = t + else: + if t < t0: + return False + if t < t1: + t1 = t + return t1 >= t0 + + +def _seg_hits_open_box(a: tuple[float, float], b: tuple[float, float], r: _Rect, inset: float = 4.0) -> bool: + inner = r.inflate(-inset) + if inner.w <= 2 or inner.h <= 2: + return False + return _seg_aabb(a, b, inner) + + +def _seg_obstacle(a: tuple[float, float], b: tuple[float, float], half: float) -> _Rect: + x0, x1 = min(a[0], b[0]) - half, max(a[0], b[0]) + half + y0, y1 = min(a[1], b[1]) - half, max(a[1], b[1]) + half + return _Rect(x0, y0, x1 - x0, y1 - y0) + + +def _take_free(used: list[float], hint: float, lo: float, hi: float, gap: float) -> float: + if hi < lo: + lo, hi = hi, lo + if hi - lo < gap * 0.4: + used.append((lo + hi) / 2) + return used[-1] + hint = min(max(hint, lo), hi) + for step in range(0, 56): + delta = (step + 1) // 2 * gap * 0.5 + x = hint + (delta if step % 2 == 0 else -delta) + x = min(max(x, lo), hi) + if all(abs(x - u) >= gap for u in used): + used.append(x) + return x + used.append(hint) + return hint + + +def _route(p1: tuple[float, float], d1: str, p2: tuple[float, float], d2: str, stub: float) -> list[tuple[float, float]]: + """Elbow helper; ICOM routing uses _route_icom instead.""" + a = _offset(p1, d1, stub) + b = _offset(p2, d2, stub) + pts = [p1, a] + if abs(a[0] - b[0]) < 1.5 or abs(a[1] - b[1]) < 1.5: + pts.append(b) + elif d1 in ("E", "W"): + pts.extend([(b[0], a[1]), b]) + else: + pts.extend([(a[0], b[1]), b]) + pts.append(p2) + return _simplify_ortho(pts) + + +def _draw_tunnel(scene: _Scene, pt: tuple[float, float], horizontal: bool, s: float, lw: float) -> None: + x, y = pt + r = 7 * s + if horizontal: + scene.arc((x - r, y - r, x + r * 0.15, y + r), 70, 290, lw) + scene.arc((x - r * 0.15, y - r, x + r, y + r), 250, 110, lw) + else: + scene.arc((x - r, y - r, x + r, y + r * 0.15), 160, 20, lw) + scene.arc((x - r, y - r * 0.15, x + r, y + r), 340, 200, lw) + + +def _draw_context_cell( + scene: _Scene, + d: Idef0Diagram, + cell: _Rect, + s: float, + fonts: dict, + lw: float, +) -> None: + """CONTEXT thumbnail: small TOP, or a mini parent box / staircase.""" + scene.text(cell.x + 6 * s, cell.y + 4 * s, "CONTEXT:", fonts["lab"], anchor="lt") + ctx = d.context or ("TOP" if (d.node or "").upper() == "A-0" else d.node) + if (ctx or "").upper() == "TOP": + scene.text(cell.cx, cell.cy + 8 * s, "TOP", 12 * s, bold=True, anchor="mm") + return + child: int | None = None + m = re.match(r"^A(\d+)$", (d.node or "").strip(), re.I) + if m and int(m.group(1)) >= 1: + child = int(m.group(1)) + # A-0 parent is a single box; A0+ child diagrams get a mini staircase. + if child is None or (ctx or "").upper() in {"A-0"}: + bw, bh = min(58 * s, cell.w * 0.46), min(28 * s, cell.h * 0.32) + bx, by = cell.cx - bw / 2, cell.y + 32 * s + sh = 2.0 * s + scene.rect(bx + sh, by + sh, bw, bh, width=0, fill="#c4c4c4", outline=None) + scene.rect(bx, by, bw, bh, width=lw, fill="#ffffff") + scene.text(bx + bw - 4 * s, by + bh - 3 * s, ctx, fonts["small"], bold=True, anchor="rb") + return + n = 4 + bw, bh = 15 * s, 9 * s + step_x, step_y = 8 * s, 7 * s + total_w = bw + step_x * (n - 1) + total_h = bh + step_y * (n - 1) + x0 = cell.cx - total_w / 2 + y0 = cell.y + 24 * s + fill_i = min(max(child, 1), n) - 1 + for i in range(n): + x, y = x0 + i * step_x, y0 + i * step_y + scene.rect(x, y, bw, bh, width=max(0.8, lw * 0.85), fill="#000000" if i == fill_i else "#ffffff") + scene.text(cell.x + 6 * s, min(cell.b - 5 * s, y0 + total_h + 4 * s), ctx, fonts["small"], bold=True, anchor="lt") + + +def _draw_kit_form(scene: _Scene, d: Idef0Diagram, outer: _Rect, s: float, fonts: dict) -> _Rect: + """NIST / KBSI IDEF0 sheet. Returns the inner working rectangle.""" + lw = max(1.0, 1.0 * s) + # double outer frame + scene.rect(outer.x, outer.y, outer.w, outer.h, width=lw) + inset = 3.2 * s + scene.rect(outer.x + inset, outer.y + inset, outer.w - 2 * inset, outer.h - 2 * inset, width=lw) + + inner = _Rect(outer.x + inset, outer.y + inset, outer.w - 2 * inset, outer.h - 2 * inset) + header_h = 96 * s + footer_h = 64 * s + hy = inner.y + fy = inner.b - footer_h + # header / footer separators + scene.line(inner.x, hy + header_h, inner.r, hy + header_h, lw) + scene.line(inner.x, fy, inner.r, fy, lw) + + # column x positions inside inner + x0 = inner.x + w = inner.w + x_used = x0 + w_used = 0.145 * w + x_auth = x_used + w_used + w_auth = 0.355 * w + x_stat = x_auth + w_auth + w_stat = 0.175 * w + x_read = x_stat + w_stat + w_read = 0.145 * w + x_ctx = x_read + w_read + w_ctx = inner.r - x_ctx + + for x in (x_auth, x_stat, x_read, x_ctx): + scene.line(x, hy, x, hy + header_h, lw) + + # DATE/REV row inside status column + date_row = 22 * s + scene.line(x_stat, hy + date_row, x_read, hy + date_row, lw) + scene.line(x_stat + w_stat * 0.55, hy, x_stat + w_stat * 0.55, hy + date_row, lw) + + lab = fonts["lab"] + val = fonts["val"] + small = fonts["small"] + + scene.text(x_used + 6 * s, hy + 5 * s, "USED AT:", lab, anchor="lt") + if d.used_at: + for i, ln in enumerate(_wrap(d.used_at, _load_font(int(val), False), int(w_used - 12 * s))[:4]): + scene.text(x_used + 6 * s, hy + 22 * s + i * 14 * s, ln, val, anchor="lt") + + scene.text(x_auth + 6 * s, hy + 4 * s, "AUTHOR:", lab, anchor="lt") + scene.text(x_auth + 62 * s, hy + 4 * s, d.author, val, bold=False, anchor="lt") + scene.text(x_auth + 6 * s, hy + 22 * s, "PROJECT:", lab, anchor="lt") + proj_lines = _wrap(d.project, _load_font(int(small), False), int(w_auth - 78 * s)) + if proj_lines: + scene.text(x_auth + 70 * s, hy + 22 * s, proj_lines[0], small, anchor="lt") + for i, ln in enumerate(proj_lines[1:3], 1): + scene.text(x_auth + 70 * s, hy + 22 * s + i * 13 * s, ln, small, anchor="lt") + notes_y = hy + 54 * s + scene.text(x_auth + 6 * s, notes_y, "NOTES:", lab, anchor="lt") + marked = set() + for tok in re.findall(r"\d+", d.notes or ""): + marked.add(int(tok)) + nx = x_auth + 58 * s + for n in range(1, 11): + scene.text(nx, notes_y, str(n), small, bold=n in marked, anchor="lt") + nx += 14 * s + + scene.text(x_stat + 5 * s, hy + 4 * s, "DATE:", lab, anchor="lt") + scene.text(x_stat + 42 * s, hy + 4 * s, d.date, val, anchor="lt") + scene.text(x_stat + w_stat * 0.55 + 4 * s, hy + 4 * s, "REV:", lab, anchor="lt") + scene.text(x_stat + w_stat * 0.55 + 36 * s, hy + 4 * s, d.rev, val, anchor="lt") + + st_y = hy + date_row + 6 * s + box_s = 8 * s + for i, name in enumerate(_STATUSES): + yy = st_y + i * 16 * s + filled = name == d.status + scene.rect( + x_stat + 6 * s, + yy + 1 * s, + box_s, + box_s, + width=lw, + fill="#000000" if filled else None, + ) + scene.text(x_stat + 18 * s, yy, name, small, bold=filled, anchor="lt") + + scene.text(x_read + 6 * s, hy + 4 * s, "READER", lab, anchor="lt") + scene.text(x_read + 6 * s, hy + 22 * s, d.reader, val, anchor="lt") + scene.line(x_read, hy + date_row, x_ctx, hy + date_row, lw) + scene.text(x_read + 6 * s, hy + date_row + 4 * s, "DATE", lab, anchor="lt") + scene.text(x_read + 6 * s, hy + date_row + 20 * s, d.reader_date, val, anchor="lt") + + _draw_context_cell(scene, d, _Rect(x_ctx, hy, w_ctx, header_h), s, fonts, lw) + + # footer + fn_w = 0.18 * inner.w + ft_x = inner.x + fn_w + ft_w = 0.62 * inner.w + fnum_x = ft_x + ft_w + scene.line(ft_x, fy, ft_x, inner.b, lw) + scene.line(fnum_x, fy, fnum_x, inner.b, lw) + scene.text(inner.x + 6 * s, fy + 4 * s, "NODE:", lab, anchor="lt") + scene.text(inner.x + fn_w / 2, fy + 38 * s, d.node, fonts["node"], bold=True, anchor="mm") + scene.text(ft_x + 6 * s, fy + 4 * s, "TITLE:", lab, anchor="lt") + title_font = _load_font(int(fonts["title"]), True) + tlines = _wrap(d.title, title_font, int(ft_w - 16 * s)) + if len(tlines) == 1: + scene.text(ft_x + ft_w / 2, fy + 38 * s, tlines[0], fonts["title"], bold=True, anchor="mm") + else: + for i, ln in enumerate(tlines[:2]): + scene.text(ft_x + ft_w / 2, fy + 26 * s + i * 18 * s, ln, fonts["title"] * 0.85, bold=True, anchor="mm") + scene.text(fnum_x + 6 * s, fy + 4 * s, "NUMBER:", lab, anchor="lt") + scene.text(fnum_x + (inner.r - fnum_x) / 2, fy + 36 * s, d.number, fonts["node"], bold=True, anchor="mm") + + # tiny page box, bottom-right of the form + pb = 18 * s + scene.rect(inner.r - pb, inner.b - pb, pb, pb, width=lw) + scene.text(inner.r - pb / 2, inner.b - pb / 2, d.page, small, bold=True, anchor="mm") + + work = _Rect(inner.x + 8 * s, hy + header_h + 8 * s, inner.w - 16 * s, fy - (hy + header_h) - 16 * s) + return work + + +def _assign_slots(diagram: Idef0Diagram, geoms: dict[str, _BoxGeom]) -> None: + for g in geoms.values(): + g.slots = {"I": [], "C": [], "O": [], "M": [], "CALL": []} + for ai, arrow in enumerate(diagram.arrows): + for end in arrow.sources + arrow.targets: + if end.box and end.box in geoms: + side = end.side if end.side in geoms[end.box].slots else "M" + geoms[end.box].slots[side].append((ai, arrow, end)) + + +def _slot_index(geom: _BoxGeom, arrow: Idef0Arrow, end: Idef0End) -> tuple[int, int]: + side = end.side if end.side in geom.slots else "M" + items = geom.slots[side] + for i, (_ai, ar, e) in enumerate(items): + if ar is arrow and e is end: + return i, len(items) + return 0, max(len(items), 1) + + +def _layout_boxes(diagram: Idef0Diagram, work: _Rect, s: float) -> dict[str, _BoxGeom]: + """BPwin-style cluster: compact staircase, large ICOM margins, do not stretch to fill.""" + n = len(diagram.boxes) + geoms: dict[str, _BoxGeom] = {} + c_h, m_h = 84 * s, 96 * s + i_w, o_w = 108 * s, 100 * s + inner = _Rect( + work.x + i_w, + work.y + c_h, + max(180 * s, work.w - i_w - o_w), + max(140 * s, work.h - c_h - m_h), + ) + if n == 1: + bw, bh = min(320 * s, inner.w * 0.42), min(138 * s, inner.h * 0.36) + rect = _Rect(inner.cx - bw / 2, inner.cy - bh / 2, bw, bh) + geoms[diagram.boxes[0].id] = _BoxGeom(diagram.boxes[0], rect) + return geoms + bw = min(200 * s, inner.w * 0.34) + bh = min(86 * s, inner.h * 0.24) + # Compact staircase, but keep a corridor so O→I can drop between boxes + # (0.85*bw alone overlaps; Kinzyabulatov A1 has a visible gap). + step_x = bw + 56 * s + step_y = bh + 58 * s + total_w = bw + step_x * (n - 1) + total_h = bh + step_y * (n - 1) + if n > 1 and total_w > inner.w: + step_x = max(bw * 0.55, (inner.w - bw) / (n - 1)) + total_w = bw + step_x * (n - 1) + if n > 1 and total_h > inner.h: + step_y = max(bh * 0.7, (inner.h - bh) / (n - 1)) + total_h = bh + step_y * (n - 1) + x0 = inner.x + max(0.0, (inner.w - total_w) / 2) + y0 = inner.y + max(0.0, (inner.h - total_h) / 2) + for i, box in enumerate(diagram.boxes): + geoms[box.id] = _BoxGeom(box, _Rect(x0 + i * step_x, y0 + i * step_y, bw, bh)) + return geoms + + +def _draw_box(scene: _Scene, geom: _BoxGeom, s: float, lw: float, fonts: dict) -> None: + r = geom.rect + sh = 4.0 * s + scene.rect(r.x + sh, r.y + sh, r.w, r.h, width=0, fill="#c0c0c0", outline=None) + scene.rect(r.x, r.y, r.w, r.h, width=max(1.0, lw), fill="#ffffff") + # BPwin "has child" tick in the top-left corner. + ts = 8 * s + scene.polygon( + [(r.x, r.y), (r.x + ts, r.y), (r.x, r.y + ts)], + fill="#000000", + outline="#000000", + width=1, + ) + name_font = _load_font(int(fonts["box"]), False) + lines = _wrap(geom.box.name, name_font, int(r.w - 18 * s)) + total_h = len(lines) * fonts["box"] * 1.15 + y0 = r.cy - total_h / 2 + fonts["box"] * 0.35 + for i, ln in enumerate(lines[:4]): + scene.text(r.cx, y0 + i * fonts["box"] * 1.15, ln, fonts["box"], anchor="mm") + scene.text(r.r - 6 * s, r.b - 5 * s, geom.box.number or geom.box.id, fonts["id"], anchor="rb") + + +def _end_point( + end: Idef0End, + arrow: Idef0Arrow, + geoms: dict[str, _BoxGeom], + work: _Rect, + mate: Idef0End | None = None, +) -> tuple[float, float]: + if end.box and end.box in geoms: + g = geoms[end.box] + idx, cnt = _slot_index(g, arrow, end) + return _port_point(g, end.side, idx, cnt) + other_y = work.cy + other_x = work.cx + mates = [mate] if mate is not None else ( + arrow.targets if end in arrow.sources else arrow.sources + ) + xs, ys = [], [] + for m in mates: + if m is None or not m.box or m.box not in geoms: + continue + g = geoms[m.box] + idx, cnt = _slot_index(g, arrow, m) + px, py = _port_point(g, m.side, idx, cnt) + xs.append(px) + ys.append(py) + if xs: + other_x = sum(xs) / len(xs) + other_y = sum(ys) / len(ys) + return _boundary_point(work, end.side, other_y if end.side in ("I", "O") else other_x) + + +def _box_order(diagram: Idef0Diagram) -> dict[str, int]: + return {b.id: i for i, b in enumerate(diagram.boxes)} + + +def _is_feedback( + src: Idef0End, + tgt: Idef0End, + order: dict[str, int], +) -> bool: + if not src.box or not tgt.box: + return False + if src.box not in order or tgt.box not in order: + return False + return order[src.box] > order[tgt.box] and src.side == "O" and tgt.side == "I" + + +@dataclass +class _Channels: + work: _Rect + stub: float + min_gap: float + c_lanes: dict[int, float] + m_lanes: dict[int, float] + i_x: float + o_x: float + m_y: float + box_top: float + box_bot: float + used_v: list[float] = field(default_factory=list) + used_h: list[float] = field(default_factory=list) + + +def _make_channels( + diagram: Idef0Diagram, + geoms: dict[str, _BoxGeom], + work: _Rect, + s: float, +) -> _Channels: + box_top = min((g.rect.y for g in geoms.values()), default=work.y + 70 * s) + box_bot = max((g.rect.b for g in geoms.values()), default=work.b - 70 * s) + c_arrows = [ + a for a in diagram.arrows + if any(e.box is None and e.side == "C" for e in a.sources) + ] + m_arrows = [ + a for a in diagram.arrows + if any(e.box is None and e.side == "M" for e in a.sources) + ] + c_arrows.sort(key=lambda a: (-len(a.targets), a.icom or a.label)) + m_arrows.sort(key=lambda a: (-len(a.targets), a.icom or a.label)) + c_lanes: dict[int, float] = {} + for k, a in enumerate(c_arrows): + y = work.y + 24 * s + k * 32 * s + c_lanes[id(a)] = min(y, box_top - 22 * s) + m_lanes: dict[int, float] = {} + for k, a in enumerate(m_arrows): + y = box_bot + 24 * s + k * 32 * s + m_lanes[id(a)] = min(y, work.b - 18 * s) + used_h = list(c_lanes.values()) + list(m_lanes.values()) + return _Channels( + work=work, + stub=28 * s, + min_gap=32 * s, + c_lanes=c_lanes, + m_lanes=m_lanes, + i_x=work.x + 18 * s, + o_x=work.r - 18 * s, + m_y=min(work.b - 16 * s, max(box_bot + 56 * s, work.b - 28 * s)), + box_top=box_top, + box_bot=box_bot, + used_v=[], + used_h=used_h, + ) + + +def _gutter_x( + p1: tuple[float, float], + p2: tuple[float, float], + geoms: dict[str, _BoxGeom], + skip: set[str], + stub: float, +) -> float: + lo, hi = (p1[0], p2[0]) if p1[0] <= p2[0] else (p2[0], p1[0]) + candidates = [ + (p1[0] + p2[0]) / 2, + p1[0] + stub, + p2[0] - stub, + lo + stub, + hi - stub, + ] + y1, y2 = p1[1], p2[1] + for gx in candidates: + if gx < lo - 1 or gx > hi + 1: + continue + ok = True + for gid, g in geoms.items(): + if gid in skip: + continue + if _seg_hits_open_box((gx, y1), (gx, y2), g.rect) or _seg_hits_open_box( + (p1[0], y1), (gx, y1), g.rect + ): + ok = False + break + if ok: + return gx + return max(lo + stub, min(hi - stub, (p1[0] + p2[0]) / 2)) + + +def _route_icom( + src: Idef0End, + tgt: Idef0End, + p1: tuple[float, float], + p2: tuple[float, float], + ch: _Channels, + geoms: dict[str, _BoxGeom], + order: dict[str, int], + arrow: Idef0Arrow, + lane_y: float | None = None, + spine_x: float | None = None, +) -> list[tuple[float, float]]: + stub = ch.stub + skip = {b for b in (src.box, tgt.box) if b} + + if src.box is None and tgt.box and src.side == "C": + if spine_x is not None and lane_y is not None: + y = lane_y + x_drop = p2[0] + x_in = spine_x + return _simplify_ortho([ + (x_in, ch.work.y), + (x_in, y), + (x_drop, y), + (x_drop, p2[1]), + ]) + return _simplify_ortho([(p2[0], ch.work.y), p2]) + + if src.box is None and tgt.box and src.side == "I": + return _simplify_ortho([(ch.work.x, p2[1]), p2]) + + if src.box is None and tgt.box and src.side == "M": + if spine_x is not None and lane_y is not None: + y = lane_y + x_up = p2[0] + x_in = spine_x + return _simplify_ortho([ + (x_in, ch.work.b), + (x_in, y), + (x_up, y), + (x_up, p2[1]), + ]) + return _simplify_ortho([(p2[0], ch.work.b), p2]) + + if tgt.box is None and src.box and tgt.side == "O": + return _simplify_ortho([p1, (ch.work.r, p1[1])]) + + if tgt.box is None and src.box and tgt.side == "CALL": + return _simplify_ortho([p1, (p1[0], ch.work.b)]) + + if src.box and tgt.box and src.side == "O" and tgt.side == "I": + if _is_feedback(src, tgt, order): + right = _take_free(ch.used_v, max(p1[0] + stub, ch.o_x), ch.o_x - 8, ch.work.r - 8, ch.min_gap) + bottom = _take_free(ch.used_h, ch.m_y, ch.box_bot + 12, ch.work.b - 10, ch.min_gap) + left = _take_free(ch.used_v, ch.i_x, ch.work.x + 8, ch.i_x + 40, ch.min_gap) + return _simplify_ortho([ + p1, + (right, p1[1]), + (right, bottom), + (left, bottom), + (left, p2[1]), + p2, + ]) + src_r = geoms[src.box].rect + tgt_r = geoms[tgt.box].rect + if tgt_r.x >= src_r.r + 10: + lo, hi = p1[0], p2[0] + hint = _gutter_x(p1, p2, geoms, skip, stub) + gx = _take_free(ch.used_v, hint, lo + stub * 0.45, hi - stub * 0.35, ch.min_gap * 0.6) + return _simplify_ortho([p1, (gx, p1[1]), (gx, p2[1]), p2]) + # Overlapping staircase: out, down the notch, in from the left of the target. + gx = _take_free( + ch.used_v, src_r.r + stub, src_r.r + stub * 0.55, src_r.r + stub * 2.8, ch.min_gap, + ) + left = _take_free( + ch.used_v, tgt_r.x - stub, ch.work.x + 8, max(ch.work.x + 10, tgt_r.x - stub * 0.45), ch.min_gap, + ) + y_lo = min(src_r.b + 8, tgt_r.y - 8) + y_hi = max(src_r.b + 8, tgt_r.y - 8) + y_bar = _take_free(ch.used_h, (src_r.b + tgt_r.y) / 2, y_lo, y_hi, ch.min_gap) + return _simplify_ortho([p1, (gx, p1[1]), (gx, y_bar), (left, y_bar), (left, p2[1]), p2]) + + if src.box and tgt.box and src.side == "O" and tgt.side == "C": + clear_x = _take_free( + ch.used_v, p1[0] + stub, p1[0] + stub * 0.7, p1[0] + stub * 3.2, ch.min_gap, + ) + y_bar = min(p2[1] - stub, geoms[tgt.box].rect.y - stub) + y_bar = max(ch.work.y + 12, y_bar) + y_bar = _take_free(ch.used_h, y_bar, ch.work.y + 10, geoms[tgt.box].rect.y - 12, ch.min_gap) + return _simplify_ortho([ + p1, + (clear_x, p1[1]), + (clear_x, y_bar), + (p2[0], y_bar), + p2, + ]) + + if src.box and tgt.box and src.side == "O" and tgt.side == "M": + clear_x = p1[0] + stub + y_bar = max(p2[1] + stub, geoms[tgt.box].rect.b + stub) + return _simplify_ortho([ + p1, + (clear_x, p1[1]), + (clear_x, y_bar), + (p2[0], y_bar), + p2, + ]) + + return _simplify_ortho([p1, p2]) + + +def _poly_len(pts: list[tuple[float, float]]) -> float: + tot = 0.0 + for a, b in zip(pts, pts[1:]): + tot += math.hypot(b[0] - a[0], b[1] - a[1]) + return tot + + +def _shared_prefix(paths: list[list[tuple[float, float]]]) -> list[tuple[float, float]]: + if not paths: + return [] + i = 0 + while all(i < len(p) for p in paths): + x0, y0 = paths[0][i] + if any(abs(p[i][0] - x0) > 1.5 or abs(p[i][1] - y0) > 1.5 for p in paths): + break + i += 1 + return paths[0][:i] + + +def _shared_suffix(paths: list[list[tuple[float, float]]]) -> list[tuple[float, float]]: + if not paths: + return [] + i = 0 + while all(i < len(p) for p in paths): + x0, y0 = paths[0][-1 - i] + if any(abs(p[-1 - i][0] - x0) > 1.5 or abs(p[-1 - i][1] - y0) > 1.5 for p in paths): + break + i += 1 + if i <= 0: + return [] + return paths[0][-i:] + + +def _best_label_polyline(paths: list[list[tuple[float, float]]], min_len: float) -> list[tuple[float, float]]: + """Prefer the fork/join spine; if it has a horizontal bar, label that.""" + if not paths: + return [] + if len(paths) == 1: + return paths[0] + prefix = _shared_prefix(paths) + if len(prefix) >= 2: + y = prefix[-1][1] + xs = [pt[0] for path in paths for pt in path if abs(pt[1] - y) < 2.5] + if xs and max(xs) - min(xs) >= min_len: + return _simplify_ortho(prefix + [(min(xs), y), (max(xs), y)]) + suffix = _shared_suffix(paths) + plen, slen = _poly_len(prefix), _poly_len(suffix) + if plen >= min_len and plen >= slen: + return prefix + if slen >= min_len: + return suffix + return max(paths, key=_poly_len) + + +def _segments( + pts: list[tuple[float, float]], +) -> list[tuple[tuple[float, float], tuple[float, float], str, float]]: + segs = [] + for a, b in zip(pts, pts[1:]): + dx, dy = b[0] - a[0], b[1] - a[1] + length = math.hypot(dx, dy) + orient = "H" if abs(dx) >= abs(dy) else "V" + segs.append((a, b, orient, length)) + return segs + + +def _pick_label_segment( + pts: list[tuple[float, float]], + min_len: float, +) -> tuple[tuple[float, float], tuple[float, float], str] | None: + segs = _segments(pts) + if not segs: + return None + + def score(sg: tuple) -> float: + _a, _b, orient, length = sg + sc = 40.0 if orient == "H" else 0.0 + sc -= abs(length - 110.0) * 0.12 + if length < min_len: + sc -= 50 + if length > 420: + sc -= 90 + return sc + + chosen = max(segs, key=score) + return chosen[0], chosen[1], chosen[2] + + +def _seg_attach( + a: tuple[float, float], + b: tuple[float, float], + t: float = 0.48, +) -> tuple[float, float]: + return a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t + + +def _closest_on_rect(r: _Rect, px: float, py: float) -> tuple[float, float]: + if r.contains_point(px, py): + dists = [ + (px - r.x, (r.x, py)), + (r.r - px, (r.r, py)), + (py - r.y, (px, r.y)), + (r.b - py, (px, r.b)), + ] + return min(dists, key=lambda t: t[0])[1] + return min(max(px, r.x), r.r), min(max(py, r.y), r.b) + + +def _clamp_rect(r: _Rect, work: _Rect) -> _Rect: + w = min(r.w, work.w) + h = min(r.h, work.h) + x = min(max(r.x, work.x), work.r - w) + y = min(max(r.y, work.y), work.b - h) + return _Rect(x, y, w, h) + + +def _label_block_size( + lines: list[str], + font: ImageFont.ImageFont, + size: float, + pad: float, +) -> tuple[float, float]: + widths = [_text_wh(font, ln)[0] for ln in lines] or [0] + line_h = size * 1.12 + return max(widths) + 2 * pad, line_h * len(lines) + 2 * pad + + +def _layout_arrow_label( + pts: list[tuple[float, float]], + label: str, + font: ImageFont.ImageFont, + size: float, + s: float, + work: _Rect, + obstacles: list[_Rect], + boxes: list[_Rect] | None = None, + shafts: list[_Rect] | None = None, +) -> tuple[_Rect, tuple[float, float], tuple[float, float], list[str], str] | None: + """Place a noun-phrase off the shaft (FIPS 183). Returns halo, attach, connect, lines, orient.""" + label = (label or "").strip() + if not label or len(pts) < 2: + return None + boxes = boxes or [] + shafts = shafts or [] + max_w = int(92 * s) + lines = _wrap(label, font, max_w)[:3] + pad = 2.8 * s + tw, th = _label_block_size(lines, font, size, pad) + picked = _pick_label_segment(pts, min_len=22 * s) + if picked is None: + return None + a, b, orient = picked + gap = 16 * s + best = None + best_score = -1e18 + best_attach = _seg_attach(a, b) + ts = (0.78, 0.68, 0.58, 0.48, 0.38, 0.28, 0.18, 0.88) + for t in ts: + attach = _seg_attach(a, b, t) + ax, ay = attach + if orient == "H": + raw = [ + ("above", ax - tw / 2, ay - gap - th), + ("below", ax - tw / 2, ay + gap), + ("aboveL", ax - tw, ay - gap - th), + ("aboveR", ax, ay - gap - th), + ("left", ax - gap - tw, ay - th / 2), + ("right", ax + gap, ay - th / 2), + ] + else: + prefer_left = ax > work.cx + side_order = ( + [("left", ax - gap - tw, ay - th / 2), ("right", ax + gap, ay - th / 2)] + if prefer_left + else [("right", ax + gap, ay - th / 2), ("left", ax - gap - tw, ay - th / 2)] + ) + raw = side_order + [ + ("above", ax - tw / 2, ay - gap - th), + ("below", ax - tw / 2, ay + gap), + ] + for i, (_side, x, y) in enumerate(raw): + halo = _clamp_rect(_Rect(x, y, tw, th), work) + score = 70.0 - i * 14 - abs(t - 0.5) * 12 + if halo.contains_point(ax, ay, pad=3 * s): + score -= 600 + box_hit = sum(halo.overlap_area(box) for box in boxes) + if box_hit > 1: + score -= 1e5 + box_hit + shaft_hit = sum(halo.overlap_area(sh) for sh in shafts) + if shaft_hit > (6 * s) * (6 * s): + score -= 2500 + shaft_hit / max(s * s, 1.0) * 4 + for obs in obstacles: + ov = halo.overlap_area(obs) + if ov > 0: + score -= ov / max(s * s, 1.0) * 10 + if halo.x < work.x + 2 or halo.r > work.r - 2: + score -= 25 + if halo.y < work.y + 2 or halo.b > work.b - 2: + score -= 25 + if score > best_score: + best_score = score + best = halo + best_attach = attach + if best is None or best_score < -9e4: + return None + connect = _closest_on_rect(best, best_attach[0], best_attach[1]) + return best, best_attach, connect, lines, orient + + +def _draw_squiggle( + scene: _Scene, + p0: tuple[float, float], + p1: tuple[float, float], + s: float, + lw: float, +) -> None: + """FIPS 183 squiggle: a short tapered sine, not a lightning bolt.""" + x0, y0 = p0 + x1, y1 = p1 + dx, dy = x1 - x0, y1 - y0 + dist = math.hypot(dx, dy) + if dist < 4 * s: + return + maxd = 15 * s + if dist > maxd: + ux, uy = dx / dist, dy / dist + x1, y1 = x0 + ux * maxd, y0 + uy * maxd + dx, dy, dist = x1 - x0, y1 - y0, maxd + ux, uy = dx / dist, dy / dist + nx, ny = -uy, ux + amp = 2.3 * s + n = 16 + pts = [] + for i in range(n + 1): + t = i / n + wave = math.sin(t * math.pi * 4.0) * math.sin(t * math.pi) + pts.append((x0 + ux * dist * t + nx * amp * wave, y0 + uy * dist * t + ny * amp * wave)) + scene.polyline(pts, width=max(lw * 0.8, 1.0)) + + +def _draw_label_block(scene: _Scene, halo: _Rect, lines: list[str], size: float) -> None: + scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) + line_h = size * 1.12 + total = line_h * len(lines) + y0 = halo.cy - total / 2 + size * 0.36 + for i, ln in enumerate(lines): + scene.text(halo.cx, y0 + i * line_h, ln, size, anchor="mm") + + +def _draw_arrow_label( + scene: _Scene, + pts: list[tuple[float, float]], + label: str, + s: float, + lw: float, + fonts: dict, + work: _Rect, + occupied: list[_Rect], + boxes: list[_Rect], + shafts: list[_Rect] | None = None, +) -> _Rect | None: + font = _load_font(int(fonts["arrow"]), False) + placed = _layout_arrow_label( + pts, label, font, fonts["arrow"], s, work, occupied, boxes, shafts, + ) + if placed is None: + return None + halo, attach, connect, lines, _orient = placed + dist = math.hypot(connect[0] - attach[0], connect[1] - attach[1]) + if dist > 18 * s: + _draw_squiggle(scene, connect, attach, s, lw) + _draw_label_block(scene, halo, lines, fonts["arrow"]) + occupied.append(halo.inflate(6 * s)) + return halo + + +def _icom_border_point( + arrow: Idef0Arrow, + geoms: dict[str, _BoxGeom], + work: _Rect, +) -> tuple[Idef0End, tuple[float, float]] | None: + """Where the ICOM arrow meets the inner work frame.""" + bound = next((e for e in arrow.sources + arrow.targets if e.box is None), None) + if bound is None: + return None + mates = arrow.targets if bound in arrow.sources else arrow.sources + xs, ys = [], [] + for m in mates: + if not m.box or m.box not in geoms: + continue + g = geoms[m.box] + idx, cnt = _slot_index(g, arrow, m) + px, py = _port_point(g, m.side, idx, cnt) + xs.append(px) + ys.append(py) + if bound.side == "C": + x = (min(xs) + max(xs)) / 2 if xs else work.cx + return bound, (x, work.y) + if bound.side == "M": + x = (min(xs) + max(xs)) / 2 if xs else work.cx + return bound, (x, work.b) + if bound.side == "I": + y = (min(ys) + max(ys)) / 2 if ys else work.cy + return bound, (work.x, y) + if bound.side in ("O", "CALL"): + y = (min(ys) + max(ys)) / 2 if ys else work.cy + return bound, (work.r if bound.side == "O" else work.cx, work.b if bound.side == "CALL" else y) + return bound, _end_point(bound, arrow, geoms, work) + + +def _draw_icom_code( + scene: _Scene, + arrow: Idef0Arrow, + geoms: dict[str, _BoxGeom], + work: _Rect, + s: float, + fonts: dict, + occupied: list[_Rect], +) -> None: + if not arrow.icom: + return + hit = _icom_border_point(arrow, geoms, work) + if hit is None: + return + bound, pt = hit + font = _load_font(int(fonts["id"]), True) + tw, th = _text_wh(font, arrow.icom) + pad = 1.6 * s + bx, by = pt + if bound.side == "I": + halo = _Rect(work.x + 2 * s, by - th - 5 * s, tw + 2 * pad, th + 2 * pad) + elif bound.side == "O": + halo = _Rect(work.r - tw - 2 * pad - 2 * s, by - th - 5 * s, tw + 2 * pad, th + 2 * pad) + elif bound.side == "C": + halo = _Rect(bx + 5 * s, work.y + 2 * s, tw + 2 * pad, th + 2 * pad) + else: + halo = _Rect(bx + 5 * s, work.b - th - 2 * pad - 2 * s, tw + 2 * pad, th + 2 * pad) + halo = _clamp_rect(halo, work) + scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) + scene.text(halo.cx, halo.cy, arrow.icom, fonts["id"], bold=True, anchor="mm") + occupied.append(halo.inflate(3 * s)) + + +def _append_tunnel( + tunnels: list[tuple[tuple[float, float], bool]], + end: Idef0End, + pt: tuple[float, float], + s: float, +) -> None: + if end.tunnel: + tunnels.append((_offset(pt, _end_stub_dir(end), 10 * s), end.side in ("I", "O"))) + + +def _seg_key(a: tuple[float, float], b: tuple[float, float]) -> tuple: + pa = (round(a[0], 1), round(a[1], 1)) + pb = (round(b[0], 1), round(b[1], 1)) + return (pa, pb) if pa <= pb else (pb, pa) + + +def _draw_arrow( + scene: _Scene, + arrow: Idef0Arrow, + geoms: dict[str, _BoxGeom], + work: _Rect, + s: float, + lw: float, + order: dict[str, int], + ch: _Channels, +) -> list[list[tuple[float, float]]]: + srcs = arrow.sources + tgts = arrow.targets + paths: list[list[tuple[float, float]]] = [] + heads: list[tuple[tuple[float, float], str]] = [] + tunnels: list[tuple[tuple[float, float], bool]] = [] + + if len(srcs) == 1 and len(tgts) >= 1: + tgt_pts = [_end_point(t, arrow, geoms, work) for t in tgts] + p1 = _end_point(srcs[0], arrow, geoms, work, mate=tgts[0] if len(tgts) == 1 else None) + spine_x = None + lane_y = None + if srcs[0].box is None and srcs[0].side == "C" and len(tgts) > 1: + xs = [p[0] for p in tgt_pts] + spine_x = (min(xs) + max(xs)) / 2 + lane_y = ch.c_lanes.get(id(arrow), work.y + 18 * s) + p1 = (spine_x, work.y) + for x in xs: + if all(abs(x - u) >= ch.min_gap * 0.45 for u in ch.used_v): + ch.used_v.append(x) + elif srcs[0].box is None and srcs[0].side == "M" and len(tgts) > 1: + xs = [p[0] for p in tgt_pts] + spine_x = (min(xs) + max(xs)) / 2 + lane_y = ch.m_lanes.get(id(arrow), work.b - 18 * s) + p1 = (spine_x, work.b) + for x in xs: + if all(abs(x - u) >= ch.min_gap * 0.45 for u in ch.used_v): + ch.used_v.append(x) + mixed_oc = ( + srcs[0].box + and srcs[0].side == "O" + and any(t.box is None and t.side == "O" for t in tgts) + and any(t.box and t.side == "C" for t in tgts) + ) + _append_tunnel(tunnels, srcs[0], p1, s) + for t, p2 in zip(tgts, tgt_pts): + if mixed_oc and t.box and t.side == "C": + drop_x = max(p2[0], p1[0] + ch.stub) + pts = _simplify_ortho([p1, (drop_x, p1[1]), p2]) + elif mixed_oc and t.box is None and t.side == "O": + pts = _simplify_ortho([p1, (work.r, p1[1])]) + else: + pts = _route_icom( + srcs[0], t, p1, p2, ch, geoms, order, arrow, + lane_y=lane_y, spine_x=spine_x, + ) + paths.append(pts) + heads.append((p2, _dir_for_into(t.side) if t.box else _dir_for_out(t.side))) + _append_tunnel(tunnels, t, p2, s) + elif len(tgts) == 1 and len(srcs) > 1: + p2 = _end_point(tgts[0], arrow, geoms, work) + for src in srcs: + p1 = _end_point(src, arrow, geoms, work, mate=tgts[0]) + pts = _route_icom(src, tgts[0], p1, p2, ch, geoms, order, arrow) + paths.append(pts) + _append_tunnel(tunnels, src, p1, s) + heads.append((p2, _dir_for_into(tgts[0].side) if tgts[0].box else _dir_for_out(tgts[0].side))) + _append_tunnel(tunnels, tgts[0], p2, s) + else: + return [] + + seen: set[tuple] = set() + for pts in paths: + for a, b in zip(pts, pts[1:]): + key = _seg_key(a, b) + if key in seen: + continue + seen.add(key) + scene.line(a[0], a[1], b[0], b[1], lw) + for tip, direction in heads: + _arrowhead(scene, tip, direction, size=8 * s, lw=lw) + for pt, horiz in tunnels: + _draw_tunnel(scene, pt, horiz, s, lw) + return paths + + +def _draw_purpose(scene: _Scene, d: Idef0Diagram, work: _Rect, s: float, fonts: dict) -> _Rect | None: + """Kept for callers; kit form does not put Purpose/Viewpoint in the work area.""" + bits = [] + if d.purpose: + bits.append(f"Purpose: {d.purpose}") + if d.viewpoint: + bits.append(f"Viewpoint: {d.viewpoint}") + if not bits: + return None + font = _load_font(int(fonts["small"]), False) + lines: list[str] = [] + for text in bits: + lines.extend(_wrap(text, font, int(work.w * 0.40))) + if not lines: + return None + pad = 3 * s + tw, th = _label_block_size(lines, font, fonts["small"], pad) + halo = _Rect(work.x + 4 * s, work.b - th - 4 * s, tw, th) + scene.rect(halo.x, halo.y, halo.w, halo.h, width=0, fill="#ffffff", outline=None) + line_h = fonts["small"] * 1.15 + y0 = halo.y + pad + fonts["small"] * 0.2 + for i, ln in enumerate(lines): + scene.text(halo.x + pad, y0 + i * line_h, ln, fonts["small"], anchor="lt") + return halo.inflate(4 * s) + + +def render_idef0( + diagram: Idef0Diagram, + *, + scale: float = 2.0, + want_svg: bool = True, +) -> tuple[bytes, str | None]: + """Return (png_bytes, svg_text_or_None).""" + s = max(float(scale), 1.0) + n = max(1, len(diagram.boxes)) + if n == 1: + W, H = int(1200 * s), int(850 * s) + elif n <= 3: + W, H = int(1400 * s), int(920 * s) + else: + W, H = int(1860 * s), int(1280 * s) + scene = _Scene(W, H) + lw = max(1.0, 1.0 * s) + fonts = { + "lab": 9 * s, + "val": 11 * s, + "small": 9.5 * s, + "box": 13 * s, + "id": 10 * s, + "arrow": 12 * s, + "node": 20 * s, + "title": 16 * s, + "top": 22 * s, + } + outer = _Rect(8 * s, 8 * s, W - 16 * s, H - 16 * s) + form = (diagram.form or "kit").strip().lower() + if form == "plain": + scene.rect(outer.x, outer.y, outer.w, outer.h, width=lw) + work = _Rect(outer.x + 20 * s, outer.y + 20 * s, outer.w - 40 * s, outer.h - 40 * s) + else: + work = _draw_kit_form(scene, diagram, outer, s, fonts) + + geoms = _layout_boxes(diagram, work, s) + _assign_slots(diagram, geoms) + order = _box_order(diagram) + ch = _make_channels(diagram, geoms, work, s) + occupied: list[_Rect] = [g.rect.inflate(12 * s) for g in geoms.values()] + boxes = [g.rect for g in geoms.values()] + + labeled: list[tuple[Idef0Arrow, list[list[tuple[float, float]]]]] = [] + all_paths: list[list[tuple[float, float]]] = [] + for arrow in diagram.arrows: + paths = _draw_arrow(scene, arrow, geoms, work, s, lw, order, ch) + labeled.append((arrow, paths)) + all_paths.extend(paths) + shafts = [ + _seg_obstacle(a, b, 6 * s) + for pts in all_paths + for a, b in zip(pts, pts[1:]) + ] + # ICOM codes at the frame first so labels keep off them. + for arrow, _paths in labeled: + _draw_icom_code(scene, arrow, geoms, work, s, fonts, occupied) + for arrow, paths in labeled: + if arrow.label and paths: + pts = _best_label_polyline(paths, min_len=24 * s) + _draw_arrow_label( + scene, pts, arrow.label, s, lw, fonts, work, occupied, boxes, shafts, + ) + for geom in geoms.values(): + _draw_box(scene, geom, s, lw, fonts) + + png = scene.to_png() + svg = scene.to_svg() if want_svg else None + return png, svg + + +def write_idef0_outputs( + source: str, + out_png: Path, + out_svg: Path | None, + *, + scale: float = 2.0, +) -> bool: + """Parse + render. Raises Idef0ParseError on bad DSL.""" + diagram = parse_idef0(source) + png, svg = render_idef0(diagram, scale=scale, want_svg=out_svg is not None) + out_png.parent.mkdir(parents=True, exist_ok=True) + out_png.write_bytes(png) + if out_svg is not None and svg is not None: + out_svg.write_text(svg, encoding="utf-8") + _log.info("IDEF0 ok → %s", out_png.name) + return True + + +# Copy-paste sample for the classic A-0 context diagram. +SAMPLE_CONTEXT = """\ +title Распорядиться товаром +node A-0 +number 1 +author Кинзябулатов Рамиль +project Разработка универсальной модели торгового предприятия +date 08.02.2022 +rev 31.03.2022 +status PUBLICATION +context TOP + +[A0] Распорядиться товаром +<- Спрос +^ Нормативная документация +-> Товар +v Персонал +""" + +# A0 child of the context: 3-box Kinzyabulatov density (fork, O→C, ICOM at border). +SAMPLE_DECOMPOSITION = """\ +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] +""" diff --git a/md2gost/page_geometry.py b/md2gost/page_geometry.py index f7ab65c..f86ca39 100644 --- a/md2gost/page_geometry.py +++ b/md2gost/page_geometry.py @@ -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 diff --git a/md2gost/pipeline.py b/md2gost/pipeline.py index 62bb9b3..82cfbca 100644 --- a/md2gost/pipeline.py +++ b/md2gost/pipeline.py @@ -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 diff --git a/md2gost/profiles.py b/md2gost/profiles.py index 7c651c6..9b6de6c 100644 --- a/md2gost/profiles.py +++ b/md2gost/profiles.py @@ -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`{3,}|~{3,})(?P[^\n]*)\n" r"(?P[\s\S]*?)" diff --git a/md2gost/renderable/paragraph.py b/md2gost/renderable/paragraph.py index 3cedcde..036071f 100644 --- a/md2gost/renderable/paragraph.py +++ b/md2gost/renderable/paragraph.py @@ -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 diff --git a/md2gost/renderable/table.py b/md2gost/renderable/table.py index 51c645d..325ce3d 100644 --- a/md2gost/renderable/table.py +++ b/md2gost/renderable/table.py @@ -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) diff --git a/md2gost/renderer.py b/md2gost/renderer.py index a47c570..45f40de 100644 --- a/md2gost/renderer.py +++ b/md2gost/renderer.py @@ -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() diff --git a/md2gost/title_page.py b/md2gost/title_page.py new file mode 100644 index 0000000..ba4cc22 --- /dev/null +++ b/md2gost/title_page.py @@ -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) diff --git a/md2gost/user_profile.py b/md2gost/user_profile.py new file mode 100644 index 0000000..a81686d --- /dev/null +++ b/md2gost/user_profile.py @@ -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 diff --git a/md2gost/word_fix.py b/md2gost/word_fix.py index e52f669..5360861 100644 --- a/md2gost/word_fix.py +++ b/md2gost/word_fix.py @@ -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). diff --git a/prompts/README.md b/prompts/README.md index 63b2155..b50e47c 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -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 / сообщение агенту; diff --git a/prompts/emulate-student.md b/prompts/emulate-student.md new file mode 100644 index 0000000..beed83c --- /dev/null +++ b/prompts/emulate-student.md @@ -0,0 +1,57 @@ +# Промпт: человечный учебный текст (не отчёт ИИ) + +Скопируйте **весь** текст ниже **после** базового промпта md2gost (`generate-md` / `generate-mirea-report` / `generate-pis-custom-report`) или вторым сообщением. + +Тема, объект, факты — из следующего сообщения пользователя. Этот блок **не привязан** к конкретному предмету: он задаёт только голос. + +--- + +Ты пишешь учебный текст так, чтобы он читался как работа студента-человека, а не как отчёт модели о выполненных командах. + +## Главное + +Пиши **про предмет работы**. Не пиши про то, что задание сделано, пункт закрыт или следующий этап курса уже «зафиксирован». + +Читатель должен узнать объект / тему. Он не должен видеть пайплайн промпта, чеклист ТЗ и разговор с невидимым проверяющим. + +## Как строить кусок текста + +1. Тезис по сути. +2. Опора: таблица, рисунок, число, факт. +3. Короткий вывод **про сам предмет** (1–3 предложения). + +Не начинай раздел преамбулой «зачем этот блок существует». Запрещены обороты вроде: «X является исходным условием…», «без X невозможны…», «что позволяет связать предмет…», «а не подменять…». Сразу давай содержание. + +Факт имеет право просто быть. Не пристёгивай каждое предложение к следующей главе, «предмету исследования», автоматизации, пользе для проверяющего или «прикладной ценности полученной характеристики» — если пользователь сам не сделал это темой. + +## Введение и заключение + +**Введение:** цель → объект / тема → почему взято → методы. Без оглавления остальных работ курса. Без фраз вроде «роботизация закрепляется за…», «предметом последующих работ принимается…», «целевая архитектура должна…». + +**Заключение:** сожми предмет — что это, как устроено, что важно. +Запрещены глаголы сдачи работы: «сформирована характеристика», «установлены», «определён», «построена», «зафиксирован предмет». +Запрещён план следующих лаб вместо вывода по теме. + +## Таблицы + +Таблица описывает предмет. Последняя колонка — смысл в теме (содержание, влияние, назначение в деятельности). +Не делай колонки: «значение для объекта исследования», «роль в предмете исследования», «назначение для характеристики», «как пригодится дальше / для автоматизации». + +## Тон и язык + +- Безличность нормальна: «выбрано», «представлены», «систематизированы». Без «я» / «мы». +- Ссылки по делу, не в каждом абзаце. Методички «как писать работу» в содержательную часть не тащи. +- Допустима лёгкая неровность: не каждый абзац одной длины, не каждое предложение «отрабатывает ценность». +- Не заканчивай абзацы мета-моралью: «следовательно, целевое решение должно…», «полученная характеристика задаёт основу…», «прикладное значение работы состоит в…» — если это не запрошенная тема. + +## Чего избегать (запахи отчёта ИИ) + +- Текст отвечает на вопрос «что я сделал по пунктам», а не «что это за объект / тема». +- Каждый абзац продаёт себя проверяющему. +- Идеальная симметрия, плотный ряд умных ярлыков подряд, консультантский лоск без живого факта. +- Спор с невидимым рецензентом внутри основного текста. +- Комментарии к собственному методу вместо содержания. + +## Выход + +Соблюдай формат и диалект из базового промпта md2gost. Этот блок меняет только **голос и логику изложения**, не синтаксис разметки. diff --git a/prompts/generate-md.md b/prompts/generate-md.md index fae33f3..3e85017 100644 --- a/prompts/generate-md.md +++ b/prompts/generate-md.md @@ -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- `` / `` ``` `` | Только макросы схемы (C4, BPMN, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама | +| `` ```uml- `` / `` ``` `` | Только макросы схемы (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. diff --git a/prompts/generate-mirea-report.md b/prompts/generate-mirea-report.md index 8218515..593fd8d 100644 --- a/prompts/generate-mirea-report.md +++ b/prompts/generate-mirea-report.md @@ -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) -``` ~~~ ### Листинг diff --git a/prompts/generate-pis-custom-report.md b/prompts/generate-pis-custom-report.md index f2c5db8..ed6e3e2 100644 --- a/prompts/generate-pis-custom-report.md +++ b/prompts/generate-pis-custom-report.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index 418224f..4e5f0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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" diff --git a/scripts/_gen_bpmn_sprites.py b/scripts/_gen_bpmn_sprites.py deleted file mode 100644 index b535935..0000000 --- a/scripts/_gen_bpmn_sprites.py +++ /dev/null @@ -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() diff --git a/scripts/fetch_graphviz.py b/scripts/fetch_graphviz.py new file mode 100644 index 0000000..9748c18 --- /dev/null +++ b/scripts/fetch_graphviz.py @@ -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()) diff --git a/scripts/pyi_bundle.py b/scripts/pyi_bundle.py new file mode 100644 index 0000000..739c985 --- /dev/null +++ b/scripts/pyi_bundle.py @@ -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 diff --git a/scripts/pyi_rth_tcltk.py b/scripts/pyi_rth_tcltk.py new file mode 100644 index 0000000..3e54c0d --- /dev/null +++ b/scripts/pyi_rth_tcltk.py @@ -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 diff --git a/tests/_idef0_preview.png b/tests/_idef0_preview.png new file mode 100644 index 0000000..4c37536 Binary files /dev/null and b/tests/_idef0_preview.png differ diff --git a/tests/_idef0_preview.svg b/tests/_idef0_preview.svg new file mode 100644 index 0000000..ddde844 --- /dev/null +++ b/tests/_idef0_preview.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + +USED AT: +AUTHOR: +Кинзябулатов Рамиль +PROJECT: +Разработка универсальной модели торгового предприятия +NOTES: +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +DATE: +08.02.2022 +REV: +31.03.2022 + +WORKING + +DRAFT + +RECOMMENDED + +PUBLICATION +READER + +DATE +CONTEXT: +TOP + + +NODE: +A-0 +TITLE: +Распорядиться товаром +NUMBER: +1 + +1 + + + + + + + + + +Спрос + +Нормативная +документация + +Товар + +Персонал + + + +Распорядиться товаром +A0 + diff --git a/tests/_idef0_preview_a0.png b/tests/_idef0_preview_a0.png new file mode 100644 index 0000000..c1b52fc Binary files /dev/null and b/tests/_idef0_preview_a0.png differ diff --git a/tests/_idef0_preview_a0.svg b/tests/_idef0_preview_a0.svg new file mode 100644 index 0000000..aa2c43f --- /dev/null +++ b/tests/_idef0_preview_a0.svg @@ -0,0 +1,166 @@ + + + + + + + + + + + + +USED AT: +AUTHOR: +Кинзябулатов Рамиль +PROJECT: +Разработка универсальной модели торгового предприятия +NOTES: +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +DATE: +08.02.2022 +REV: +31.03.2022 + +WORKING + +DRAFT + +RECOMMENDED + +PUBLICATION +READER + +DATE +CONTEXT: + + +A-0 + + +NODE: +A0 +TITLE: +Распорядиться товаром +NUMBER: +2 + +2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +I1 + +I2 + +C1 + +C2 + +C3 + +O1 + +O2 + +M1 + +M2 + +Спрос + +Предложения +поставщиков + +Нормативная +документация + +Ассортиментная +политика + +Условия +поставки + +план закупок + +график +поставок + +товар к отгрузке + +Товар + +Акт отгрузки + +Персонал + +Склад + + + +Принять и оценить спрос +A1 + + + +Закупить и хранить +A2 + + + +Отгрузить товар +A3 + diff --git a/tests/test_dfd.py b/tests/test_dfd.py new file mode 100644 index 0000000..342cf04 --- /dev/null +++ b/tests/test_dfd.py @@ -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"" + ) + 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( + "Клиент", + 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" diff --git a/tests/test_diagram_renderer.py b/tests/test_diagram_renderer.py index 9c17e59..3d5ac15 100644 --- a/tests/test_diagram_renderer.py +++ b/tests/test_diagram_renderer.py @@ -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 " 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") diff --git a/tests/test_doc_metadata.py b/tests/test_doc_metadata.py new file mode 100644 index 0000000..2acfdfc --- /dev/null +++ b/tests/test_doc_metadata.py @@ -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 == "Тема профиля" diff --git a/tests/test_idef0.py b/tests/test_idef0.py new file mode 100644 index 0000000..88f0bf0 --- /dev/null +++ b/tests/test_idef0.py @@ -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" diff --git a/tests/test_landscape.py b/tests/test_landscape.py index 9f4137e..1c8fc56 100644 --- a/tests/test_landscape.py +++ b/tests/test_landscape.py @@ -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")) diff --git a/tests/test_mirea_tz.py b/tests/test_mirea_tz.py index 9666395..fd244b8 100644 --- a/tests/test_mirea_tz.py +++ b/tests/test_mirea_tz.py @@ -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) diff --git a/tests/test_page_geometry.py b/tests/test_page_geometry.py index 89f2402..33364e0 100644 --- a/tests/test_page_geometry.py +++ b/tests/test_page_geometry.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index b6c0e32..65511c7 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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) diff --git a/tests/test_title_page.py b/tests/test_title_page.py new file mode 100644 index 0000000..7354c66 --- /dev/null +++ b/tests/test_title_page.py @@ -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 diff --git a/tests/test_word2md.py b/tests/test_word2md.py new file mode 100644 index 0000000..317b15a --- /dev/null +++ b/tests/test_word2md.py @@ -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 diff --git a/tests/test_word_fix.py b/tests/test_word_fix.py index adfce33..cd7794f 100644 --- a/tests/test_word_fix.py +++ b/tests/test_word_fix.py @@ -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(): diff --git a/todo.txt b/todo.txt index ed00b42..bc3bbe5 100644 --- a/todo.txt +++ b/todo.txt @@ -1,2 +1,2 @@ -- Ожидаем внедрение BPMN в mermaid -- Оценки с текстом через сам Word а не предугадываание \ No newline at end of file +- Оценки с текстом через сам Word а не предугадываание +- Проверка «текст могут не принять»: эвристика отчёта ИИ (пишет про сдачу ТЗ, а не про предмет). `--check` + отдельный разбор по абзацам (`--check-voice` / кнопка в GUI). Идеи правил — сравнение живой практики и сгенерированного отчёта: телеология «последующие работы», преамбулы разделов, заключение-чеклист, колонки «значение для объекта», плотность ссылок, консультантский лоск. diff --git a/uv.lock b/uv.lock index 370420d..3c5fe30 100644 --- a/uv.lock +++ b/uv.lock @@ -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" diff --git a/word2md/README.md b/word2md/README.md new file mode 100644 index 0000000..1bada6a --- /dev/null +++ b/word2md/README.md @@ -0,0 +1,9 @@ +# word2md + +Импорт **DOCX → Markdown** в диалекте md2gost. + +```bash +python -m word2md report.docx -o report.md +``` + +Документация: [`docs/word2md.md`](../docs/word2md.md). diff --git a/word2md/__init__.py b/word2md/__init__.py new file mode 100644 index 0000000..4b39c33 --- /dev/null +++ b/word2md/__init__.py @@ -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__"] diff --git a/word2md/__main__.py b/word2md/__main__.py new file mode 100644 index 0000000..ffbecf3 --- /dev/null +++ b/word2md/__main__.py @@ -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 (по умолчанию: .md рядом с docx)", + ) + p.add_argument( + "--media-dir", + default=None, + help="Каталог для картинок (по умолчанию: _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() diff --git a/word2md/blocks.py b/word2md/blocks.py new file mode 100644 index 0000000..16e7df7 --- /dev/null +++ b/word2md/blocks.py @@ -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 +) diff --git a/word2md/captions.py b/word2md/captions.py new file mode 100644 index 0000000..53ace7b --- /dev/null +++ b/word2md/captions.py @@ -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[\d.]+)\s*(?:[—–\-]\s*(?P.+))?$", + 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 diff --git a/word2md/classify.py b/word2md/classify.py new file mode 100644 index 0000000..a29a8da --- /dev/null +++ b/word2md/classify.py @@ -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 diff --git a/word2md/emit.py b/word2md/emit.py new file mode 100644 index 0000000..f446699 --- /dev/null +++ b/word2md/emit.py @@ -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" diff --git a/word2md/media.py b/word2md/media.py new file mode 100644 index 0000000..46ceac6 --- /dev/null +++ b/word2md/media.py @@ -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 diff --git a/word2md/omml.py b/word2md/omml.py new file mode 100644 index 0000000..bcd0ed0 --- /dev/null +++ b/word2md/omml.py @@ -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)) diff --git a/word2md/pipeline.py b/word2md/pipeline.py new file mode 100644 index 0000000..ef83601 --- /dev/null +++ b/word2md/pipeline.py @@ -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, + ) diff --git a/word2md/refs.py b/word2md/refs.py new file mode 100644 index 0000000..e3fea0b --- /dev/null +++ b/word2md/refs.py @@ -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 diff --git a/word2md/tables.py b/word2md/tables.py new file mode 100644 index 0000000..1101bca --- /dev/null +++ b/word2md/tables.py @@ -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 diff --git a/word2md/text.py b/word2md/text.py new file mode 100644 index 0000000..bd134fc --- /dev/null +++ b/word2md/text.py @@ -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", "") diff --git a/word2md/walker.py b/word2md/walker.py new file mode 100644 index 0000000..048a857 --- /dev/null +++ b/word2md/walker.py @@ -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