@@ -0,0 +1,2 @@
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+71
-15
@@ -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 без правки кода.
|
||||
|
||||
@@ -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).
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
-3468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,988 @@
|
||||
УДК 378
|
||||
ББК 65
|
||||
К 43
|
||||
Кириллина Ю.В. Требования к оформлению отчетов по практикам, курсовых работ и
|
||||
ВКР бакалавров и магистров [Электронный ресурс]: Методические указания / Кириллина
|
||||
Ю.В., Лагунова А.Д., Бергер Е.Г. — М.: МИРЭА – Российский технологический
|
||||
университет, 2022. — 1 электрон. опт. диск (CD-ROM)
|
||||
Методические указания знакомят студентов с требованиями по оформлению учебных
|
||||
работ, таких как курсовые работы, отчеты по практикам, выпускная квалификационная
|
||||
работа. Использование методических указаний позволит студентам грамотно оформлять
|
||||
учебные работы, которые они выполняют согласно учебному плану в процессе обучения.
|
||||
Данные методические указания основаны на ГОСТ 7.32 – 2017 Система стандартов по
|
||||
информации, б иблиотечному и издательскому делу. Отчет о научно -исследовательской
|
||||
работе. Структура и правила и оформления.
|
||||
Предназначено для студентов по направлени ям подготовки 09.03.03 Прикладная
|
||||
информатика, 09.0 4.03 Прикладная информатика , а также может быть использовано
|
||||
студентами других направлений подготовки для получения навыков грамотного оформления
|
||||
учебных работ.
|
||||
Методические указания издаются в авторской редакции.
|
||||
Авторский коллектив: Кириллина Юлия Владимировна, Лагунова Анна Дмитриевна, Бергер
|
||||
Екатерина Геннадьевна
|
||||
Рецензенты:
|
||||
Одинцова Мария Александровна , к.т.н., доцент, доцент кафедры «Предметно -
|
||||
ориентированные информационные системы» Института кибербезопасности и
|
||||
цифровых технологий РТУ МИРЭА
|
||||
Системные требования:
|
||||
Наличие операционной системы Windows, поддерживаемой производителем.
|
||||
Наличие свободного места в оперативной памяти не менее 128 Мб.
|
||||
Наличие свободного места в памяти постоянного хранения (на жестком диске) не менее 30 Мб.
|
||||
Наличие интерфейса ввода информации.
|
||||
Дополнительные программные средства: программа для чтения pdf-файлов (Adobe Reader).
|
||||
Подписано к использованию по решению Редакционно-издательского совета
|
||||
МИРЭА — Российский технологический университет.
|
||||
Обьем: 5.39 мб
|
||||
Тираж: 10
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
© Кириллина Ю.В., Лагунова А.Д.,
|
||||
Бергер Е.Г., 2022
|
||||
© МИРЭА – Российский
|
||||
технологический университет, 2022
|
||||
3
|
||||
СОДЕРЖАНИЕ
|
||||
|
||||
ПРЕДИСЛОВИЕ ...................................................................................................... 4
|
||||
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ .................................................................... 5
|
||||
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА ........................................................ 12
|
||||
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В УЧЕБНОЙ РАБОТЕ ....... 17
|
||||
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В УЧЕБНОЙ
|
||||
РАБОТЕ .................................................................................................................. 27
|
||||
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ В
|
||||
УЧЕБНОЙ РАБОТЕ ............................................................................................... 30
|
||||
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ .............................. 34
|
||||
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ ...................................................... 37
|
||||
ПРИЛОЖЕНИЕ А .................................................................................................. 38
|
||||
ПРИЛОЖЕНИЕ Б................................................................................................... 39
|
||||
ПРИЛОЖЕНИЕ В .................................................................................................. 41
|
||||
ПРИЛОЖЕНИЕ Г................................................................................................... 42
|
||||
ПРИЛОЖЕНИЕ Д .................................................................................................. 44
|
||||
ПРИЛОЖЕНИЕ Е .................................................................................................. 45
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4
|
||||
ПРЕДИСЛОВИЕ
|
||||
|
||||
В процессе обучения студентам направлений подготовки 09.03.03, 09.04.03
|
||||
Прикладная информатика необходимо писать и оформлять курсовые работы, от-
|
||||
четы по различным видам и типам практик, а также выпускную квалификацион-
|
||||
ную работу (далее по тексту — учебные работы). Данные методические указания
|
||||
предназначены для студентов вышеуказанных направлений подготовки.
|
||||
Целью данных методических указаний является поддержка грамотного
|
||||
оформления текстовых документов студентами.
|
||||
Задачами методических указаний являются:
|
||||
• ознакомление студентов с общей структурой учебных работ, таких как
|
||||
курсовая работа, отчет по практике, выпускная квалификационная ра-
|
||||
бота (ВКР);
|
||||
• ознакомление студентов с правилами оформления учебных работ.
|
||||
Основой для создания методических указаний является ГОСТ 7.32 — 2017
|
||||
Система стандартов по информации, библиотечному и издательскому делу.
|
||||
Отчет о научно-исследовательской работе. Структура и правила и оформления.
|
||||
Методические указания составлены в соответствии с:
|
||||
• СМКО МИРЭА 7.5.1/03.П.67- 18 Положение о выпускной квалифика-
|
||||
ционной работе студентов, обучающихся по образовательным про-
|
||||
граммам подготовки бакалавров;
|
||||
• СМКО МИРЭА 7.5.1/03.П.68- 18 Положение о выпускной квалифика-
|
||||
ционной работе студентов, обучающихся по образовательным про-
|
||||
граммам подготовки магистров;
|
||||
• СМКО МИРЭА 7.5.1/04.И.05- 18 Инструкция по организации и прове-
|
||||
дению курсового проектирования.
|
||||
|
||||
|
||||
5
|
||||
1. СТРУКТУРА УЧЕБНОЙ РАБОТЫ
|
||||
|
||||
Учебная работа студента должна иметь определенную структуру.
|
||||
Структура конкретной учебной работы регламентируется методическими
|
||||
указаниями по написанию курсовой работы, методическими указаниями по
|
||||
написанию отчета по практике, методическими указаниями по написанию ВКР,
|
||||
которые разрабатываются преподавателями выпускающей кафедры и издаются
|
||||
редакционно-издательским отделом университета. В се изданные методически е
|
||||
указания выпускающей кафедры студент может найти на сайте университет а в
|
||||
разделе Студентам, далее Библиотека.
|
||||
В тоже время общая структура учебной работы включает в себя:
|
||||
• ый лист;
|
||||
• ие;
|
||||
• ие;
|
||||
• ие;
|
||||
• ием;
|
||||
• ие;
|
||||
• иков;
|
||||
• ия.
|
||||
Титульный лист учебной работы имеет утвержденную форму , выдается
|
||||
преподавателем, который руководит курсовой работой/практикой/ВКР.
|
||||
На титульном листе в обязательном порядке проставляется подпись
|
||||
руководителя учебной работы и студента. В случае, если в кач естве учебной
|
||||
работы выступает курсовая работа, то на не м необходима подпись второго
|
||||
преподавателя, участвующего в процедуре защиты курсовой работы. В слу чае,
|
||||
если в качестве учебной работы выступает ВКР, то на титульном листе
|
||||
необходима подпись заведующего выпускающей кафедрой. Примеры титульных
|
||||
листов различных учебных работ приведены в Приложении А .
|
||||
му и выдается
|
||||
преподавателем, который руководит курсовой работой/практикой/ВКР.
|
||||
аботы) — т, в
|
||||
яется абота). ие подписывается
|
||||
аботы. совых
|
||||
абот, а т а к ж е о т ч е т о в п о п р а к т и к е ие обязательно ждается
|
||||
ой. абот
|
||||
дополнительно согласуется с директор ом института. Факт принятия
|
||||
студентом задания к работе подтверждается подписью студента на задании.
|
||||
6
|
||||
Кроме подписей в задании должны быть указаны дата утверждения задания
|
||||
заведующим кафедрой, дата выдачи задания, дата принятия задания к
|
||||
исполнению. Примеры заданий различных учебных работ приведены в
|
||||
Приложении Б.
|
||||
афической
|
||||
частей в учебной работе устанавливаются методическими указаниями по
|
||||
написанию курсовой работы, методическими указаниями по написанию отчета
|
||||
по практике, методическими указаниями по написанию ВКР .
|
||||
авливается методическими
|
||||
указаниями по написанию курсовой работы, методическими указаниями по
|
||||
написанию отчета по практике, методическими указаниями по написанию ВКР ,
|
||||
а также положениями, приведенными в предисловии данного пособия.
|
||||
Текст основной части учебной работы еобходимости а азделы,
|
||||
азделы, пункты . кты имеют заголовки, четко и
|
||||
ктов.
|
||||
а листе, который расположен ого листа и
|
||||
ия, азделов,
|
||||
иц. иц
|
||||
а быть
|
||||
ая.
|
||||
ия заголовков:
|
||||
• аздела — я;
|
||||
• аздела — я;
|
||||
• ктов — ей.
|
||||
та,
|
||||
ые с абзацевого отступа.
|
||||
аков
|
||||
ия пишется заголовок.
|
||||
а б ы т ь в
|
||||
ﮦ ﮦ аздела,
|
||||
ых точками.
|
||||
Параметры оформления заголовков в учебной работе представлены в табл.
|
||||
2.1. Важно обратить внимание на то, что интервалы между заголовками
|
||||
выставляются в единицах измерения — мм, а не пт.
|
||||
На рис. 2.1 представлен пример внешнего вида заголовков выпускной
|
||||
квалификационной работы и курсовой работы с учетом установленных
|
||||
параметров страниц для учебной работы.
|
||||
7
|
||||
Таблица 2.1. Параметры оформления заголовков в учебной работе
|
||||
Параметр Заголовок
|
||||
первого урﮦовнﮦя
|
||||
Заголовок
|
||||
второго уровня
|
||||
Заголовок
|
||||
третьего уровня
|
||||
ифт
|
||||
Шрﮦифт Times New Roman
|
||||
Нﮦачерﮦтанﮦие полужирﮦнﮦый
|
||||
Подчерﮦкиванﮦие нﮦет
|
||||
Видоизменﮦенﮦие все прﮦописнﮦые нﮦет нﮦет
|
||||
Рﮦазмерﮦ 18 пт 16 пт 14 пт
|
||||
Цвет текста Авто (черﮦнﮦый)
|
||||
Абзац
|
||||
Вырﮦавнﮦиванﮦие По левому крﮦаю
|
||||
Перﮦвая стрﮦока нﮦет
|
||||
Отступ слева 1,25 см
|
||||
Отступ спрﮦава 0 мм
|
||||
Инﮦтерﮦвал перﮦед 0 мм 15 мм 15 мм
|
||||
Инﮦтерﮦвал после 10 мм 10 мм 10 мм
|
||||
Междустрﮦочнﮦый
|
||||
инﮦтерﮦвал
|
||||
Полуторﮦнﮦый
|
||||
Положенﮦие нﮦа
|
||||
ице
|
||||
Не отрывать от
|
||||
следующего
|
||||
С новой страницы
|
||||
Не отрывать от
|
||||
следующего
|
||||
Запрет висящих строк
|
||||
Нﮦе отрывать от
|
||||
следующего
|
||||
Запрет висящих строк
|
||||
|
||||
|
||||
Рисунок 2.1. Пример внешнего вида заголовков
|
||||
|
||||
8
|
||||
азделы «СОДЕРЖАНИЕ», «ВВЕДЕНИЕ», «ЗАКЛЮЧЕНИЕ», «СПИСОК
|
||||
ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ », «П РИЛОЖЕНИЕ»/«ПРИЛОЖЕНИЯ»
|
||||
уются.
|
||||
Разделы «СОДЕРЖАНИЕ» и «СПИСОК ИСПОЛЬЗОВАННЫХ
|
||||
ИСТОЧНИКОВ» выравниваются по центру страницы.
|
||||
ие
|
||||
азделяют точкой.
|
||||
е Word используется
|
||||
вкладка «Стили» (рис. 2.2). ﮦ ов
|
||||
о
|
||||
ить азу
|
||||
ия.
|
||||
|
||||
|
||||
Рисунок 2.2. Использование вкладки «Стили» в текстовом редакторе Word
|
||||
|
||||
ить
|
||||
а «Стили» ять
|
||||
овые стили (рис. 2.3).
|
||||
ые соответствующими стилями, позволяют
|
||||
ие.
|
||||
ктов должны
|
||||
ии, и
|
||||
соответственно наоборот. Для обеспечения этого содержание учебной работы
|
||||
должно формироваться с помощью встроенной функции Оглавление.
|
||||
ИЕ»,
|
||||
ием по
|
||||
у. Важно обратить внимание на значения интервала перед самим
|
||||
заголовком и после него для того, чтобы обеспечить нужные интервалы между
|
||||
заголовков и последующими пунктами самого содержания учебной работы.
|
||||
ю
|
||||
«Ссылки» → а ю и с
|
||||
ителя строки «. . . . . . . .» ( рис. 2.4).
|
||||
|
||||
9
|
||||
ить и
|
||||
овать.
|
||||
|
||||
|
||||
Рисунок 2.3. Редактирование заголовков в текстовом редакторе Word с
|
||||
использование диалогового окна «Стили»
|
||||
|
||||
|
||||
Рисунок 2.4. Формирование содержания через Автособираемое оглавление
|
||||
|
||||
иц,
|
||||
ом
|
||||
овить поле».
|
||||
|
||||
10
|
||||
При создании содержания слово ОГЛАВЛЕНИЕ заменяется в ручном
|
||||
режиме на СОДЕРЖАНИЕ с выставлением необходимых параметров,
|
||||
характерных для заголовков первого уровня.
|
||||
ой
|
||||
текст» без отступа первой строки аю.
|
||||
я,
|
||||
ии
|
||||
ыми буквами (рис. 2.5), аналогично тому, как они
|
||||
оформлены в учебной работе за исключением того, что полужирное начертание
|
||||
не применяется.
|
||||
|
||||
|
||||
Рисунок 2.5. Пример оформления содержания в ВКР
|
||||
|
||||
11
|
||||
ии текст попадает иц
|
||||
кте отсутствует отточие ицы, то часть
|
||||
очку.
|
||||
|
||||
12
|
||||
2. ОФОРМЛЕНИЕ ТЕКСТА ДОКУМЕНТА
|
||||
|
||||
ты учебной работы ой
|
||||
е листа.
|
||||
В случае представления учебной работы в бумажном виде она а быть
|
||||
мата А4.
|
||||
Учебная работа ых
|
||||
ﮦ илами.
|
||||
Учебная работа проверяется руководителем на соблюдение правил
|
||||
оформления, в случае несоответствия работы она не допускается до защиты и
|
||||
выставления оценки, и направляется на доработку.
|
||||
Текст документа должен быть кратким, четким и не допускать различных
|
||||
толкований. В докуме нтах должны применяться научно-технические те рмины,
|
||||
обозначения и определен ия общеп ринятые в научно-технической лите ратуре
|
||||
Стиль изложения текста в учебной работе строго нау чный, подстиль —
|
||||
собственно-научный. е допускается:
|
||||
• ицизмы,
|
||||
ализмы;
|
||||
• ия;
|
||||
• усской
|
||||
тами и
|
||||
ая
|
||||
овка;
|
||||
• и
|
||||
ﮦ .
|
||||
Текст учебной работы печатается, соблюдая следующие размеры полей:
|
||||
левое — 30 мм, правое — 10 мм, верхнее и нижнее — 20 мм. Абзацный отступ
|
||||
должен быть одинаковым по всему тексту и равен 1,25 см [1].
|
||||
а листах в рамках еще
|
||||
незаконченного раздела (см. Приложение В).
|
||||
иц.
|
||||
Не допускается размещать заголовки в кон це страницы, а основной текст
|
||||
переносить на следующую страницу.
|
||||
Текст ифтом
|
||||
ого цвета , размер шрифта — 14 пт, тип шрифта для основного текста
|
||||
учебной работы — Times NewтRoman.
|
||||
ой текст» (табл. 2.1).
|
||||
13
|
||||
ифт»,
|
||||
а знаков абзаца и других скрытых знаков
|
||||
форматирования ца абзаца и
|
||||
аки» (ая» →
|
||||
ифт» → ¶).
|
||||
|
||||
Таблица 2.1. Параметры оформления основного текста учебной работы
|
||||
Параметр Значение
|
||||
ифт
|
||||
Шрﮦифт Times New Roman
|
||||
Нﮦачерﮦтанﮦие Обычнﮦый
|
||||
Подчерﮦкиванﮦие нﮦет
|
||||
Видоизменﮦенﮦие нﮦет
|
||||
Рﮦазмерﮦ 14 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По ширине
|
||||
Первая строка 1,25 см.
|
||||
Отступ слева нет
|
||||
Отступ справа нﮦет
|
||||
Интервал перﮦед нﮦет
|
||||
Интервал после нет
|
||||
Междустрочный интервал Полуторный
|
||||
Положение на странице Запрет висящих строк
|
||||
|
||||
Пробел ставится после любого знака пунктуации, но не перед ним. При этом
|
||||
не ставятся п робелы после отк рывающих скобок и кавычек, так же как и пе ред
|
||||
закрывающими скобками и кавычками.
|
||||
Кавычки в русском тексте (включая список использова нных источ ников)
|
||||
имеют форму «…», а в английском — "…".
|
||||
ая.
|
||||
ицы учебной работы оставляемыми по
|
||||
ифт — ﮦ12 пт).
|
||||
ﮦ ицы
|
||||
е ставится:
|
||||
• титульный лист;
|
||||
• ие;
|
||||
• ие.
|
||||
е
|
||||
аки, такие как
|
||||
«+», «–», «=», «>», «<» и так далее. В тексте их следует писать словами: «плюс»,
|
||||
ьше».
|
||||
аки:
|
||||
14
|
||||
1. ак дефиса «-» обелами и используется:
|
||||
• жащих дефисы (во-первых,
|
||||
физико-математический, кто-либо);
|
||||
• валов (100-150 Мб).
|
||||
2. уса «–» ия.
|
||||
3. е «—обелами и используется в соответствии с
|
||||
ктуации (Классификаторﮦ —это
|
||||
систематизированный …).
|
||||
абские
|
||||
талов, полугодий,
|
||||
ами.
|
||||
ами, ы
|
||||
иями (… в начале XXI века).
|
||||
ых
|
||||
ий (… во 2 разделе, … характерно для середины 1980 годов).
|
||||
ﮦ ой и той же
|
||||
его числа
|
||||
(…составляет 200, 250, 300 руб.)ица
|
||||
ﮦ е (… длиной 20-25 см).
|
||||
ﮦ иц
|
||||
ﮦ ами, а числа без
|
||||
ﮦ ицы до девяти
|
||||
— словами (провести испытан ия семи т руб, каждая дли ной 5 м) . ые
|
||||
обей с ипользованием
|
||||
запятой при написании (20,5 см).
|
||||
ый и ый спис ки уются стилем
|
||||
ой текст».
|
||||
|
||||
Таблица 2.2. Параметры оформления списков
|
||||
Параметр Значение
|
||||
Марﮦкирﮦованﮦнﮦый список –, ―, ●, ■, ○
|
||||
Нﮦумерﮦованﮦнﮦый список 1.
|
||||
а)
|
||||
Положенﮦие марﮦкерﮦа (нﮦомерﮦа) Отступ 1,25 см
|
||||
Положенﮦие текста Табуляция после 2,25 см
|
||||
Отступ 2,25 см
|
||||
|
||||
ﮦ а во всем
|
||||
ькой
|
||||
15
|
||||
чивается — кт в
|
||||
чивается точкой).
|
||||
ﮦ ой буквы и
|
||||
чиваться точкой (рис. 2.1-2.3).
|
||||
|
||||
|
||||
Рисунок 2.1. Пример 1 оформления списка в учебной работе
|
||||
|
||||
|
||||
Рисунок 2.2. Пример 2 оформления списка в учебной работе
|
||||
|
||||
16
|
||||
|
||||
Рисунок 2.3. Пример 3 оформления списка в учебной работе
|
||||
|
||||
В случае необходимости применения для нумерованного списка строчных
|
||||
букв русского алфавита, список начинают с буквы «а» (за исключением букв ё,
|
||||
з, й, о, ч, ъ, ы, ь) [1].
|
||||
Внутри списка не могут располагаться иллюстрации и таблицы.
|
||||
При выполнении учебной работы необходимо соблюдать равномерную
|
||||
плотность и четкость изображения по все й работе. Все линии, буквы, цифры и
|
||||
знаки должны иметь одинаковую контрастность по всему тексту.
|
||||
|
||||
|
||||
17
|
||||
3. ОФОРМЛЕНИЕ ИЛЛЮСТРАЦИЙ И ТАБЛИЦ В
|
||||
УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
Количество иллюстраций в учебной работе должно быть достаточ ным для
|
||||
пояснения излагаемого текста, в некоторых случаях обязательные иллюстрации
|
||||
регламентируются методическими указаниями по написанию курсовой работы,
|
||||
методическими указаниями по написанию отчета по практике, методическими
|
||||
указаниями по написанию ВКР.
|
||||
о после
|
||||
ице (по
|
||||
возможности ближе к соответствующим частям текста учебной работы) с
|
||||
у без абзацного отступа и первой строки.
|
||||
ами.
|
||||
аздела —
|
||||
ﮦ ﮦ азделяются точкой.
|
||||
ации
|
||||
ации (табл. 3.1).
|
||||
|
||||
Таблица 3.1. Параметры оформления подписи иллюстраций
|
||||
Параметр Значение
|
||||
ифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Полужирﮦнﮦый
|
||||
Подчеркивание нет
|
||||
Видоизменﮦение нет
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черﮦнﮦый)
|
||||
Абзац
|
||||
Выравнивание По центру
|
||||
Первая строка 0 см
|
||||
Отступ слева 0 см
|
||||
Отступ спрﮦава нет
|
||||
Интервал перﮦед 0 мм
|
||||
Интервал после 6 мм
|
||||
Междустрочный интервал Одинарный
|
||||
Положение на странице Запрет висящих строк
|
||||
|
||||
ия
|
||||
е ставится . Предложение, в котором автор ссылается на
|
||||
рисунок, должно заканчиваться точкой, а не двоеточие м, т а к к а к э т о является
|
||||
нарушением правил пунктуации. (рис. 3.1).
|
||||
ием
|
||||
а.
|
||||
|
||||
18
|
||||
|
||||
Рисунок 3.1. Пример оформления рисунка с учетом установленных параметров
|
||||
страниц учебной работы
|
||||
|
||||
углых скобках или
|
||||
ﮦ :
|
||||
• план проекта иллюстрирует диаграмма Ганта (Рисунок 1.1);
|
||||
• дерево функций представлено на Рисунке 1.1.
|
||||
о
|
||||
ия по часовой
|
||||
а 90 яется с
|
||||
ую с помощью команды главного меню «Макет» . При
|
||||
размещении рисунка на отдельной странице с альбомной ориентацией
|
||||
необходимо установить в Параметрах страницы вертикальное выравнивание
|
||||
по центру.
|
||||
Страницы с альбомной ориентацией предназначены только для
|
||||
рисунков и таблиц, нельзя размещать на них основной текст (Приложение Г,
|
||||
рис. Г.1-Г.2).
|
||||
|
||||
19
|
||||
Сначала устанавливаются разрывы страницы, начиная со следующей
|
||||
страницы, а потом меняется ориентация с книжной на альбомную (рис. 3.2-3.3).
|
||||
|
||||
|
||||
Рисунок 3.2. Переход от книжной ориентации к альбомной
|
||||
|
||||
|
||||
Рисунок 3.3. Пример оформления рисунка на странице с альбомной ориентацией
|
||||
|
||||
20
|
||||
В случае, если рисунок завершает раздел, подраздел или пункт
|
||||
рекомендуется после него сформулиро вать заключительное предложение. Для
|
||||
раздела это будет заключительное предложение, подводящее итоги по
|
||||
выполненной работе в соответствующем разделе. Для подраздела или пункта это
|
||||
будет завершающее предложение, обеспечивающее переход к следующему
|
||||
подразделу или пункту (рис. 3.4-3.5).
|
||||
|
||||
|
||||
Рисунок 3.4. Пример завершения раздела и переход к следующему
|
||||
|
||||
Все иллюстрации должны иметь границы четко по представляемому
|
||||
рисунку, схеме и т.д., и не допускается пустая, неинформативная часть в
|
||||
иллюстрации, занимающая место, предназначеннное для основного текста.
|
||||
На рис. 3.6 представлена правильная и неправильная обрезка границ
|
||||
иллюстрации.
|
||||
Вне зависимости от того, каков формат представления учебной работы:
|
||||
распечатанная бумажная версия или электронный файл, все иллюстрации
|
||||
должны удовлетворять требованию их четкого воспроизведения, то есть должна
|
||||
быть обеспечена читабельность иллюстраций без необходимости прибегать к
|
||||
увеличению масштаба представленного материала.
|
||||
|
||||
|
||||
21
|
||||
|
||||
Рисунок 3.5. Пример завершения подраздела и переход к следующему подразделу в
|
||||
курсовой работе
|
||||
|
||||
|
||||
Рисунок 3.6. Пример правильной обрезки границ иллюстрации (левая часть) и
|
||||
неправильной обрезки границ илллюстрации (правая часть)
|
||||
|
||||
22
|
||||
Кроме того, рисунки, представляющие собой чертежи, графики, диаграммы,
|
||||
схемы, помещаемые в учебной работе , должны соответствовать требованиям
|
||||
стандартов Единой системы конструкторской документации (ЕСКД) .
|
||||
ия
|
||||
показателей.
|
||||
ие таблицы атким.
|
||||
ад таблицей.
|
||||
ие таблицы пишется без абзацного отступа и первой строки у
|
||||
це
|
||||
е ставится.
|
||||
ами аздела —
|
||||
ﮦ ﮦ азделяются точкой.
|
||||
ом
|
||||
еобходимости,
|
||||
ту.
|
||||
ﮦ ие ячеек
|
||||
е допускается.
|
||||
ед
|
||||
е Word).
|
||||
Параметры оформления таблицы в учебной работе представлены в табл. 3.2.
|
||||
|
||||
Таблица 3.2. Параметры оформления таблицы
|
||||
Параметр Надпись таблицы Текст таблицы
|
||||
ифт
|
||||
Шрифт Times New Roman
|
||||
ие Курсив Обычный, полужирный,
|
||||
курсив
|
||||
Подчеркиванﮦие нет
|
||||
Видоизменение нет
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По левому краю По центру, по левому
|
||||
краю
|
||||
Первая стрﮦока 0 см
|
||||
Отступ слева 0 см
|
||||
Отступ справа нет
|
||||
Интервал перед 6 мм 0 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный интервал Одинﮦарный
|
||||
Положение нﮦа странице Запрет висящих строк
|
||||
|
||||
23
|
||||
а все таблицы учебной работы ы ссылки в тексте.
|
||||
углых скобках или включаются в
|
||||
ие. Если ссылка на таблицу завершает предложение, то в конце
|
||||
предложения ставится точка.
|
||||
ием
|
||||
углых скобках
|
||||
ﮦ :
|
||||
• далее представлен планﮦпроекта (Таблица 3.1);
|
||||
• планﮦпроекта представлен в Таблице 3.1 (рис. 3.7).
|
||||
|
||||
|
||||
Рисунок 3.7. Пример представления таблицы
|
||||
|
||||
ие
|
||||
ую
|
||||
угими частями пишется
|
||||
адпись таблицы»
|
||||
(рис. 3.8).
|
||||
Не допускаются таблицы в одну строчку, а также не допускается оставлять
|
||||
на странице только заголовок к таблице или заголовок с заголовками граф
|
||||
(Приложение Г, рис. Г.3).
|
||||
ации
|
||||
ед их
|
||||
ием.
|
||||
Все таблицы в учебной работе должны быть оформлены единообразно с
|
||||
точки зрения применяемого стиля для оформления заголовков и подзаголовков
|
||||
граф, а также строк таблицы.
|
||||
|
||||
|
||||
|
||||
24
|
||||
|
||||
Рисунок 3.8. Пример оформления переноса таблицы в учебной работе
|
||||
|
||||
ия,
|
||||
ия
|
||||
е
|
||||
о под
|
||||
мулой.
|
||||
ием
|
||||
мул Microsoft Equation или MathType.
|
||||
а вкладке «Вставка» →«Уравнение» (рис.
|
||||
3.9).
|
||||
|
||||
|
||||
Рисунок 3.9. Возможности контекстного меню «Уравнение»
|
||||
|
||||
|
||||
25
|
||||
Не допускается формулы оформлять как основной текст, основным
|
||||
шрифтом.
|
||||
мулы, ивается по
|
||||
у. а
|
||||
ока.
|
||||
а ака
|
||||
ус ( –ия (×) или
|
||||
ия яется.
|
||||
ию.
|
||||
тов следует
|
||||
ости, в какой
|
||||
муле.
|
||||
ают без абзацного отступа со слова «где»
|
||||
его.
|
||||
Значение каждого символа и числового коэффицие нта следует давать с
|
||||
новой строки, располагая символы один под другим (рис. 3.10).
|
||||
|
||||
|
||||
Рисунок 3.10. Пример оформления последовательности формул в учебной работе
|
||||
|
||||
|
||||
26
|
||||
Если формула является концом предложения, то ставится точка, если после
|
||||
формулы предложение продолжается — запятая.
|
||||
Формулы нумеруются последовательно в пределах раздела арабскими
|
||||
цифрами в круглых скобках, выровненных по правому краю. Нумеруются только
|
||||
те формулы, на которые имеются ссылки в тексте.
|
||||
Ссылки в тексте на номер формулы даются в круглых скобках.
|
||||
Вне зависимости от способа выполнения учебной работы качество
|
||||
напечатанного текста и оформления иллюстраций, таблиц, распечаток программ
|
||||
должно удовлетворять требованию их четкого воспроизведения.
|
||||
27
|
||||
4. ОФОРМЛЕНИЕ ТЕКСТА (ЛИСТИНГА) ПРОГРАММЫ В
|
||||
УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
В учебной работе могут содержаться отдельные фрагменты программ (ли-
|
||||
стинг), разработанных студентом.
|
||||
Название листинга пишется в одну строку с номером, через тире, с большой
|
||||
буквы, перед листингом. Точка в конце не ставится. Пример, «Листинг 1.7 —
|
||||
Процедура открытия программы».
|
||||
При ссылке на отдельные строки программы они нумеруются в порядке воз-
|
||||
растания с шагом 10. После ссылки на листинг в фигурных скобках привод ятся
|
||||
номера строк. Пример «Листинг 3.2 {30, 50, 140}», «Листинг 3.2 {20- 80}».
|
||||
Листинг оформляется в рамку (рис. 4.1-4.2).
|
||||
|
||||
|
||||
Рисунок 4.1. Пример 1 оформления листинга
|
||||
|
||||
|
||||
Рисунок 4.2. Пример 2 оформления листинга
|
||||
|
||||
28
|
||||
Параметры оформления листинга представлены в табл. 4.1.
|
||||
|
||||
Таблица 4.1. Параметры оформления листинга
|
||||
Листинг
|
||||
Надпись листинга
|
||||
Шрифт Шрифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Курсив
|
||||
Размер 12 пт
|
||||
Цвет текста Авто (черный)
|
||||
Подчеркивание нет
|
||||
Видоизменение нет
|
||||
Интервал
|
||||
Масштаб 100%
|
||||
Интервал Обычный
|
||||
Смещение Нет
|
||||
Анимация Вид Нет
|
||||
Абзац Отступы и интервалы
|
||||
Выравнивание По левому краю
|
||||
Отступ слева 0 см
|
||||
Отступ справа 0 см
|
||||
Первая строка нет
|
||||
Интервал перед 6 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный Одинарный
|
||||
Положение на странице Разбивка на страницы Не отрывать от следующего
|
||||
С новой страницы
|
||||
Содержание листинга
|
||||
Шрифт Шрифт
|
||||
Шрифт Courier New
|
||||
Начертание Обычный
|
||||
Размер 10 пт
|
||||
Цвет текста Авто (черный)
|
||||
Подчеркивание Нет
|
||||
Видоизменение нет
|
||||
Интервал
|
||||
Масштаб 100%
|
||||
Интервал Обычный
|
||||
Смещение Нет
|
||||
Анимация Вид Нет
|
||||
Абзац
|
||||
Отступы и интервалы
|
||||
Выравнивание По левому краю
|
||||
Отступ слева 0 см
|
||||
Отступ справа 0 см
|
||||
Первая строка 0 см
|
||||
Интервал перед 0 мм
|
||||
Интервал после 0 мм
|
||||
Междустрочный Одинарный
|
||||
|
||||
29
|
||||
Основной текст (строка) после листинга печатается с интервалом перед 6
|
||||
мм.
|
||||
Весь текст программы целесообразно выносить в приложени е.
|
||||
|
||||
30
|
||||
5. ОФОРМЛЕНИЕ СПИСКА ИСПОЛЬЗОВАННЫХ
|
||||
ИСТОЧНИКОВ В УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
Все заимствованные части учебной работы (автором которых не является
|
||||
студент) должны быть сопровождены ссылкой на источник , тип ссылки —
|
||||
затекстовая (см. ГОСТ Р 7.0.5- 2008). Список должен содержать сведения об
|
||||
источниках, использованных при составлении учебной работы [1].
|
||||
Список использованных источников должен включать библиографические
|
||||
записи на документы, использованные при составлении учебной работы, ссылки
|
||||
на которые оформляют арабскими цифрами в квадратных скобках.
|
||||
Сведения об источниках следует располагать в порядке появления ссылок
|
||||
на источники в тексте учебной работы и нумеровать арабскими цифрами с
|
||||
точкой и печатать с абзацного отступа в 1,25 см.
|
||||
При оформлении раздела СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
на первой строке листа печатается соответствующее наименование р аздела,
|
||||
отформатированное по параметрам для заголовка первого уровня.
|
||||
Для учебных работ вида курсовой работы или отчета по практике список
|
||||
использованных источников делить на разделы не требуется. Требованиями к
|
||||
источникам в данном случае являются следующие:
|
||||
• количество источников от 5 до 7;
|
||||
• год издания источника — за последние 5 лет к моменту написания и
|
||||
представления учебной работы (Приложение Д, рис. Д.1).
|
||||
В случае выпускной квалификационной рабо ты список использованных
|
||||
источников делится на разделы, в каждом и которых должно быть представлено
|
||||
не менее 10 источников, годом издания не больше, чем 5 лет к моменту защиты
|
||||
выпускной квалификационной работы.
|
||||
Параметры оформления заголовков разделов в списке использованных
|
||||
источников представлены в табл. 5.1.
|
||||
Внутри раздела источники распределяются в порядке упоминания их в
|
||||
выпускной квалификационной работе и нум еруются (рис. 5.1). В данном случае
|
||||
номер источника будет состоять из номера разд ела, к которому он относится, и
|
||||
номера по порядку.
|
||||
ики следует давать в виде
|
||||
арабских цифр, заключенных в квадратные скобки, указывающих порядковый
|
||||
номер источника по списку, например, [1], [2, 3], [1.5], [2.18].
|
||||
В случае цитирования указываются страницы книги, статьи или другого
|
||||
источника, из которых взята цитата, например, [2.18, c. 21-25].
|
||||
|
||||
31
|
||||
Таблица 5.1. Параметры оформления заголовков разделов
|
||||
в списке использованных источников
|
||||
Параметр Значение
|
||||
Шрифт
|
||||
Шрифт Times New Roman
|
||||
Начертание Обычный
|
||||
Подчеркивание нет
|
||||
Видоизменение Все прописные
|
||||
Размер 14 пт
|
||||
Цвет текста Авто (черный)
|
||||
Абзац
|
||||
Выравнивание По центру
|
||||
Первая строка нет
|
||||
Отступ слева 1,25 см.
|
||||
Отступ справа нет
|
||||
Интервал перед 6 мм
|
||||
Интервал после 6 мм
|
||||
Междустрочный интервал Полуторный
|
||||
Положение на странице Не отрывать от следующего
|
||||
|
||||
|
||||
Рисунок 5.1. Пример оформления списка использованных источников с делением на
|
||||
разделы
|
||||
|
||||
32
|
||||
На рис. 5. 2 приведен пример основного текста учебной работы со ссылкой
|
||||
на источник.
|
||||
|
||||
|
||||
Рисунок 5.2. Пример оформления ссылок в тексте учебной работы
|
||||
|
||||
На рис. 5. 3 приведен пример основного текста учебной работы со ссылкой
|
||||
на источник при цитировании. При цитировании цитируемая фраза ставится в
|
||||
кавычки.
|
||||
|
||||
|
||||
Рисунок 5.3. Пример оформления ссылок в тексте при цитировании.
|
||||
|
||||
33
|
||||
В основном тексте ВВЕДЕНИЯ и ЗАКЛЮЧЕНИЯ ссылки на
|
||||
источники не указываются.
|
||||
Более подробно порядок оформления различных источников представлен в
|
||||
ГОСТ 7.32 — 2017 Система стандартов по информации, библиотечному и
|
||||
издательскому делу. Отчет о научно -исследовательской работе. Структура и
|
||||
правила и оформления. Приложение Е на странице 26 указанного документа.
|
||||
34
|
||||
6. ОФОРМЛЕНИЕ ПРИЛОЖЕНИЙ В УЧЕБНОЙ РАБОТЕ
|
||||
|
||||
яющий текст учебной работы, допускается помещать в
|
||||
ие учебной работы а
|
||||
последующих ее листах.
|
||||
иложения могут выноситься:
|
||||
• ом, так и
|
||||
ия;
|
||||
• уемого объекта
|
||||
ия;
|
||||
• асчеты;
|
||||
• сложные алгоритмы;
|
||||
• аботы;
|
||||
• тов;
|
||||
• тов;
|
||||
• экранные формы инормацинной системы;
|
||||
• листинги программ;
|
||||
• оизводство
|
||||
ии).
|
||||
мата А4. Допускается
|
||||
мата A3 по ГОСТ 2.301.
|
||||
В учебной работе приложение может быть одно или несколько.
|
||||
В случае, если в учебной работе будет только одно приложение , т о н а
|
||||
следующем листе после списка использованных источников заголовком первого
|
||||
уровня оформляется заголовок ПРИЛОЖЕНИЕ. И на этом же листе размещается
|
||||
необходимый дополняющий материал (рис. Д.1, Приложение Д).
|
||||
В случае необходимости оформить несколько приложений в учебной работе
|
||||
их а я с А , з а
|
||||
ﮦ «Приложение А».
|
||||
ь
|
||||
ия в
|
||||
ой текст» (рис.
|
||||
6.1).
|
||||
ием
|
||||
ого стилем
|
||||
я».
|
||||
35
|
||||
|
||||
Рисунок 6.1. Пример оформления раздела ПРИЛОЖЕНИЯ
|
||||
|
||||
а
|
||||
ﮦ «Приложение А.1», «Приложение А.2», если они связаны по
|
||||
смыслу.
|
||||
ый по
|
||||
ой текст» (рис. 6.2).
|
||||
|
||||
|
||||
Рисунок 6.2. Пример оформления отдельного приложения в составе раздела
|
||||
ПРИЛОЖЕНИЯ
|
||||
|
||||
36
|
||||
Таблицы и иллюст рации, соде ржащиеся в п риложении, нумеруются
|
||||
аналогично таблицам и рисунк а м в о сновном тексте учебной работы.
|
||||
Иллюстрации каждого пр иложения обоз начают отдель ной нумерацией
|
||||
арабскими циф рами с добавлен ием пе ред циф рой обоз начения приложе ния,
|
||||
например, «… представлено на Рисунке Б.1», «… отражен в таблице В.1».
|
||||
В тексте докумен та на все п риложения должн ы быть да ны ссылки.
|
||||
Приложения располагают в порядке ссылок на них в тексте учебной работы.
|
||||
При ссылке следует писать слово «П риложение» с большой буквы с
|
||||
указанием порядкового номера, например «…представлено в Приложении Б».
|
||||
Перечень материала, оформляемого в виде приложения, определяется
|
||||
обучающимся совместно с руководителем на основе методических
|
||||
рекомендаций по курсовой работе, методических рекомендаций по подготовке
|
||||
отчета по практике, методических рекомендаций по написанию ВКР.
|
||||
Применительно к ВКР бакалавра или магистра обязательным приложением
|
||||
к ней является приложение под названием Графический материал, который
|
||||
представляет собой слайды презентации с основными результатами,
|
||||
полученными в ходе написания ВКР.
|
||||
|
||||
37
|
||||
СПИСОК ИСТОЧНИКОВ И ЛИТЕРАТУРЫ
|
||||
|
||||
1. ГОСТ 7.32 — 2017 Система стандартов по информации,
|
||||
библиотечному и издательскому делу. Отчет о научно- исследовательской
|
||||
работе. Структура и правила и оформления — URL:
|
||||
https://www.rea.ru/ru/org/managements/orgnirupr/Documents/gost_7.32 -2017.pdf
|
||||
(дата обращения 20.11.2022)
|
||||
|
||||
38
|
||||
ПРИЛОЖЕНИЕ А
|
||||
|
||||
|
||||
Рисунок А.1. Общий вид титульного листа ВКР
|
||||
|
||||
|
||||
Рисунок А.2. Общий вид титульного листа курсовой работы
|
||||
|
||||
39
|
||||
ПРИЛОЖЕНИЕ Б
|
||||
|
||||
|
||||
Рисунок Б.1. Общий вид одностороннего задания на курсовую работу
|
||||
|
||||
|
||||
Рисунок Б.2. Общий вид двустороннего задания на практику
|
||||
|
||||
40
|
||||
|
||||
Рисунок Б.3. Общий вид двустороннего задания на ВКР (лицевая сторона)
|
||||
|
||||
|
||||
Рисунок Б.4. Общий вид задания на ВКР (оборотная сторона и окончание задания )
|
||||
|
||||
41
|
||||
ПРИЛОЖЕНИЕ В
|
||||
|
||||
|
||||
Рисунок В.1. Пример недопустимой ситуация в учебной работе, полупустая страница при условии,
|
||||
что подраздел еще не закончился
|
||||
|
||||
42
|
||||
ПРИЛОЖЕНИЕ Г
|
||||
|
||||
|
||||
Рисунок Г.1. Пример 1 неправильного использования страницы с альбомной
|
||||
ориентацией
|
||||
|
||||
|
||||
Рисунок Г.1. Пример 2 неправильного использования страницы с альбомной
|
||||
ориентацией
|
||||
|
||||
43
|
||||
|
||||
Рисунок Г.3. Неверное представление таблицы в учебной работе
|
||||
|
||||
|
||||
44
|
||||
ПРИЛОЖЕНИЕ Д
|
||||
|
||||
|
||||
Рисунок Д.1. Пример оформления списка использованных источников без деления на
|
||||
разделы
|
||||
|
||||
|
||||
|
||||
|
||||
45
|
||||
ПРИЛОЖЕНИЕ Е
|
||||
|
||||
|
||||
Рисунок Е.1. Оформление ПРИЛОЖЕНИЯ в учебной работе
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
46
|
||||
СВЕДЕНИЯ ОБ АВТОРАХ
|
||||
|
||||
Бергер Екатерина Геннадьевна, к.э.н., доцент кафедры практической и при-
|
||||
кладной информатики Института информационных технологий
|
||||
Кириллина Юлия Владимировна, к.э.н., доцент, доцент кафедры практиче-
|
||||
ской и прикладной информатики Института информационных технологий
|
||||
Лагунова Анна Дмитриевна, к.э.н., доцент кафедры практической и при-
|
||||
кладной информатики Института информационных технологий
|
||||
|
||||
+15
-17
@@ -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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
cd /d "%~dp0"
|
||||
chcp 65001 >nul 2>&1
|
||||
|
||||
set "DO_PAUSE=1"
|
||||
set "WANT_CLEAN=0"
|
||||
set "FAST=0"
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto :args_done
|
||||
if /i "%~1"=="nopause" set "DO_PAUSE=0"
|
||||
if /i "%~1"=="clean" set "WANT_CLEAN=1"
|
||||
if /i "%~1"=="fast" set "FAST=1"
|
||||
shift
|
||||
goto :parse_args
|
||||
:args_done
|
||||
|
||||
set "PYI_SPEC=md2gost.spec"
|
||||
set "PYI_WORK="
|
||||
set "PYI_DIST="
|
||||
set "PYI_CLEAN=--clean"
|
||||
if "%FAST%"=="1" (
|
||||
set "PYI_SPEC=md2gost.fast.spec"
|
||||
set "PYI_WORK=--workpath build-fast"
|
||||
set "PYI_DIST=--distpath dist"
|
||||
set "PYI_CLEAN="
|
||||
if "%WANT_CLEAN%"=="1" set "PYI_CLEAN=--clean"
|
||||
)
|
||||
|
||||
if "%FAST%"=="1" (
|
||||
echo === md2gost: сборка exe через uvx, Python 3.11, fast spec ===
|
||||
echo Spec: md2gost.fast.spec
|
||||
echo Кэш: build-fast\
|
||||
) else (
|
||||
echo === md2gost: сборка exe через uvx, Python 3.11 ===
|
||||
echo Spec: md2gost.spec
|
||||
)
|
||||
echo Каталог: %CD%
|
||||
echo uvx сам возьмёт CPython 3.11 ^(скачает, если ещё нет^).
|
||||
echo.
|
||||
|
||||
set "UVX_BIN="
|
||||
for /f "delims=" %%I in ('where uvx 2^>nul') do (
|
||||
set "UVX_BIN=%%I"
|
||||
goto :got_uvx
|
||||
)
|
||||
if exist "%USERPROFILE%\.local\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.local\bin\uvx.exe"
|
||||
if defined UVX_BIN goto :got_uvx
|
||||
if exist "%USERPROFILE%\.cargo\bin\uvx.exe" set "UVX_BIN=%USERPROFILE%\.cargo\bin\uvx.exe"
|
||||
if defined UVX_BIN goto :got_uvx
|
||||
|
||||
echo uvx не найден. Установите uv: https://docs.astral.sh/uv/getting-started/installation/
|
||||
echo powershell: irm https://astral.sh/uv/install.ps1 ^| iex
|
||||
goto :fail
|
||||
|
||||
:got_uvx
|
||||
echo uvx: %UVX_BIN%
|
||||
"%UVX_BIN%" --python 3.11 python -c "import sys; print('CPython', sys.version.split()[0], sys.executable)"
|
||||
if errorlevel 1 goto :nopython
|
||||
|
||||
if not exist "md2gost\Template.docx" goto :notemplate
|
||||
|
||||
echo [1/4] PlantUML jar + Graphviz + Mermaid assets ...
|
||||
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_plantuml.py
|
||||
if "%FAST%"=="1" (
|
||||
if not exist "md2gost\vendor\plantuml.jar" goto :nojar
|
||||
) else (
|
||||
if not exist "md2gost\vendor\plantuml.jar" (
|
||||
echo Предупреждение: plantuml.jar нет — в exe UML пойдёт через kroki.io.
|
||||
)
|
||||
)
|
||||
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_graphviz.py
|
||||
if not exist "md2gost\vendor\graphviz\bin\dot.exe" (
|
||||
echo Предупреждение: Graphviz нет — в exe DFD пойдёт через kroki.io / PATH.
|
||||
)
|
||||
if not exist "md2gost\diagrams\mermaid.min.js" (
|
||||
"%UVX_BIN%" --python 3.11 --isolated --with-editable . -- python scripts\fetch_mermaid.py
|
||||
)
|
||||
if "%FAST%"=="0" (
|
||||
if not exist "md2gost\diagrams\mermaid.min.js" (
|
||||
echo Предупреждение: mermaid.min.js нет — браузерный Mermaid в exe без assets.
|
||||
)
|
||||
)
|
||||
|
||||
echo [2/4] Эфемерное окружение: проект + matplotlib + PyInstaller ^(CPython 3.11^) ...
|
||||
echo [3/4] PyInstaller, один файл ...
|
||||
"%UVX_BIN%" --python 3.11 --isolated --from "pyinstaller>=6.0" --with-editable . --with "matplotlib>=3.7" --with "tkinterdnd2" -- pyinstaller --noconfirm %PYI_CLEAN% %PYI_WORK% %PYI_DIST% %PYI_SPEC%
|
||||
if errorlevel 1 goto :pyifail
|
||||
|
||||
if not exist "dist\md2gost.exe" goto :noexe
|
||||
|
||||
echo.
|
||||
echo [4/4] Готово:
|
||||
echo %CD%\dist\md2gost.exe
|
||||
echo.
|
||||
echo Двойной клик — GUI. Из консоли: md2gost.exe report.md -o report.docx
|
||||
echo Быстрый кэш: build-exe-uvx.bat fast
|
||||
echo Полный пересбор fast: build-exe-uvx.bat fast clean
|
||||
echo.
|
||||
if "%DO_PAUSE%"=="1" pause
|
||||
exit /b 0
|
||||
|
||||
:nopython
|
||||
echo uvx не смог запустить Python 3.11.
|
||||
goto :fail
|
||||
|
||||
:notemplate
|
||||
echo Нет md2gost\Template.docx — сборка бессмысленна.
|
||||
goto :fail
|
||||
|
||||
:nojar
|
||||
echo Нет md2gost\vendor\plantuml.jar — portable exe без jar не собираем.
|
||||
goto :fail
|
||||
|
||||
:pyifail
|
||||
echo PyInstaller завершился с ошибкой.
|
||||
goto :fail
|
||||
|
||||
:noexe
|
||||
echo dist\md2gost.exe не появился.
|
||||
goto :fail
|
||||
|
||||
:fail
|
||||
echo.
|
||||
echo Сборка не удалась.
|
||||
if "%DO_PAUSE%"=="1" pause
|
||||
exit /b 1
|
||||
+13
-15
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
tests/_idef0_preview.png
|
||||
tests/_idef0_preview.svg
|
||||
tests/_idef0_preview_a0.png
|
||||
tests/_idef0_preview_a0.svg
|
||||
+8
-5
@@ -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/).
|
||||
|
||||
|
||||
@@ -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 <<business-process>>
|
||||
archimate #Application "Портал" as c <<application-component>>
|
||||
c --> p
|
||||
@enduml
|
||||
```
|
||||
|
||||
Sprites: `<<$archimate/business-process>>` или `jar:archimate/...`.
|
||||
**Не смешивайте** макросы и сырой `archimate` на одной схеме.
|
||||
|
||||
### 7.3. Темы
|
||||
|
||||
```plantuml
|
||||
!theme archimate-standard from <archimate/themes>
|
||||
' archimate-alternate | saturated | lowsaturation | handwriting
|
||||
```
|
||||
|
||||
### 7.4. Nesting и special shapes
|
||||
|
||||
```plantuml
|
||||
Business_Product(prod, "Тариф") {
|
||||
Business_Service(s1, "Подключение")
|
||||
Business_Contract(c1, "Оферта")
|
||||
}
|
||||
```
|
||||
|
||||
Для special shapes (`$ARCH_SPECIAL_SHAPES`) у Service/Actor/Value/ValueStream при вложении: `$nest=%true()`.
|
||||
|
||||
### 7.5. Layout helpers
|
||||
|
||||
`Lay_U` / `Lay_D` / `Lay_L` / `Lay_R` — скрытые связи для выравнивания.
|
||||
`LAYOUT_AS_SKETCH()` — черновик (не для финального ГОСТ-рисунка).
|
||||
|
||||
### 7.6. Sequence с ArchiMate
|
||||
|
||||
Только если явно просят sequence: `!global $ARCH_SEQUENCE_SUPPORT = %true()` до include. Это **не** замена viewpoint.
|
||||
|
||||
### 7.7. List sprites
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
listsprite
|
||||
@enduml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Чеклист для ИИ
|
||||
|
||||
1. Версия языка: **ArchiMate 3.2**; макросы слоя (`Business_*`, `Application_*`, …).
|
||||
2. Оградка `` ```uml-archimate ``; без `@startuml` / `!include`.
|
||||
3. Выбрать **один** viewpoint; 8–18 элементов на рисунок.
|
||||
4. Сначала все элементы, потом все `Rel_*`.
|
||||
5. Alias латиницей; подписи по-русски в кавычках; длинные имена с `\n`.
|
||||
6. Serving от сервиса к потребителю; Realization от конкретного к абстрактному.
|
||||
7. Поведение того же слоя, что и исполнитель (Assignment).
|
||||
8. Не UML class/component, не C4 `Person`, не BPMN `XOR`, не mxgraph.
|
||||
9. Не Association «по умолчанию».
|
||||
10. Для отчёта: `%id Название` (+ `+landscape` если широко).
|
||||
|
||||
---
|
||||
|
||||
## 9. Готовые шаблоны
|
||||
|
||||
Тела для `` ```uml-archimate `` (без обёртки).
|
||||
|
||||
### 9.1. Organization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Actor(uni, "Университет") {
|
||||
Business_Actor(dean, "Деканат")
|
||||
Business_Actor(it, "Управление ИТ")
|
||||
}
|
||||
Business_Role(student, "Студент")
|
||||
Business_Role(clerk, "Методист")
|
||||
Rel_Assignment(dean, clerk)
|
||||
Rel_Assignment(uni, student)
|
||||
```
|
||||
|
||||
### 9.2. Service Realization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Role(applicant, "Абитуриент")
|
||||
Business_Process(apply, "Подача заявления")
|
||||
Business_Service(applySvc, "Приём заявлений")
|
||||
Application_Service(webSvc, "Веб-приёмная")
|
||||
Application_Component(lk, "Личный кабинет")
|
||||
Application_Function(submitFn, "Отправка формы")
|
||||
|
||||
Rel_Assignment(applicant, apply)
|
||||
Rel_Realization(apply, applySvc)
|
||||
Rel_Serving(webSvc, apply)
|
||||
Rel_Assignment(lk, submitFn)
|
||||
Rel_Realization(submitFn, webSvc)
|
||||
```
|
||||
|
||||
### 9.3. Application Cooperation
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Application_Component(crm, "CRM")
|
||||
Application_Component(billing, "Биллинг")
|
||||
Application_Service(crmApi, "API клиентов")
|
||||
Application_DataObject(client, "Карточка клиента")
|
||||
|
||||
Rel_Realization(crm, crmApi)
|
||||
Rel_Serving(crmApi, billing)
|
||||
Rel_Access_rw(crm, client)
|
||||
Rel_Access_r(billing, client)
|
||||
Rel_Flow(crm, billing, "событие оплаты")
|
||||
```
|
||||
|
||||
### 9.4. Layered
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Business_Process(sale, "Оформление заказа")
|
||||
Business_Object(order, "Заказ")
|
||||
Application_Service(shopSvc, "Интернет-магазин")
|
||||
Application_Component(shop, "ShopApp")
|
||||
Technology_Service(dbSvc, "СУБД")
|
||||
Technology_Node(dbHost, "DB Host")
|
||||
Technology_SystemSoftware(pg, "PostgreSQL")
|
||||
|
||||
Rel_Access_w(sale, order)
|
||||
Rel_Serving_Up(shopSvc, sale)
|
||||
Rel_Realization(shop, shopSvc)
|
||||
Rel_Serving_Up(dbSvc, shop)
|
||||
Rel_Assignment(dbHost, dbSvc)
|
||||
Rel_Composition(dbHost, pg)
|
||||
```
|
||||
|
||||
### 9.5. Motivation / Requirements Realization
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Motivation_Stakeholder(rector, "Ректор")
|
||||
Motivation_Driver(comp, "Конкуренция вузов")
|
||||
Motivation_Goal(digital, "Цифровизация приёма")
|
||||
Motivation_Requirement(online, "Заявление онлайн 24/7")
|
||||
Motivation_Constraint(152fz, "152-ФЗ")
|
||||
Application_Service(portal, "Портал абитуриента")
|
||||
|
||||
Rel_Association(rector, comp)
|
||||
Rel_Association(comp, digital)
|
||||
Rel_Realization(online, digital)
|
||||
Rel_Influence(152fz, online, "-")
|
||||
Rel_Realization(portal, online)
|
||||
```
|
||||
|
||||
### 9.6. Implementation & Migration
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Implementation_Plateau(asIs, "AS-IS 2025")
|
||||
Implementation_Plateau(toBe, "TO-BE 2026")
|
||||
Implementation_Gap(gap1, "Нет единого ЛК")
|
||||
Implementation_WorkPackage(wp, "Проект Единый ЛК")
|
||||
Implementation_Deliverable(d1, "Релиз 1.0")
|
||||
Implementation_Event(goLive, "Go-Live")
|
||||
|
||||
Rel_Association(gap1, asIs)
|
||||
Rel_Association(gap1, toBe)
|
||||
Rel_Realization(wp, d1)
|
||||
Rel_Triggering(d1, goLive)
|
||||
Rel_Triggering(goLive, toBe)
|
||||
```
|
||||
|
||||
### 9.7. Technology Usage
|
||||
|
||||
```plantuml
|
||||
LAYOUT_TOP_DOWN()
|
||||
Application_Component(api, "API Gateway")
|
||||
Technology_Node(k8s, "Kubernetes")
|
||||
Technology_Device(node1, "Worker x86")
|
||||
Technology_SystemSoftware(os, "Linux")
|
||||
Technology_CommunicationNetwork(net, "Cluster CNI")
|
||||
Technology_Artifact(image, "api:1.2.img")
|
||||
|
||||
Rel_Serving_Up(k8s, api)
|
||||
Rel_Composition(k8s, node1)
|
||||
Rel_Composition(node1, os)
|
||||
Rel_Association(node1, net)
|
||||
Rel_Assignment(node1, image)
|
||||
```
|
||||
|
||||
### 9.8. Process с Junction (в духе van den Berg)
|
||||
|
||||
```plantuml
|
||||
LAYOUT_LEFT_RIGHT()
|
||||
Business_Event(incident, "Инцидент")
|
||||
Business_Process(triage, "Триаж")
|
||||
Junction_Or(j1, "")
|
||||
Business_Process(restore, "Восстановление КФ")
|
||||
Business_Process(investigate, "Расследование")
|
||||
Business_Process(close, "Закрытие")
|
||||
|
||||
Rel_Triggering(incident, triage)
|
||||
Rel_Triggering(triage, j1)
|
||||
Rel_Triggering(j1, restore)
|
||||
Rel_Triggering(j1, investigate)
|
||||
Rel_Triggering(restore, close)
|
||||
Rel_Triggering(investigate, close)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Антипаттерны
|
||||
|
||||
| Плохо | Почему | Как надо |
|
||||
|-------|--------|----------|
|
||||
| Все связи `Rel_Association` | Теряется семантика | Serving / Realization / Assignment / Access |
|
||||
| `Application_Component` вместо `Business_Actor` | Разные аспекты и слои | Actor/Role для людей/орг. единиц |
|
||||
| Business Process «внутри» компонента без Assignment | Слои смешаны | Process на бизнес-слое; Serving от Application Service |
|
||||
| Serving от Role к Service | Направление наоборот | Service → Role/Process |
|
||||
| Realization Component → Process | Пропущен внутренний behavior | Component → Function → Service |
|
||||
| Flow вместо Access к Data Object | Flow — передача, Access — работа с объектом | `Rel_Access_r/w/rw` |
|
||||
| Один Layered на 40+ элементов | Нечитаемо | Несколько viewpoint’ов |
|
||||
| C4 `Person` / UML class в archimate-блоке | Другой язык | Только макросы ArchiMate |
|
||||
| Generic «Process» без слоя | В 3.2 слоя обязательны | `Business_Process` / `Application_Process` / … |
|
||||
|
||||
---
|
||||
|
||||
## 11. Источники и границы корпуса
|
||||
|
||||
### Норматив (при конфликте — сверху вниз)
|
||||
|
||||
1. **n221p.pdf** — ArchiMate 3.2 Reference Cards (N221): определения элементов и связей.
|
||||
2. **978940180955C_SMPL.pdf** (+ дубль SMPL-1) — sample C226: гл. 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 <archimate/Archimate>`)
|
||||
|
||||
### Примеры композиции (не словарь языка)
|
||||
|
||||
- hospital ZiRA preview (inkijkexemplaar) — reference architecture / C226.
|
||||
- **vandenBerg_MA_EEMCS.pdf** — §2.4 framework, motivational viewpoint, Junction в процессах BCM/DR.
|
||||
|
||||
### Не правила ArchiMate
|
||||
|
||||
- Article_37 (FEM), paper_41 (OntoUML/COVO), FASD (UML атак), 419 (единое окно), **2659430** (Packt Practical Cybersecurity Architecture — процесс кибер-архитектуры, не спецификация 3.2).
|
||||
|
||||
### Ограничения
|
||||
|
||||
- Полная Appendix B в sample отсутствует — матрица пар здесь практическая, не нормативная таблица B.5.
|
||||
- PDF спецификации в git не хранятся.
|
||||
- Старый PlantUML/Kroki без stdlib 3.2: fallback — `` ```uml `` + keyword `archimate`.
|
||||
- Пользовательский `md2gost.schemes.json`: встроенные схемы с `author: md2gost` синхронизируются с бандлом; свои правки (другой author) не затираются.
|
||||
|
||||
---
|
||||
|
||||
См. также: [schemes.md](schemes.md), меню **Шаблоны UML**, **Справка → Промпт для ИИ** (схема `archimate`).
|
||||
+25
-1
@@ -8,7 +8,9 @@
|
||||
python -m md2gost # GUI
|
||||
python -m md2gost --gui report.md
|
||||
python -m md2gost report.md -o out.docx --type coursework --check --strict
|
||||
python -m md2gost report.docx -o report.md # импорт Word → Markdown (word2md)
|
||||
python -m md2gost report.docx --check-pages # только проверка вёрстки готового DOCX
|
||||
python -m word2md report.docx -o report.md
|
||||
```
|
||||
|
||||
Справка: `python -m md2gost -h`.
|
||||
@@ -24,6 +26,10 @@ python -m md2gost report.docx --check-pages # только проверка в
|
||||
| `--emdash-to-hyphen` | Галочка «—» → «-» | по умолчанию выкл. |
|
||||
| `--hr-pagebreak` | Галочка --- → разрыв | по умолчанию `---` игнорируется |
|
||||
| `--title` / `--assignment` / `-t` | Настройки → Файлы | титул, задание, шаблон |
|
||||
| `--auto-title` / `--no-auto-title` | галочка «Автотитул» | автогенерация титула (по умолчанию выкл.) |
|
||||
| `--student` / `--group` | Настройки → Студент… | ФИО и группа для автотитула |
|
||||
| `--doc-author-from` / `--doc-author` | Настройки → Метаданные… | автор в свойствах DOCX (`os` / `student` / `custom`) |
|
||||
| `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by` | Настройки → Метаданные… | остальные поля File → сведения |
|
||||
| `--check` / `--check-only` / `--strict` | Галочки проверки ТЗ | замечания по структуре и источникам |
|
||||
| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) |
|
||||
| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` |
|
||||
@@ -34,6 +40,7 @@ python -m md2gost report.docx --check-pages # только проверка в
|
||||
| `--diagram-format` | Диаграммы | `png` / `svg` |
|
||||
| `--diagram-scale` | Диаграммы | качество PNG PlantUML / локального Mermaid (по умолчанию 2) |
|
||||
| `--page-start N` | Основные → Смещение страниц | начальный номер PAGE в теле; пусто = сквозной |
|
||||
| (нет CLI) | Диаграммы → «Скачать Graphviz» | portable Graphviz для DFD (`vendor` или `%LOCALAPPDATA%\md2gost\graphviz`) |
|
||||
| `--install-chromium` | Диаграммы → «Скачать headless Chromium» | Chromium для Mermaid в `%LOCALAPPDATA%\md2gost` |
|
||||
| `--schemes` | файл схем / Шаблоны UML | путь к `md2gost.schemes.json` |
|
||||
| `--styles` | Настройки → Файлы → Стили JSON | оверлей `md2gost.styles.json` (также рядом с `.md`); см. [styles.md](styles.md) |
|
||||
@@ -47,14 +54,31 @@ DOCX не умеет сам писать «Продолжение Таблицы
|
||||
## Меню GUI
|
||||
|
||||
- **Настройки → Файлы** — шаблон, титул, задание, стили JSON, выходной путь
|
||||
- **Настройки → Диаграммы** — PlantUML, Mermaid (Chromium), Kroki, формат, кэш includes
|
||||
- **Настройки → Студент…** — ФИО и группа (при первом запуске спрашивают сами)
|
||||
- **Настройки → Метаданные…** — свойства DOCX (автор, название, тема, теги, примечание)
|
||||
- **Настройки → Диаграммы** — PlantUML, Graphviz (DFD), Mermaid (Chromium), Kroki, формат, кэш includes
|
||||
- **Шаблоны UML** — редактор `md2gost.schemes.json`
|
||||
- **Справка → Инструкция / Документация / Схемы / Промпт для ИИ**
|
||||
(Документация — встроенный просмотр `docs/*.md`, вшито в exe)
|
||||
|
||||
## Автотитульник
|
||||
|
||||
Кратко: **по умолчанию выкл.** Вкл. — `--auto-title` или галочка «Автотитул». Рядом с `.md` — `info_conv.yaml`, в MD — опциональный блок `title` (`number` / `year`), ФИО и группа — **Настройки → Студент…** или `--student` / `--group`. Явный `--title` отключает генератор.
|
||||
|
||||
Подробно (примеры, таблица полей, приоритеты, FAQ): **[title.md](title.md)**.
|
||||
|
||||
## Свойства файла Word
|
||||
|
||||
По умолчанию в DOCX пишутся **автор = имя пользователя Windows** и примечание «Создано при помощи md2gost (ТЗ МИРЭА)». Даты создания/изменения ставятся на момент сборки, revision = 1 (чтобы не утекали 2013 год и revision из шаблона).
|
||||
|
||||
Задать свои поля: **Настройки → Метаданные…** (сохраняется в `md2gost.user.json`) или флаги `--doc-author-from` / `--doc-author` / `--doc-title` / `--doc-subject` / `--doc-keywords` / `--doc-comments` / `--doc-category` / `--doc-last-modified-by`. Источник автора: `os` (по умолчанию), `student` (ФИО из профиля), `custom` (строка). Пустое `--doc-comments` / пустое примечание в GUI — не писать штамп md2gost.
|
||||
|
||||
## Другие форматы
|
||||
|
||||
```bash
|
||||
python -m md2fodt report.md -o report.fodt # LibreOffice
|
||||
python -m word2md report.docx -o report.md # Word → Markdown (диалект md2gost)
|
||||
# PDF через LaTeX — см. md2latex/
|
||||
```
|
||||
|
||||
Импорт DOCX подробнее: [word2md.md](word2md.md). В GUI — кнопка «Импорт DOCX→MD» или drop `.docx`.
|
||||
|
||||
+7
-1
@@ -89,7 +89,7 @@ def f():
|
||||
|
||||
## Диаграмма → Рисунок
|
||||
|
||||
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, или id схемы (`c4`, `bpmn`, …).
|
||||
Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, `idef0`, `dfd`/`data-flow-diagram` (Graphviz вшитый/PATH или Kroki), или id схемы (`c4`, `usecase`, …).
|
||||
|
||||
- `+listing` — ещё и Листинг с исходником
|
||||
- `+landscape` — альбомная страница под широкий рисунок/таблицу
|
||||
@@ -144,6 +144,12 @@ $$
|
||||
- Тире в предложениях: «—» (по умолчанию сохраняется). Замена на «-»: `--emdash-to-hyphen`.
|
||||
- Строка `---` / `***` / `___` по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI.
|
||||
|
||||
## Автотитульник (метаданные в MD)
|
||||
|
||||
В начале файла можно указать блок с оградой `title` (номер работы, год, оверрайды полей). Рядом с файлом — `info_conv.yaml` с институтом, кафедрой, дисциплиной и преподавателем. ФИО и группа студента задаются в GUI / CLI, не в yaml. Автогенерация титула **по умолчанию выкл.** — включите `--auto-title` или галочку «Автотитул».
|
||||
|
||||
Подробно: [title.md](title.md).
|
||||
|
||||
## Запрещено в отчёте
|
||||
|
||||
- «рис.», «табл.»
|
||||
|
||||
+4
-3
@@ -9,11 +9,12 @@
|
||||
| [`generate-md.md`](../prompts/generate-md.md) | Базовый: диалект md2gost, чем отличается от обычного MD; UML/Mermaid рисует конвертер |
|
||||
| [`generate-mirea-report.md`](../prompts/generate-mirea-report.md) | Полный отчёт ГОСТ / МИРЭА (`coursework`, `practice`, `vkr`) |
|
||||
| [`generate-pis-custom-report.md`](../prompts/generate-pis-custom-report.md) | Итоговый отчёт ПИС (`PIS_custom`) |
|
||||
| [`emulate-student.md`](../prompts/emulate-student.md) | После любого generate-*: текст про предмет, не отчёт модели о сдаче ТЗ |
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
1. Откройте промпт (файл или GUI).
|
||||
2. В GUI включите нужные схемы (C4, BPMN, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`.
|
||||
1. Откройте промпт (файл или GUI). При необходимости добавьте `emulate-student` после базового generate-*.
|
||||
2. В GUI включите нужные схемы (C4, usecase, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`.
|
||||
3. Скопируйте собранный текст в ChatGPT / Claude / Cursor / Copilot.
|
||||
4. Следующим сообщением: тип работы, тема, черновик / требования.
|
||||
5. Сохраните ответ как `.md` и конвертируйте:
|
||||
@@ -24,7 +25,7 @@ python -m md2gost report.md -o report.docx --type coursework --check --strict
|
||||
|
||||
## Зачем кнопки схем
|
||||
|
||||
Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, Start/XOR/Flow для BPMN и т.д.) — в том числе пользовательских из «Шаблоны UML».
|
||||
Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, actor/usecase и т.д.) — в том числе пользовательских из «Шаблоны UML».
|
||||
|
||||
## Подробнее о синтаксисе
|
||||
|
||||
|
||||
+4
-1
@@ -38,11 +38,13 @@ md2gost-gui # то же GUI (entry point)
|
||||
|
||||
1. Перетащите `.md` в верхнюю область (или кликните по ней).
|
||||
2. Выберите тип документа (`practice` по умолчанию).
|
||||
3. При необходимости: Настройки → Файлы (титул, задание), Настройки → Диаграммы, меню «Шаблоны UML».
|
||||
3. При необходимости: Настройки → Файлы (титул, задание), **Настройки → Студент…** (ФИО и группа для автотитула), **Настройки → Метаданные…** (свойства DOCX), Настройки → Диаграммы, меню «Шаблоны UML».
|
||||
4. Нажмите «Конвертировать».
|
||||
|
||||
Справка в меню: **Инструкция**, **Схемы**, **Промпт для ИИ**.
|
||||
|
||||
Автотитул (`info_conv.yaml` + блок `title`): [title.md](title.md).
|
||||
|
||||
## Типы документов (кратко)
|
||||
|
||||
| `--type` | Назначение |
|
||||
@@ -57,5 +59,6 @@ md2gost-gui # то же GUI (entry point)
|
||||
## Следующие шаги
|
||||
|
||||
- Написать отчёт в диалекте md2gost: [markdown.md](markdown.md)
|
||||
- Автотитульник: [title.md](title.md)
|
||||
- Вставить диаграммы: [schemes.md](schemes.md)
|
||||
- Сгенерировать `.md` через ИИ: [prompts.md](prompts.md)
|
||||
|
||||
+158
-12
@@ -1,6 +1,6 @@
|
||||
# Схемы и диаграммы
|
||||
|
||||
В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki) или локальный Mermaid (браузер / QuickJS), иначе Kroki.
|
||||
В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki), локальный Mermaid (браузер / QuickJS), локальный IDEF0 (Pillow) или DFD через [data-flow-diagram](https://github.com/pbauermeister/dfd) (вшитый / системный Graphviz или Kroki).
|
||||
|
||||
## Синтаксис в markdown
|
||||
|
||||
@@ -32,13 +32,12 @@ Rel(user, app, "логин")
|
||||
| `` ```uml `` / `` ```plantuml `` | Сырой PlantUML (`@startuml`…`@enduml`) |
|
||||
| `` ```uml-<id> `` / `` ```<id> `` | Схема из `md2gost.schemes.json` (обёртка и `!include` добавляются сами) |
|
||||
| `` ```mermaid `` / `` ```mmd `` | Mermaid: браузер / QuickJS / Kroki |
|
||||
| `` ```bpmn `` / `` ```uml-bpmn `` | BPMN 2.0 (макросы библиотеки md2gost) |
|
||||
|
||||
**IDEF0 / DFD** конвертер не рисует — вставляйте готовый PNG как обычный Рисунок.
|
||||
| `` ```idef0 `` / `` ```uml-idef0 `` | IDEF0 (FIPS 183): локальный рендер, без PlantUML / Kroki |
|
||||
| `` ```dfd `` / `` ```uml-dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): вшитый Graphviz / PATH / Kroki |
|
||||
|
||||
## Встроенные схемы
|
||||
|
||||
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением (существующий файл не перезаписывается).
|
||||
Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением. При каждом запуске встроенные схемы (`author: md2gost`) синхронизируются с шаблоном: новые id добавляются, устаревшие (например удалённый `bpmn`) убираются, содержимое обновляется. Схемы с пустым или другим `author` не трогаются. Если правите встроенную в GUI и сохраняете — `author` сбрасывается, чтобы миграция не затёрла правки.
|
||||
|
||||
| id | Название | Оградка |
|
||||
|----|----------|---------|
|
||||
@@ -46,9 +45,9 @@ Rel(user, app, "логин")
|
||||
| `c4context` | C4 Context | `` ```uml-c4context `` |
|
||||
| `c4component` | C4 Component | `` ```uml-c4component `` |
|
||||
| `usecase` | Прецеденты | `` ```uml-usecase `` / `` ```usecase `` |
|
||||
| `bpmn` | BPMN 2.0 | `` ```bpmn `` / `` ```uml-bpmn `` |
|
||||
| `archimate` | ArchiMate 3.2 | `` ```uml-archimate `` / `` ```archimate `` |
|
||||
|
||||
Для схем в теле пишите **только макросы** (Person, Start, XOR, …) — без `@startuml` и без `!include`, если схема сама их добавляет.
|
||||
Для схем в теле пишите **только макросы** (Person, Rel, actor, Business_Actor, …) — без `@startuml` и без `!include`, если схема сама их добавляет.
|
||||
|
||||
### C4 (пример макросов)
|
||||
|
||||
@@ -58,14 +57,151 @@ Rel(user, app, "логин")
|
||||
|
||||
`actor`, `usecase`, связи `-->`, `<<include>>`, `<<extend>>`.
|
||||
|
||||
### BPMN
|
||||
### ArchiMate 3.2
|
||||
|
||||
`Pool` / `Lane`, `Start` / `StartMessage` / `End`, `UserTask` / `ServiceTask`, `XOR` / `AND` / `OR`, `Flow` / `CondFlow` / `DefaultFlow` / `MessageFlow`, …
|
||||
|
||||
Sequence Flow только внутри пула; между пулами — `MessageFlow`.
|
||||
`Business_Actor`, `Application_Service`, `Technology_Node`, `Rel_Serving` / `Rel_Realization` / `Rel_Assignment`, …
|
||||
Подробно: [archimate.md](archimate.md).
|
||||
|
||||
Полные шпаргалки — поля `docs` и `ai-prompt` в JSON; в GUI: **Шаблоны UML** и кнопки схем в **Справка → Промпт для ИИ**.
|
||||
|
||||
## IDEF0 (FIPS 183)
|
||||
|
||||
Оградка `` ```idef0 `` (то же: `` ```uml-idef0 ``). Текст — не XML: шапка формы, боксы, стрелки ICOM (Input слева, Control сверху, Output справа, Mechanism снизу). Контекст A-0 — один бокс; декомпозиция — несколько боксов «лесенкой» и связи между ними. Рендер локальный (Pillow), без Java и Kroki.
|
||||
|
||||
````markdown
|
||||
%a0 Контекстная диаграмма A-0
|
||||
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A-0
|
||||
number 1
|
||||
author Кинзябулатов Рамиль
|
||||
project Разработка универсальной модели торгового предприятия
|
||||
date 08.02.2022
|
||||
rev 31.03.2022
|
||||
status PUBLICATION
|
||||
context TOP
|
||||
|
||||
[A0] Распорядиться товаром
|
||||
<- Спрос
|
||||
^ Нормативная документация
|
||||
-> Товар
|
||||
v Персонал
|
||||
```
|
||||
````
|
||||
|
||||
Шапка/подвал NIST-формы (необязательные поля): `title`, `node`, `number`, `author`, `project`, `date`, `rev`, `status` (`WORKING` / `DRAFT` / `RECOMMENDED` / `PUBLICATION`), `context` (для A-0 — `TOP`), `used_at`, `reader`, `reader_date`, `notes`, `purpose`, `viewpoint`, `page`, `form` (`kit` по умолчанию, `plain` — без рамки).
|
||||
|
||||
Бокс: `[A0] Имя` или `box A0 Имя`. Стрелки к последнему боксу: `<-` / `in` вход, `^` / `control` управление, `->` / `out` выход, `v` / `mech` механизм. Явно: `A0 <- Спрос`.
|
||||
|
||||
Декомпозиция A0 (3 бокса, развилка, O→C, ICOM на рамке):
|
||||
|
||||
````markdown
|
||||
%a0 Декомпозиция A0
|
||||
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A0
|
||||
number 2
|
||||
author Кинзябулатов Рамиль
|
||||
project Разработка универсальной модели торгового предприятия
|
||||
date 08.02.2022
|
||||
rev 31.03.2022
|
||||
status PUBLICATION
|
||||
context A-0
|
||||
purpose Обеспечить наличие товара и его отпуск покупателю
|
||||
viewpoint Руководитель торгового предприятия
|
||||
|
||||
[A1] Принять и оценить спрос
|
||||
[A2] Закупить и хранить
|
||||
[A3] Отгрузить товар
|
||||
|
||||
A1 <- Спрос [I1]
|
||||
A2 <- Предложения поставщиков [I2]
|
||||
A1, A2, A3 ^ Нормативная документация [C1]
|
||||
A1 ^ Ассортиментная политика [C2]
|
||||
A3 ^ Условия поставки [C3]
|
||||
A1 -> A2 : план закупок
|
||||
A1 -> A2.C : график поставок
|
||||
A2 -> A3 : товар к отгрузке
|
||||
A3 -> Товар [O1]
|
||||
A3 -> Акт отгрузки [O2]
|
||||
A1, A2, A3 v Персонал [M1]
|
||||
A2 v Склад [M2]
|
||||
```
|
||||
````
|
||||
|
||||
Подписи к стрелкам — существительные рядом со стволом, не на линии; если связь не очевидна, рисуется squiggle (FIPS 183). Длинные фразы переносятся на 2–3 строки.
|
||||
|
||||
Короткий каркас:
|
||||
|
||||
```
|
||||
[A1] Принять заказ
|
||||
[A2] Отгрузить
|
||||
A1 <- Заявка [I1]
|
||||
A1 ^ Правила [C1]
|
||||
A1 -> A2 : заказ
|
||||
A1 -> A2, A3 : данные
|
||||
A1, A2 -> A3 : сводка
|
||||
A2 -> Товар [O1]
|
||||
A2 v Склад [M1]
|
||||
```
|
||||
|
||||
Туннель (скобки IDEF0): `(in Секрет` — на границе листа, `in) Локальный` — у бокса. Вызов другой модели: `call ИмяМодели`. Сторона порта: `A1.O -> A2.C : условие`.
|
||||
|
||||
## DFD (data-flow-diagram / SA/RT)
|
||||
|
||||
Оградка `` ```dfd `` (то же: `` ```uml-dfd ``, `` ```data-flow-diagram ``, `` ```yourdon ``). Синтаксис — пакет [pbauermeister/dfd](https://github.com/pbauermeister/dfd) (ставится с md2gost, Python 3.11+). Рендер: Graphviz (`dot` / `neato`) или Kroki (`graphviz`), не Pillow.
|
||||
|
||||
Порядок поиска Graphviz (как у `plantuml.jar`):
|
||||
|
||||
1. Вшитый `md2gost/vendor/graphviz` (exe-сборка / `python scripts/fetch_graphviz.py`)
|
||||
2. Кэш `%LOCALAPPDATA%\md2gost\graphviz` (GUI: **Настройки → Диаграммы → «Скачать Graphviz»**)
|
||||
3. `dot` / `neato` в `PATH`
|
||||
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
````markdown
|
||||
%dfd0 Контекстная DFD
|
||||
|
||||
```dfd
|
||||
style context
|
||||
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process System Система учёта заявок
|
||||
|
||||
Client --> System заявка
|
||||
System --> Client статус
|
||||
System --> Warehouse накладная
|
||||
```
|
||||
````
|
||||
|
||||
Ключевые слова узлов: `process`, `entity`, `store`, `control`, `channel`. Потоки: `-->`, `->>`, `<->`, сигналы `::>` / `<::`. Контекст: `style context`. Полный синтаксис — [документация Upstream](https://github.com/pbauermeister/dfd/blob/main/doc/README.md).
|
||||
|
||||
Декомпозиция (уровень 0):
|
||||
|
||||
````markdown
|
||||
%dfd1 DFD уровня 0
|
||||
|
||||
```dfd
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process Accept Принять заявку
|
||||
process Register Зарегистрировать
|
||||
process Ship Сформировать накладную
|
||||
store Orders Заявки
|
||||
|
||||
Client --> Accept заявка
|
||||
Accept --> Orders новая заявка
|
||||
Orders --> Register данные заявки
|
||||
Register --> Ship подтверждённая заявка
|
||||
Ship --> Warehouse накладная
|
||||
Ship --> Client уведомление
|
||||
```
|
||||
````
|
||||
|
||||
Зависимости: `data-flow-diagram` идёт с md2gost (Python ≥3.11). Без Graphviz офлайн DFD не нарисуется — либо кнопка «Скачать Graphviz» / `scripts/fetch_graphviz.py`, либо `--diagram-fallback remote` → kroki.io.
|
||||
|
||||
## Файл схем: поля
|
||||
|
||||
```json
|
||||
@@ -114,7 +250,15 @@ id схемы: латиница, цифры, `_`; начинается с бук
|
||||
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
|
||||
4. Локальный Kroki → `https://kroki.io` (если `--diagram-fallback remote`)
|
||||
|
||||
Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб растра PlantUML / локального Mermaid: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1).
|
||||
**IDEF0:** сразу Pillow (PNG, при `--diagram-format svg` ещё SVG). Java/Kroki не используются.
|
||||
|
||||
**DFD:**
|
||||
|
||||
1. [data-flow-diagram](https://github.com/pbauermeister/dfd) компилирует DSL → DOT
|
||||
2. Вшитый / кэш / PATH Graphviz (`dot`, для `style context` — `neato`)
|
||||
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб растра PlantUML / локального Mermaid / IDEF0: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1).
|
||||
|
||||
Кэш картинок: `{каталог_md}/.md2gost-cache/`.
|
||||
Кэш includes: `md2gost.include-cache.json` + папка `include-cache/` (сброс в GUI: Настройки → Диаграммы / Шаблоны UML).
|
||||
@@ -124,3 +268,5 @@ id схемы: латиница, цифры, `_`; начинается с бук
|
||||
1. Меню **Шаблоны UML** — добавить / править / сохранить.
|
||||
2. Или править `md2gost.schemes.json` вручную («Открыть JSON»).
|
||||
3. CLI: `--schemes путь.json`.
|
||||
|
||||
Чтобы правка встроенной схемы не сбрасывалась при обновлении приложения, оставьте пустой `author` (GUI делает это сама при сохранении изменённой встроенной схемы).
|
||||
|
||||
+187
@@ -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` рядом с приложением (см. каталог схем). Не коммитьте его с чужими ФИО в общие репозитории отчётов — это локальные данные студента.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Импорт Word → Markdown (word2md)
|
||||
|
||||
Обратный пайплайн: **DOCX → Markdown** в диалекте md2gost. Удобно, если отчёт уже набран в Word (или собран md2gost раньше), а дальше править удобнее в `.md`.
|
||||
|
||||
Это **эвристический** импорт, не lossless round-trip: исходник UML/Mermaid, `%id` из исходного md и язык fence у листинга из DOCX не восстанавливаются.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python -m word2md report.docx -o report.md
|
||||
word2md report.docx
|
||||
python -m md2gost report.docx -o report.md # тот же импорт через md2gost
|
||||
```
|
||||
|
||||
В GUI: перетащите `.docx` или кнопка **«Импорт DOCX→MD»**.
|
||||
|
||||
Картинки пишутся в каталог `<stem>_media/` рядом с `.md` (или `--media-dir`).
|
||||
|
||||
| Флаг | Смысл |
|
||||
|------|--------|
|
||||
| `-o` / `--output` | путь к `.md` |
|
||||
| `--media-dir` | каталог картинок |
|
||||
| `--keep-toc-pages` | не выкидывать строки содержания Word (по умолчанию только `[TOC]`) |
|
||||
| `--pagebreaks` | писать `---` на разрывах страниц |
|
||||
|
||||
## Что распознаётся
|
||||
|
||||
- заголовки `Heading N` / спецразделы → `# *ВВЕДЕНИЕ`, `# 1 …`
|
||||
- содержание → `# *СОДЕРЖАНИЕ` + `[TOC]` (поле TOC и строки `toc N` отбрасываются)
|
||||
- подписи `Рисунок N — …`, `Таблица N`, `Листинг N` (+ «Продолжение…» склеивается)
|
||||
- таблицы с merge → `^` / `>`
|
||||
- листинги стиля `Code`
|
||||
- формулы (таблица Formula / OMML) → `$$…$$`
|
||||
- библиография `[1]: …`, ссылки `[1]` в тексте
|
||||
- второй проход: `Рисунок 1.1` → `@Рисунок:fig1_1` (если номер известен по подписи)
|
||||
|
||||
Титул и бланк задания до первого H1/спецраздела пропускаются. Колонтитулы не импортируются.
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Нет восстановления PlantUML / Mermaid (только PNG/JPEG из файла)
|
||||
- OMML → формула — best-effort, не идеальный LaTeX
|
||||
- Надписи, SmartArt, сноски, плавающие рисунки — пропуск с записью в лог
|
||||
- Титул, свёрстанный большой таблицей, может попасть в поток, если walker уже «стартовал» — обычно титул до первого Heading пропускается
|
||||
|
||||
После импорта имеет смысл прогнать `python -m md2gost report.md --check`.
|
||||
+27
-6
@@ -1,11 +1,13 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""Faster onefile freeze: same portable exe (fonts + plantuml.jar + onefile).
|
||||
"""Faster onefile freeze: fonts + plantuml.jar + optional Graphviz + onefile.
|
||||
|
||||
Keeps matplotlib (system font lookup) and bundles plantuml.jar.
|
||||
Graphviz (vendor/) is included when present — for offline DFD.
|
||||
Speeds Analysis by pinning the Agg backend, skipping duplicate pygments
|
||||
collection (the stock hook already pulls all lexers), and not probing Qt/Gtk.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PyInstaller.utils.hooks import (
|
||||
@@ -18,9 +20,14 @@ from PyInstaller.utils.hooks import (
|
||||
os.environ["MPLBACKEND"] = "Agg"
|
||||
|
||||
ROOT = Path(SPECPATH).resolve()
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from pyi_bundle import collect_tkinterdnd2, prepare_tcltk_env, sanitize_analysis # noqa: E402
|
||||
|
||||
prepare_tcltk_env()
|
||||
|
||||
datas = [
|
||||
(str(ROOT / "md2gost" / "Template.docx"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "TitleTemplate.docx"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "mml2omml"), "md2gost"),
|
||||
(str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"),
|
||||
(str(ROOT / "prompts"), "prompts"),
|
||||
@@ -33,9 +40,20 @@ if not _plantuml.is_file():
|
||||
"сначала scripts/fetch_plantuml.py (jar обязателен в portable exe)"
|
||||
)
|
||||
datas.append((str(_plantuml), "md2gost/vendor"))
|
||||
_graphviz = ROOT / "md2gost" / "vendor" / "graphviz"
|
||||
if (_graphviz / "bin" / "dot.exe").is_file() or (_graphviz / "bin" / "dot").is_file():
|
||||
from md2gost.dfd import strip_graphviz_tcl_libs
|
||||
|
||||
strip_graphviz_tcl_libs(_graphviz)
|
||||
datas.append((str(_graphviz), "md2gost/vendor/graphviz"))
|
||||
|
||||
binaries = []
|
||||
hiddenimports = collect_submodules("md2gost")
|
||||
hiddenimports += collect_submodules("word2md")
|
||||
try:
|
||||
hiddenimports += collect_submodules("data_flow_diagram")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
datas += collect_data_files("certifi")
|
||||
datas += collect_data_files("lxml")
|
||||
@@ -95,10 +113,9 @@ hiddenimports += [
|
||||
]
|
||||
# Do NOT collect_submodules("pygments.lexers") here — hook-pygments.py already
|
||||
# collects all lexers/formatters/styles once during Analysis.
|
||||
try:
|
||||
hiddenimports += collect_submodules("tkinterdnd2")
|
||||
except Exception:
|
||||
pass
|
||||
_dnd_datas, _dnd_hidden = collect_tkinterdnd2()
|
||||
datas += _dnd_datas
|
||||
hiddenimports += _dnd_hidden
|
||||
|
||||
excludes = [
|
||||
"pytest",
|
||||
@@ -150,10 +167,14 @@ a = Analysis(
|
||||
hooksconfig={
|
||||
"matplotlib": {"backends": "Agg"},
|
||||
},
|
||||
runtime_hooks=[str(ROOT / "scripts" / "pyi_rth_mplbackend.py")],
|
||||
runtime_hooks=[
|
||||
str(ROOT / "scripts" / "pyi_rth_tcltk.py"),
|
||||
str(ROOT / "scripts" / "pyi_rth_mplbackend.py"),
|
||||
],
|
||||
excludes=excludes,
|
||||
noarchive=False,
|
||||
)
|
||||
sanitize_analysis(a)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
|
||||
+22
-5
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"student": "Тарасов Игорь Алексеевич",
|
||||
"group": "ИНБО-31-23",
|
||||
"metadata": {
|
||||
"author_source": "student",
|
||||
"author": "",
|
||||
"last_modified_by": "",
|
||||
"title": "",
|
||||
"subject": "",
|
||||
"keywords": "",
|
||||
"comments": "Создано при помощи md2gost (ТЗ МИРЭА)",
|
||||
"category": ""
|
||||
}
|
||||
}
|
||||
+10
-30
@@ -102,7 +102,7 @@ python -m md2gost report.md -o report.docx --toc manual
|
||||
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
|
||||
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
|
||||
| Листинг | `%id Подпись` перед code fence |
|
||||
| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет |
|
||||
| Диаграмма UML / Mermaid / IDEF0 / DFD / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````mermaid` / ````idef0` / ````dfd` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json` |
|
||||
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
|
||||
| Источник | `[1]` в тексте (кликабельная ссылка на пункт списка); `[1]: …` в списке |
|
||||
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
|
||||
@@ -136,7 +136,7 @@ Word COM режет по реальной пагинации. Без Word — у
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`) |
|
||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
|
||||
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
||||
@@ -208,34 +208,7 @@ Rel(user, app, "логин")
|
||||
```
|
||||
````
|
||||
|
||||
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **BPMN 2.0** — оградка ````bpmn` / ````uml-bpmn` (макросы `Start`, `UserTask`, `XOR`, `Flow`, `Pool`…; библиотека `diagrams/BPMN.puml`). **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** конвертер не рисует — вставляйте готовый PNG.
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw, "ок?")
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
````markdown
|
||||
%bpmn1 Процесс заявки
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw, "ок?")
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
````
|
||||
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** — ````idef0`: локальный Pillow (рамка NIST, стрелки ICOM). **DFD** — ````dfd`: [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/системный Graphviz или Kroki (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz»).
|
||||
|
||||
Обычному пользователю jar/Kroki указывать не нужно. Порядок для UML:
|
||||
|
||||
@@ -250,6 +223,12 @@ Mermaid:
|
||||
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
|
||||
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
DFD (````dfd`):
|
||||
|
||||
1. Пакет `data-flow-diagram` (зависимость md2gost) → DOT
|
||||
2. Graphviz: вшитый `md2gost/vendor/graphviz` (сборка exe / `scripts/fetch_graphviz.py`) → `%LOCALAPPDATA%\md2gost\graphviz` (GUI «Скачать Graphviz») → `PATH`
|
||||
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
|
||||
|
||||
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML и локальный Mermaid с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
|
||||
|
||||
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
|
||||
@@ -260,6 +239,7 @@ Mermaid:
|
||||
|
||||
```bash
|
||||
python scripts/fetch_plantuml.py
|
||||
python scripts/fetch_graphviz.py
|
||||
python scripts/fetch_mermaid.py
|
||||
python -m md2gost --install-chromium
|
||||
python -m md2gost report.md -o report.docx --diagram-fallback local
|
||||
|
||||
Binary file not shown.
+82
-2
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,7 +46,8 @@ class Converter:
|
||||
table_continuation: str = DEFAULT_TABLE_CONTINUATION,
|
||||
listing_continuation: str = DEFAULT_LISTING_CONTINUATION,
|
||||
hr_pagebreak: bool = False,
|
||||
styles_path: str | None = None):
|
||||
styles_path: str | None = None,
|
||||
markdown_text: str | None = None):
|
||||
if heading_numbering not in HEADING_NUMBERING_MODES:
|
||||
raise ValueError(
|
||||
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
|
||||
@@ -79,6 +80,7 @@ class Converter:
|
||||
self._document: Document = docx.Document(template_path)
|
||||
self._document._body.clear_content()
|
||||
md_dir = os.path.dirname(os.path.abspath(input_path)) or "."
|
||||
os.environ["WORKING_DIR"] = md_dir
|
||||
self._style_config = apply_document_styles(
|
||||
self._document,
|
||||
self._profile.style_preset,
|
||||
@@ -86,8 +88,11 @@ class Converter:
|
||||
styles_path=styles_path,
|
||||
)
|
||||
self._debugger = Debugger(self._document) if debug else None
|
||||
with open(input_path, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
if markdown_text is not None:
|
||||
raw = markdown_text
|
||||
else:
|
||||
with open(input_path, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
self._raw_markdown = raw
|
||||
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
|
||||
self.parser = Parser(self._document, self._preprocessed, hr_pagebreak=hr_pagebreak)
|
||||
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
"""DFD via pbauermeister/data-flow-diagram (Graphviz).
|
||||
|
||||
Fence: ```dfd (aliases ```uml-dfd, ```data-flow-diagram, ```yourdon, …).
|
||||
DSL: https://github.com/pbauermeister/dfd
|
||||
|
||||
Pipeline:
|
||||
1. Compile DSL → Graphviz DOT (`data-flow-diagram` Python API).
|
||||
2. Rasterize: bundled/system Graphviz (`dot`/`neato`), else Kroki `graphviz`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from . import package_dir
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
CACHE_KEY_PREFIX = "dfd-v3-pbauermeister\n"
|
||||
|
||||
# Context diagrams use neato; everything else uses dot (matches upstream templates).
|
||||
_ENGINE_CONTEXT = "neato"
|
||||
_ENGINE_DEFAULT = "dot"
|
||||
|
||||
# Official Windows portable build (same pattern as plantuml.jar vendor).
|
||||
GRAPHVIZ_VERSION = "16.1.0"
|
||||
GRAPHVIZ_ZIP_URL = (
|
||||
"https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/"
|
||||
f"{GRAPHVIZ_VERSION}/windows_10_cmake_Release_Graphviz-{GRAPHVIZ_VERSION}-win64.zip"
|
||||
)
|
||||
# Portable Graphviz ships Tcl 8.6.10 as tcl86t.dll. CPython 3.11's tkinter needs
|
||||
# exactly 8.6.12 — if both land in the onefile extract, GUI dies on Tk().
|
||||
# `dot` does not need the Tcl bindings.
|
||||
GRAPHVIZ_TCL_DLL_NAMES = frozenset(
|
||||
{
|
||||
"tcl86t.dll",
|
||||
"tk86t.dll",
|
||||
"tcl86.dll",
|
||||
"tk86.dll",
|
||||
"tcldot.dll",
|
||||
"tcldot_builtin.dll",
|
||||
"tclplan.dll",
|
||||
"gdtclft.dll",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DfdParseError(ValueError):
|
||||
"""Invalid DFD DSL or render tooling failure surfaced as parse/render error."""
|
||||
|
||||
|
||||
SAMPLE_CONTEXT = """\
|
||||
style context
|
||||
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process System Система учёта заявок
|
||||
|
||||
Client --> System заявка
|
||||
System --> Client статус
|
||||
System --> Warehouse накладная
|
||||
"""
|
||||
|
||||
SAMPLE_LEVEL0 = """\
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process Accept Принять заявку
|
||||
process Register Зарегистрировать
|
||||
process Ship Сформировать накладную
|
||||
store Orders Заявки
|
||||
|
||||
Client --> Accept заявка
|
||||
Accept --> Orders новая заявка
|
||||
Orders --> Register данные заявки
|
||||
Register --> Ship подтверждённая заявка
|
||||
Ship --> Warehouse накладная
|
||||
Ship --> Client уведомление
|
||||
"""
|
||||
|
||||
|
||||
def vendor_graphviz_root() -> Path:
|
||||
return Path(package_dir()) / "vendor" / "graphviz"
|
||||
|
||||
|
||||
def cached_graphviz_root() -> Path:
|
||||
if os.name == "nt":
|
||||
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
|
||||
else:
|
||||
root = Path.home() / ".md2gost"
|
||||
return root / "graphviz"
|
||||
|
||||
|
||||
def _graphviz_bin_candidates() -> list[Path]:
|
||||
roots: list[Path] = [vendor_graphviz_root()]
|
||||
if getattr(sys, "frozen", False):
|
||||
mei = getattr(sys, "_MEIPASS", None)
|
||||
if mei:
|
||||
roots.append(Path(mei) / "md2gost" / "vendor" / "graphviz")
|
||||
roots.append(Path(mei) / "vendor" / "graphviz")
|
||||
roots.append(Path(sys.executable).resolve().parent / "graphviz")
|
||||
roots.append(cached_graphviz_root())
|
||||
bins: list[Path] = []
|
||||
for root in roots:
|
||||
bins.append(root / "bin")
|
||||
bins.append(root)
|
||||
return bins
|
||||
|
||||
|
||||
def _engine_in_bin(bin_dir: Path, context: bool) -> Path | None:
|
||||
if not bin_dir.is_dir():
|
||||
return None
|
||||
names = (
|
||||
(_ENGINE_CONTEXT, _ENGINE_DEFAULT)
|
||||
if context
|
||||
else (_ENGINE_DEFAULT, _ENGINE_CONTEXT)
|
||||
)
|
||||
for name in names:
|
||||
for cand in (bin_dir / f"{name}.exe", bin_dir / name):
|
||||
if cand.is_file():
|
||||
return cand
|
||||
return None
|
||||
|
||||
|
||||
def resolve_graphviz_engine(context: bool = False) -> str | None:
|
||||
"""Path to Graphviz layout engine: vendor → cache → PATH."""
|
||||
for bin_dir in _graphviz_bin_candidates():
|
||||
hit = _engine_in_bin(bin_dir, context)
|
||||
if hit is not None:
|
||||
return str(hit)
|
||||
if context:
|
||||
return _which_many(
|
||||
_ENGINE_CONTEXT, f"{_ENGINE_CONTEXT}.exe", _ENGINE_DEFAULT, "dot.exe",
|
||||
)
|
||||
return _which_many(_ENGINE_DEFAULT, "dot.exe", _ENGINE_CONTEXT, "neato.exe")
|
||||
|
||||
|
||||
def _which_many(*names: str) -> str | None:
|
||||
for name in names:
|
||||
hit = shutil.which(name)
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_extracted_graphviz(extract_dir: Path, dest_root: Path) -> bool:
|
||||
"""Find bin/dot(.exe) under extract_dir and copy tree into dest_root."""
|
||||
dot = None
|
||||
for path in extract_dir.rglob("dot.exe"):
|
||||
if path.is_file():
|
||||
dot = path
|
||||
break
|
||||
if dot is None:
|
||||
for path in extract_dir.rglob("dot"):
|
||||
if path.is_file() and os.access(path, os.X_OK):
|
||||
dot = path
|
||||
break
|
||||
if dot is None:
|
||||
return False
|
||||
src_bin = dot.parent
|
||||
src_root = src_bin.parent if src_bin.name.lower() == "bin" else src_bin
|
||||
if dest_root.exists():
|
||||
shutil.rmtree(dest_root)
|
||||
dest_root.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src_root, dest_root)
|
||||
strip_graphviz_tcl_libs(dest_root)
|
||||
return (
|
||||
_engine_in_bin(dest_root / "bin", False) is not None
|
||||
or _engine_in_bin(dest_root, False) is not None
|
||||
)
|
||||
|
||||
|
||||
def strip_graphviz_tcl_libs(root: Path) -> int:
|
||||
"""Remove Graphviz Tcl/Tk DLLs so they cannot shadow CPython's tkinter."""
|
||||
root = Path(root)
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
removed = 0
|
||||
bins = [root / "bin", root]
|
||||
seen: set[Path] = set()
|
||||
for bin_dir in bins:
|
||||
if not bin_dir.is_dir():
|
||||
continue
|
||||
resolved = bin_dir.resolve()
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
for name in GRAPHVIZ_TCL_DLL_NAMES:
|
||||
path = bin_dir / name
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed += 1
|
||||
tcl_mod = root / "lib" / "graphviz" / "tcl"
|
||||
if tcl_mod.is_dir():
|
||||
shutil.rmtree(tcl_mod)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def fetch_graphviz(dest_root: Path | None = None, url: str = GRAPHVIZ_ZIP_URL) -> bool:
|
||||
"""Download official Graphviz win64 ZIP into dest_root (default: vendor)."""
|
||||
dest = Path(dest_root) if dest_root is not None else vendor_graphviz_root()
|
||||
if _engine_in_bin(dest / "bin", False) or _engine_in_bin(dest, False):
|
||||
strip_graphviz_tcl_libs(dest)
|
||||
return True
|
||||
try:
|
||||
_log.info("Downloading Graphviz %s …", GRAPHVIZ_VERSION)
|
||||
with requests.get(url, stream=True, timeout=180) as resp:
|
||||
resp.raise_for_status()
|
||||
with tempfile.TemporaryDirectory(prefix="md2gost-gv-") as tmp:
|
||||
zpath = Path(tmp) / "graphviz.zip"
|
||||
with zpath.open("wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=1024 * 256):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
extract_dir = Path(tmp) / "out"
|
||||
extract_dir.mkdir()
|
||||
with zipfile.ZipFile(zpath) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
if not _normalize_extracted_graphviz(extract_dir, dest):
|
||||
_log.warning("Graphviz ZIP распакован, но dot.exe не найден")
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
_log.warning("Не удалось скачать Graphviz: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def resolve_dfd_cli() -> list[str] | None:
|
||||
"""Argv prefix that compiles DSL → DOT without needing Graphviz for compile."""
|
||||
py = _which_many("py", "py.exe")
|
||||
if py:
|
||||
for ver in ("-3.14", "-3.13", "-3.12", "-3.11"):
|
||||
try:
|
||||
probe = subprocess.run(
|
||||
[py, ver, "-c", "import data_flow_diagram"],
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
if probe.returncode == 0:
|
||||
return [py, ver, "-c", _API_DOT_RUNNER]
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
exe = _which_many("data-flow-diagram", "data-flow-diagram.exe")
|
||||
if exe:
|
||||
return [exe]
|
||||
return None
|
||||
|
||||
|
||||
_API_DOT_RUNNER = r"""
|
||||
import sys
|
||||
from data_flow_diagram.dfd import build
|
||||
from data_flow_diagram import model
|
||||
|
||||
src_path = sys.argv[1]
|
||||
text = open(src_path, encoding="utf-8").read()
|
||||
root = model.SourceLine("", "<md2gost>", None, 0)
|
||||
opts = model.Options(
|
||||
format="dot",
|
||||
background_color=None,
|
||||
no_graph_title=True,
|
||||
no_check_dependencies=False,
|
||||
debug=False,
|
||||
)
|
||||
dot, graph_options = build(root, text, "", opts)
|
||||
sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
|
||||
sys.stdout.write(dot)
|
||||
sys.stderr.write("\n__MD2GOST_DFD_CONTEXT__=%s\n" % int(bool(graph_options.is_context)))
|
||||
"""
|
||||
|
||||
|
||||
def _is_context_diagram(source: str) -> bool:
|
||||
return bool(re.search(r"(?im)^\s*style\s+context\b", source))
|
||||
|
||||
|
||||
def _compile_dot(source: str) -> tuple[str, bool]:
|
||||
"""Return (dot_text, is_context). Raises DfdParseError."""
|
||||
text = (source or "").strip()
|
||||
if not text:
|
||||
raise DfdParseError("пустой DFD")
|
||||
|
||||
try:
|
||||
from data_flow_diagram.dfd import build as dfd_build
|
||||
from data_flow_diagram import model as dfd_model
|
||||
|
||||
root = dfd_model.SourceLine("", "<md2gost>", None, 0)
|
||||
opts = dfd_model.Options(
|
||||
format="dot",
|
||||
background_color=None,
|
||||
no_graph_title=True,
|
||||
no_check_dependencies=False,
|
||||
debug=False,
|
||||
)
|
||||
dot, graph_options = dfd_build(root, text, "", opts)
|
||||
return dot, bool(graph_options.is_context)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise DfdParseError(str(exc)) from exc
|
||||
|
||||
cli = resolve_dfd_cli()
|
||||
if not cli:
|
||||
raise DfdParseError(
|
||||
"Пакет data-flow-diagram не найден. "
|
||||
"Установите зависимости проекта (Python 3.11+) — "
|
||||
"https://github.com/pbauermeister/dfd"
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="md2gost-dfd-") as tmp:
|
||||
src_path = Path(tmp) / "diagram.dfd"
|
||||
src_path.write_text(text + "\n", encoding="utf-8")
|
||||
if cli[-1] == _API_DOT_RUNNER or (len(cli) >= 2 and cli[-2] == "-c"):
|
||||
cmd = list(cli) + [str(src_path)]
|
||||
elif len(cli) == 1:
|
||||
cmd = cli + [str(src_path), "-f", "dot", "-o", "-", "--no-graph-title"]
|
||||
else:
|
||||
cmd = list(cli) + [str(src_path)]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise DfdParseError(f"Не удалось запустить data-flow-diagram: {exc}") from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise DfdParseError("Таймаут data-flow-diagram") from exc
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = (proc.stderr or proc.stdout or "").strip() or f"код {proc.returncode}"
|
||||
raise DfdParseError(f"DFD: {err}")
|
||||
|
||||
dot = proc.stdout or ""
|
||||
if not dot.strip():
|
||||
raise DfdParseError("data-flow-diagram вернул пустой DOT")
|
||||
|
||||
is_ctx = _is_context_diagram(text)
|
||||
m = re.search(r"__MD2GOST_DFD_CONTEXT__=(\d+)", proc.stderr or "")
|
||||
if m:
|
||||
is_ctx = bool(int(m.group(1)))
|
||||
return dot, is_ctx
|
||||
|
||||
|
||||
def _graphviz_run_env(engine: str) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
bin_dir = str(Path(engine).resolve().parent)
|
||||
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
|
||||
env["GVBINDIR"] = bin_dir
|
||||
return env
|
||||
|
||||
|
||||
def _readable_dot(dot: str) -> str:
|
||||
"""Make Graphviz DFD easier to read in printed Word reports."""
|
||||
text = dot
|
||||
text = re.sub(
|
||||
r'edge\[color=gray fontname="times-italic" fontsize=\d+\]',
|
||||
'edge[color=black fontname="helvetica" fontsize=13]',
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r'node\[fontname="helvetica" fontsize=\d+\]',
|
||||
'node[fontname="helvetica" fontsize=13]',
|
||||
text,
|
||||
)
|
||||
if "dpi=" not in text:
|
||||
text = text.replace("digraph D {", 'digraph D {\n graph[dpi=180 nodesep=0.55 ranksep=0.7]', 1)
|
||||
return text
|
||||
|
||||
|
||||
def _render_dot_local(dot: str, out_path: Path, fmt: str, *, context: bool) -> bool:
|
||||
engine = resolve_graphviz_engine(context=context)
|
||||
if not engine:
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
[engine, f"-T{fmt}", f"-o{out_path}"],
|
||||
input=_readable_dot(dot),
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=120,
|
||||
env=_graphviz_run_env(engine),
|
||||
)
|
||||
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
|
||||
_log.warning("Graphviz %s failed: %s", engine, exc)
|
||||
return False
|
||||
return out_path.is_file() and out_path.stat().st_size > 0
|
||||
|
||||
|
||||
def _render_dot_kroki(dot: str, out_path: Path, fmt: str) -> bool:
|
||||
try:
|
||||
from .diagram_renderer import (
|
||||
DEFAULT_KROKI_URL,
|
||||
REMOTE_KROKI_URL,
|
||||
_CONFIG,
|
||||
_render_kroki,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
local = (_CONFIG.kroki_url or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL).rstrip("/")
|
||||
if _render_kroki(dot, "graphviz", local, out_path, fmt):
|
||||
return True
|
||||
if _CONFIG.fallback == "remote":
|
||||
return _render_kroki(dot, "graphviz", REMOTE_KROKI_URL, out_path, fmt)
|
||||
return False
|
||||
|
||||
|
||||
def dfd_engine_status() -> str:
|
||||
cli_ok = False
|
||||
try:
|
||||
import data_flow_diagram # noqa: F401
|
||||
|
||||
cli_ok = True
|
||||
except ImportError:
|
||||
cli_ok = resolve_dfd_cli() is not None
|
||||
gv = resolve_graphviz_engine(False)
|
||||
parts = ["DFD (pbauermeister/data-flow-diagram), оградка ```dfd"]
|
||||
parts.append(
|
||||
"компилятор: data-flow-diagram OK"
|
||||
if cli_ok
|
||||
else "компилятор: не найден (зависимость md2gost, Python 3.11+)"
|
||||
)
|
||||
if gv:
|
||||
parts.append(f"Graphviz: {gv}")
|
||||
else:
|
||||
parts.append(
|
||||
"Graphviz: нет — «Скачать Graphviz» / scripts/fetch_graphviz.py или Kroki"
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def write_dfd_outputs(
|
||||
source: str,
|
||||
out_png: Path,
|
||||
out_svg: Path | None,
|
||||
*,
|
||||
scale: float = 2.0,
|
||||
) -> bool:
|
||||
"""Compile DSL and write PNG (+ optional SVG). Raises DfdParseError."""
|
||||
del scale
|
||||
dot, is_context = _compile_dot(source)
|
||||
out_png = Path(out_png)
|
||||
out_png.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ok_png = _render_dot_local(dot, out_png, "png", context=is_context)
|
||||
if not ok_png:
|
||||
ok_png = _render_dot_kroki(dot, out_png, "png")
|
||||
if not ok_png:
|
||||
raise DfdParseError(
|
||||
"Не удалось отрисовать DFD: скачайте Graphviz "
|
||||
"(GUI: Диаграммы → Скачать Graphviz, или scripts/fetch_graphviz.py) "
|
||||
"или включите Kroki (--diagram-fallback remote). "
|
||||
"DSL: https://github.com/pbauermeister/dfd"
|
||||
)
|
||||
|
||||
if out_svg is not None:
|
||||
out_svg = Path(out_svg)
|
||||
ok_svg = _render_dot_local(dot, out_svg, "svg", context=is_context)
|
||||
if not ok_svg:
|
||||
_render_dot_kroki(dot, out_svg, "svg")
|
||||
|
||||
_log.info("DFD ok → %s", out_png.name)
|
||||
return True
|
||||
@@ -33,11 +33,16 @@ DIAGRAM_LANGS = frozenset(
|
||||
"c4context",
|
||||
"c4component",
|
||||
"usecase",
|
||||
"bpmn",
|
||||
"mermaid",
|
||||
"mmd",
|
||||
"idef0",
|
||||
"dfd",
|
||||
}
|
||||
)
|
||||
# Native Pillow renderers — never jar / Kroki.
|
||||
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd"})
|
||||
# Local hi-res path uses a scale-specific cache key.
|
||||
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd"})
|
||||
FallbackMode = Literal["local", "remote", "off"]
|
||||
DiagramFormat = Literal["png", "svg"]
|
||||
|
||||
@@ -255,6 +260,13 @@ def diagram_engine_status(explicit_jar: str | None = None) -> str:
|
||||
lines = lines + "\n" + mermaid_engine_status()
|
||||
except Exception as exc:
|
||||
lines = lines + f"\nMermaid: статус недоступен ({exc})"
|
||||
lines = lines + "\nIDEF0: локальный рендер (Pillow), оградка ```idef0"
|
||||
try:
|
||||
from .dfd import dfd_engine_status
|
||||
|
||||
lines = lines + "\n" + dfd_engine_status()
|
||||
except Exception as exc:
|
||||
lines = lines + f"\nDFD: статус недоступен ({exc})"
|
||||
return lines
|
||||
|
||||
|
||||
@@ -424,14 +436,23 @@ def render_diagram(
|
||||
cache_prefix = CACHE_KEY_PREFIX
|
||||
except Exception:
|
||||
cache_prefix = "mmd-local-v4\n"
|
||||
elif kroki_type == "idef0":
|
||||
from .idef0 import CACHE_KEY_PREFIX as IDEF0_CACHE_PREFIX
|
||||
|
||||
# Mermaid local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
|
||||
cache_prefix = IDEF0_CACHE_PREFIX
|
||||
elif kroki_type == "dfd":
|
||||
from .dfd import CACHE_KEY_PREFIX as DFD_CACHE_PREFIX
|
||||
|
||||
cache_prefix = DFD_CACHE_PREFIX
|
||||
|
||||
# Local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
|
||||
local_prefix = cache_prefix
|
||||
if kroki_type == "mermaid" and scale > 1:
|
||||
if kroki_type in SCALED_LOCAL_TYPES and scale > 1:
|
||||
local_prefix = f"{cache_prefix}scale={scale:g}\n"
|
||||
|
||||
out_png, out_svg_path = _cache_paths(
|
||||
prepared, cache_dir, cache_prefix=local_prefix if kroki_type == "mermaid" else cache_prefix,
|
||||
prepared, cache_dir,
|
||||
cache_prefix=local_prefix if kroki_type in SCALED_LOCAL_TYPES else cache_prefix,
|
||||
)
|
||||
# Kroki / remote paths (mermaid): never share the scaled local key.
|
||||
kroki_png, kroki_svg_path = out_png, out_svg_path
|
||||
@@ -453,7 +474,7 @@ def render_diagram(
|
||||
has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0
|
||||
if not want_svg or has_svg:
|
||||
hit_scale = pixel_scale
|
||||
if kroki_type == "mermaid" and scale > 1 and local_prefix != cache_prefix:
|
||||
if kroki_type in SCALED_LOCAL_TYPES and scale > 1 and local_prefix != cache_prefix:
|
||||
hit_scale = scale
|
||||
return DiagramRenderResult(
|
||||
png_path=str(out_png),
|
||||
@@ -505,6 +526,30 @@ def render_diagram(
|
||||
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
|
||||
return _result()
|
||||
|
||||
# 1a) Native Pillow (IDEF0 / DFD) — never Kroki / PlantUML
|
||||
if kroki_type in LOCAL_ONLY_TYPES:
|
||||
if kroki_type == "idef0":
|
||||
from .idef0 import Idef0ParseError, write_idef0_outputs
|
||||
|
||||
try:
|
||||
if write_idef0_outputs(prepared, out_png, svg_target, scale=scale):
|
||||
return _result(pscale=scale if scale > 1 else 1.0)
|
||||
except Idef0ParseError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
raise RuntimeError("Не удалось отрендерить IDEF0-диаграмму")
|
||||
|
||||
if kroki_type == "dfd":
|
||||
from .dfd import DfdParseError, write_dfd_outputs
|
||||
|
||||
try:
|
||||
if write_dfd_outputs(prepared, out_png, svg_target, scale=scale):
|
||||
return _result(pscale=scale if scale > 1 else 1.0)
|
||||
except DfdParseError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
raise RuntimeError("Не удалось отрендерить DFD-диаграмму")
|
||||
|
||||
raise RuntimeError(f"Неизвестный локальный тип диаграммы: {kroki_type}")
|
||||
|
||||
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
|
||||
if kroki_type == "mermaid":
|
||||
try:
|
||||
|
||||
+142
-15
@@ -21,10 +21,33 @@ _log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMES_FILENAME = "md2gost.schemes.json"
|
||||
SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
APP_SCHEME_AUTHOR = "md2gost"
|
||||
BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"})
|
||||
# Rendered via Kroki only (not PlantUML jar / schemes).
|
||||
KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"})
|
||||
KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"}
|
||||
# Local native renderers (not PlantUML / Kroki / schemes.json).
|
||||
NATIVE_DIAGRAM_LANGS = {
|
||||
"idef0": "idef0",
|
||||
"uml-idef0": "idef0",
|
||||
"dfd": "dfd",
|
||||
"uml-dfd": "dfd",
|
||||
"data-flow-diagram": "dfd",
|
||||
"yourdon": "dfd",
|
||||
"uml-yourdon": "dfd",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemesMigrationResult:
|
||||
added: list[str] = field(default_factory=list)
|
||||
updated: list[str] = field(default_factory=list)
|
||||
removed: list[str] = field(default_factory=list)
|
||||
written: bool = False
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.added or self.updated or self.removed)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -82,22 +105,84 @@ def user_schemes_path(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / SCHEMES_FILENAME
|
||||
|
||||
|
||||
def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
|
||||
"""True if scheme is managed by the app template (author=md2gost)."""
|
||||
return scheme.author.strip().lower() == APP_SCHEME_AUTHOR
|
||||
|
||||
|
||||
def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]:
|
||||
return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
|
||||
|
||||
|
||||
def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult:
|
||||
"""
|
||||
Sync app-owned schemes in the user file with the bundled template.
|
||||
|
||||
- Bundled ids: always present; overwrite user entries with author=md2gost.
|
||||
- User overrides of bundled ids (empty/other author): kept as-is.
|
||||
- Orphaned author=md2gost ids no longer in the template: removed.
|
||||
- Other custom schemes: kept.
|
||||
"""
|
||||
result = SchemesMigrationResult()
|
||||
path = user_schemes_path(base)
|
||||
if not path.is_file():
|
||||
return result
|
||||
|
||||
bundled = _load_schemes_file(bundled_schemes_path())
|
||||
user = _load_schemes_file(path)
|
||||
out: dict[str, DiagramScheme] = {}
|
||||
|
||||
for sid, scheme in user.items():
|
||||
if sid in bundled:
|
||||
if is_app_owned_scheme(scheme):
|
||||
continue # step below writes bundled
|
||||
out[sid] = scheme
|
||||
continue
|
||||
if is_app_owned_scheme(scheme):
|
||||
result.removed.append(sid)
|
||||
_log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid)
|
||||
continue
|
||||
out[sid] = scheme
|
||||
|
||||
for sid, scheme in bundled.items():
|
||||
if sid in out:
|
||||
continue # user-owned override
|
||||
prev = user.get(sid)
|
||||
if prev is None:
|
||||
result.added.append(sid)
|
||||
_log.info("Миграция схем: добавлена «%s»", sid)
|
||||
elif prev.to_dict() != scheme.to_dict():
|
||||
result.updated.append(sid)
|
||||
_log.info("Миграция схем: обновлена «%s»", sid)
|
||||
out[sid] = scheme
|
||||
|
||||
if _schemes_payload(out) == _schemes_payload(user):
|
||||
result.added.clear()
|
||||
result.updated.clear()
|
||||
result.removed.clear()
|
||||
return result
|
||||
|
||||
save_schemes(out, path)
|
||||
result.written = True
|
||||
return result
|
||||
|
||||
|
||||
def ensure_user_schemes(base: Path | None = None) -> Path:
|
||||
"""
|
||||
On first run copy bundled template next to the app.
|
||||
Never overwrite an existing user file.
|
||||
Ensure md2gost.schemes.json exists next to the app, then sync app-owned
|
||||
schemes from the bundled template (add / update / remove orphans).
|
||||
"""
|
||||
dest = user_schemes_path(base)
|
||||
if dest.is_file():
|
||||
return dest
|
||||
src = bundled_schemes_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
shutil.copyfile(src, dest)
|
||||
_log.info("Создан файл схем: %s", dest)
|
||||
else:
|
||||
dest.write_text("{}\n", encoding="utf-8")
|
||||
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
|
||||
if not dest.is_file():
|
||||
src = bundled_schemes_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
shutil.copyfile(src, dest)
|
||||
_log.info("Создан файл схем: %s", dest)
|
||||
else:
|
||||
dest.write_text("{}\n", encoding="utf-8")
|
||||
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
|
||||
migrate_user_schemes(base)
|
||||
return dest
|
||||
|
||||
|
||||
@@ -129,7 +214,7 @@ def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
|
||||
|
||||
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
|
||||
dest = path or user_schemes_path()
|
||||
payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
|
||||
payload = _schemes_payload(schemes)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
@@ -138,12 +223,50 @@ def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) ->
|
||||
return dest
|
||||
|
||||
|
||||
def claim_user_scheme_if_diverged(
|
||||
scheme: DiagramScheme,
|
||||
*,
|
||||
bundled: dict[str, DiagramScheme] | None = None,
|
||||
) -> DiagramScheme:
|
||||
"""
|
||||
If scheme id is in the app template and content differs while still
|
||||
author=md2gost, clear author so migration will not overwrite user edits.
|
||||
"""
|
||||
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
|
||||
scheme.id
|
||||
)
|
||||
if stock is None:
|
||||
return scheme
|
||||
if not is_app_owned_scheme(scheme):
|
||||
return scheme
|
||||
if scheme.to_dict() == stock.to_dict():
|
||||
return scheme
|
||||
return DiagramScheme(
|
||||
id=scheme.id,
|
||||
title=scheme.title,
|
||||
version=scheme.version,
|
||||
author="",
|
||||
docs=scheme.docs,
|
||||
ai_prompt=scheme.ai_prompt,
|
||||
includes=list(scheme.includes),
|
||||
prefix=scheme.prefix,
|
||||
postfix=scheme.postfix,
|
||||
theme=scheme.theme,
|
||||
)
|
||||
|
||||
|
||||
def native_diagram_type(lang: str) -> str | None:
|
||||
"""Kroki/PlantUML-independent diagram type, or None."""
|
||||
lang = (lang or "").lower().strip()
|
||||
return NATIVE_DIAGRAM_LANGS.get(lang)
|
||||
|
||||
|
||||
def scheme_id_from_lang(lang: str) -> str | None:
|
||||
"""Normalize fence lang to scheme id, or None for bare uml/plantuml."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if not lang:
|
||||
return None
|
||||
if lang in BUILTIN_DIAGRAM_LANGS:
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
|
||||
return None
|
||||
if lang.startswith("uml-"):
|
||||
sid = lang[4:]
|
||||
@@ -224,7 +347,7 @@ def get_scheme(scheme_id: str) -> DiagramScheme | None:
|
||||
def is_diagram_lang(lang: str) -> bool:
|
||||
"""True if fence should render as a diagram (not a plain listing)."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS:
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
|
||||
return True
|
||||
if lang.startswith("uml-"):
|
||||
# Force diagram path so missing scheme becomes an error, not a listing.
|
||||
@@ -316,6 +439,10 @@ def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) ->
|
||||
if lang in KROKI_DIAGRAM_LANGS:
|
||||
return text, KROKI_TYPE_BY_LANG[lang]
|
||||
|
||||
native = native_diagram_type(lang)
|
||||
if native:
|
||||
return text, native
|
||||
|
||||
sid = scheme_id_from_lang(lang)
|
||||
|
||||
if sid is None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 <archimate/Archimate>\n!theme archimate-standard from <archimate/themes>\n",
|
||||
"postfix": "\n@enduml"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""DOCX core properties (File → Info) for generated reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from getpass import getuser
|
||||
from typing import Any
|
||||
|
||||
CORE_PROP_MAX = 255
|
||||
AUTHOR_SOURCES = ("os", "student", "custom")
|
||||
DEFAULT_AUTHOR_SOURCE = "os"
|
||||
DEFAULT_DOC_COMMENTS = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||
|
||||
|
||||
def os_user() -> str:
|
||||
try:
|
||||
return (getuser() or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def clip_core(value: str | None) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
if len(text) > CORE_PROP_MAX:
|
||||
return text[:CORE_PROP_MAX]
|
||||
return text
|
||||
|
||||
|
||||
def _normalize_source(value: str | None) -> str:
|
||||
src = (value or DEFAULT_AUTHOR_SOURCE).strip().lower()
|
||||
return src if src in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentMetadata:
|
||||
author_source: str = DEFAULT_AUTHOR_SOURCE
|
||||
author: str = ""
|
||||
last_modified_by: str = ""
|
||||
title: str = ""
|
||||
subject: str = ""
|
||||
keywords: str = ""
|
||||
comments: str = DEFAULT_DOC_COMMENTS
|
||||
category: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"author_source": _normalize_source(self.author_source),
|
||||
"author": (self.author or "").strip(),
|
||||
"last_modified_by": (self.last_modified_by or "").strip(),
|
||||
"title": (self.title or "").strip(),
|
||||
"subject": (self.subject or "").strip(),
|
||||
"keywords": (self.keywords or "").strip(),
|
||||
"comments": "" if self.comments is None else str(self.comments).strip(),
|
||||
"category": (self.category or "").strip(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: object) -> DocumentMetadata:
|
||||
if not isinstance(raw, dict):
|
||||
return cls()
|
||||
comments = raw.get("comments")
|
||||
if comments is None:
|
||||
comments_s = DEFAULT_DOC_COMMENTS
|
||||
else:
|
||||
comments_s = str(comments).strip()
|
||||
return cls(
|
||||
author_source=_normalize_source(str(raw.get("author_source") or "")),
|
||||
author=str(raw.get("author") or "").strip(),
|
||||
last_modified_by=str(raw.get("last_modified_by") or "").strip(),
|
||||
title=str(raw.get("title") or "").strip(),
|
||||
subject=str(raw.get("subject") or "").strip(),
|
||||
keywords=str(raw.get("keywords") or "").strip(),
|
||||
comments=comments_s,
|
||||
category=str(raw.get("category") or "").strip(),
|
||||
)
|
||||
|
||||
|
||||
def _pick(override: str | None, fallback: str) -> str:
|
||||
if override is not None:
|
||||
return str(override)
|
||||
return fallback
|
||||
|
||||
|
||||
def resolve_document_metadata(
|
||||
*,
|
||||
stored: DocumentMetadata | None = None,
|
||||
student: str | None = None,
|
||||
author_source: str | None = None,
|
||||
author: str | None = None,
|
||||
last_modified_by: str | None = None,
|
||||
title: str | None = None,
|
||||
subject: str | None = None,
|
||||
keywords: str | None = None,
|
||||
comments: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> DocumentMetadata:
|
||||
"""Merge CLI/request overrides over the saved profile into concrete core properties."""
|
||||
base = stored or DocumentMetadata()
|
||||
src = _normalize_source(author_source or base.author_source)
|
||||
|
||||
if author is not None:
|
||||
resolved_author = str(author).strip()
|
||||
elif src == "custom":
|
||||
resolved_author = (base.author or "").strip()
|
||||
elif src == "student":
|
||||
resolved_author = (student or "").strip()
|
||||
else:
|
||||
resolved_author = ""
|
||||
if not resolved_author:
|
||||
resolved_author = os_user()
|
||||
|
||||
last_mod = _pick(last_modified_by, base.last_modified_by).strip()
|
||||
if not last_mod:
|
||||
last_mod = resolved_author
|
||||
|
||||
return DocumentMetadata(
|
||||
author_source=src,
|
||||
author=resolved_author,
|
||||
last_modified_by=last_mod,
|
||||
title=_pick(title, base.title).strip(),
|
||||
subject=_pick(subject, base.subject).strip(),
|
||||
keywords=_pick(keywords, base.keywords).strip(),
|
||||
comments=_pick(comments, base.comments).strip(),
|
||||
category=_pick(category, base.category).strip(),
|
||||
)
|
||||
|
||||
|
||||
def apply_document_metadata(
|
||||
document: Any,
|
||||
meta: DocumentMetadata,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
stamp = now or datetime.utcnow()
|
||||
if stamp.tzinfo is not None:
|
||||
stamp = stamp.replace(tzinfo=None)
|
||||
cp = document.core_properties
|
||||
cp.author = clip_core(meta.author)
|
||||
cp.last_modified_by = clip_core(meta.last_modified_by or meta.author)
|
||||
cp.title = clip_core(meta.title)
|
||||
cp.subject = clip_core(meta.subject)
|
||||
cp.keywords = clip_core(meta.keywords)
|
||||
cp.comments = clip_core(meta.comments)
|
||||
cp.category = clip_core(meta.category)
|
||||
cp.created = stamp
|
||||
cp.modified = stamp
|
||||
cp.revision = 1
|
||||
+395
-20
@@ -13,6 +13,7 @@ from .diagram_renderer import diagram_engine_status
|
||||
from .diagram_schemes import (
|
||||
DiagramScheme,
|
||||
SCHEME_ID_RE,
|
||||
claim_user_scheme_if_diverged,
|
||||
ensure_user_schemes,
|
||||
get_schemes,
|
||||
load_schemes_from_path,
|
||||
@@ -20,7 +21,7 @@ from .diagram_schemes import (
|
||||
save_schemes,
|
||||
user_schemes_path,
|
||||
)
|
||||
from .dnd import enable_file_drop, first_markdown
|
||||
from .dnd import enable_file_drop, first_docx, first_markdown
|
||||
from .help_content import (
|
||||
SCHEMES_HELP,
|
||||
USAGE_HELP,
|
||||
@@ -200,6 +201,8 @@ class Md2GostApp:
|
||||
self._win_files: tk.Toplevel | None = None
|
||||
self._win_diagrams: tk.Toplevel | None = None
|
||||
self._win_schemes: tk.Toplevel | None = None
|
||||
self._win_student: tk.Toplevel | None = None
|
||||
self._win_meta: tk.Toplevel | None = None
|
||||
self._win_help: dict[str, tk.Toplevel] = {}
|
||||
|
||||
ensure_user_schemes()
|
||||
@@ -209,6 +212,7 @@ class Md2GostApp:
|
||||
self._build()
|
||||
self._apply_request(self.req)
|
||||
self.root.after(300, self._setup_dnd)
|
||||
self.root.after(400, self._ensure_student_profile)
|
||||
|
||||
if self.req.filename:
|
||||
self._set_markdown(self.req.filename, from_user_output=bool(self.req.output))
|
||||
@@ -229,6 +233,7 @@ class Md2GostApp:
|
||||
style.configure("Title.TLabel", font=font_title, foreground=NAVY, background=PAPER)
|
||||
style.configure("Hint.TLabel", foreground=MUTED, background=PAPER)
|
||||
style.configure("TCheckbutton", background=PAPER, font=font)
|
||||
style.configure("TRadiobutton", background=PAPER, foreground=INK, font=font)
|
||||
style.configure("TButton", font=font, padding=(10, 4))
|
||||
style.configure("TEntry", padding=3)
|
||||
style.configure("TCombobox", padding=3)
|
||||
@@ -242,6 +247,7 @@ class Md2GostApp:
|
||||
self.listing_var = tk.StringVar()
|
||||
self.emdash_var = tk.BooleanVar(value=False)
|
||||
self.hr_pagebreak_var = tk.BooleanVar(value=False)
|
||||
self.auto_title_var = tk.BooleanVar(value=False)
|
||||
self.open_var = tk.BooleanVar(value=True)
|
||||
self.check_var = tk.BooleanVar(value=False)
|
||||
self.check_only_var = tk.BooleanVar(value=False)
|
||||
@@ -268,6 +274,8 @@ class Md2GostApp:
|
||||
|
||||
settings = tk.Menu(menubar, tearoff=0)
|
||||
settings.add_command(label="Файлы…", command=self._open_files_window)
|
||||
settings.add_command(label="Студент…", command=self._open_student_window)
|
||||
settings.add_command(label="Метаданные…", command=self._open_metadata_window)
|
||||
settings.add_command(label="Диаграммы…", command=self._open_diagrams_window)
|
||||
menubar.add_cascade(label="Настройки", menu=settings)
|
||||
|
||||
@@ -340,6 +348,8 @@ class Md2GostApp:
|
||||
hint = ttk.Label(
|
||||
frame,
|
||||
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.\n"
|
||||
"Автотитул (info_conv.yaml / ```title) — галочка «Автотитул» на главном окне "
|
||||
"(по умолчанию выкл.; ФИО/группа — Настройки → Студент…).\n"
|
||||
"Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.",
|
||||
style="Hint.TLabel",
|
||||
wraplength=560,
|
||||
@@ -348,6 +358,225 @@ class Md2GostApp:
|
||||
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files"))
|
||||
return win
|
||||
|
||||
def _ensure_student_profile(self) -> None:
|
||||
from .user_profile import load_user_profile
|
||||
|
||||
if load_user_profile().is_complete():
|
||||
return
|
||||
self._open_student_window(first_run=True)
|
||||
|
||||
def _open_student_window(self, first_run: bool = False) -> None:
|
||||
existing = getattr(self, "_win_student", None)
|
||||
if existing is not None and existing.winfo_exists():
|
||||
existing.lift()
|
||||
existing.focus_force()
|
||||
return
|
||||
self._win_student = self._create_student_window(first_run=first_run)
|
||||
|
||||
def _create_student_window(self, *, first_run: bool = False) -> tk.Toplevel:
|
||||
from .user_profile import load_user_profile, save_user_profile
|
||||
|
||||
profile = load_user_profile()
|
||||
win = tk.Toplevel(self.root)
|
||||
win.title("Данные студента" if first_run else "Настройки — Студент")
|
||||
win.configure(bg=PAPER)
|
||||
win.minsize(420, 180)
|
||||
win.geometry("480x200")
|
||||
win.transient(self.root)
|
||||
if first_run:
|
||||
win.grab_set()
|
||||
frame = ttk.Frame(win, padding=12)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
frame.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(
|
||||
frame,
|
||||
text="ФИО и группа нужны для автотитульника (info_conv.yaml / ```title).",
|
||||
style="Hint.TLabel",
|
||||
wraplength=440,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
|
||||
|
||||
student_var = tk.StringVar(value=profile.student)
|
||||
group_var = tk.StringVar(value=profile.group)
|
||||
ttk.Label(frame, text="ФИО").grid(row=1, column=0, sticky="w", pady=4)
|
||||
ttk.Entry(frame, textvariable=student_var).grid(row=1, column=1, sticky="ew", pady=4, padx=(8, 0))
|
||||
ttk.Label(frame, text="Группа").grid(row=2, column=0, sticky="w", pady=4)
|
||||
ttk.Entry(frame, textvariable=group_var).grid(row=2, column=1, sticky="ew", pady=4, padx=(8, 0))
|
||||
|
||||
def _save(close: bool = True) -> None:
|
||||
student = student_var.get().strip()
|
||||
group = group_var.get().strip()
|
||||
if not student or not group:
|
||||
messagebox.showwarning(
|
||||
"Данные студента",
|
||||
"Заполните ФИО и группу.",
|
||||
parent=win,
|
||||
)
|
||||
return
|
||||
current = load_user_profile()
|
||||
current.student = student
|
||||
current.group = group
|
||||
save_user_profile(current)
|
||||
if close:
|
||||
self._close_toplevel(win, "_win_student")
|
||||
|
||||
btns = ttk.Frame(frame)
|
||||
btns.grid(row=3, column=0, columnspan=2, sticky="e", pady=(12, 0))
|
||||
ttk.Button(btns, text="Сохранить", command=lambda: _save(True)).pack(side=tk.RIGHT)
|
||||
|
||||
def _on_close() -> None:
|
||||
if first_run and not load_user_profile().is_complete():
|
||||
# Allow closing only after save; remind once
|
||||
if not student_var.get().strip() or not group_var.get().strip():
|
||||
messagebox.showinfo(
|
||||
"Данные студента",
|
||||
"Можно заполнить позже: Настройки → Студент…\n"
|
||||
"Без ФИО/группы автотитул не соберётся.",
|
||||
parent=win,
|
||||
)
|
||||
self._close_toplevel(win, "_win_student")
|
||||
|
||||
win.protocol("WM_DELETE_WINDOW", _on_close)
|
||||
return win
|
||||
|
||||
def _open_metadata_window(self) -> None:
|
||||
self._win_meta = self._raise_or_create(self._win_meta, self._create_metadata_window)
|
||||
|
||||
def _create_metadata_window(self) -> tk.Toplevel:
|
||||
from .doc_metadata import (
|
||||
AUTHOR_SOURCES,
|
||||
DEFAULT_AUTHOR_SOURCE,
|
||||
DEFAULT_DOC_COMMENTS,
|
||||
DocumentMetadata,
|
||||
os_user,
|
||||
)
|
||||
from .user_profile import load_user_profile, save_user_profile
|
||||
|
||||
profile = load_user_profile()
|
||||
meta = profile.metadata or DocumentMetadata()
|
||||
win = tk.Toplevel(self.root)
|
||||
win.title("Настройки — Метаданные")
|
||||
win.configure(bg=PAPER)
|
||||
win.minsize(520, 420)
|
||||
win.geometry("580x500")
|
||||
win.transient(self.root)
|
||||
frame = ttk.Frame(win, padding=12)
|
||||
frame.pack(fill=tk.BOTH, expand=True)
|
||||
frame.columnconfigure(1, weight=1)
|
||||
|
||||
ttk.Label(
|
||||
frame,
|
||||
text=(
|
||||
"Свойства файла Word (Файл → сведения): автор, название, тема, теги, примечание. "
|
||||
"Хранятся в md2gost.user.json рядом с приложением."
|
||||
),
|
||||
style="Hint.TLabel",
|
||||
wraplength=540,
|
||||
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
|
||||
|
||||
src_value = meta.author_source if meta.author_source in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
|
||||
author_src = tk.StringVar(value=src_value)
|
||||
author_var = tk.StringVar(value=meta.author)
|
||||
last_mod_var = tk.StringVar(value=meta.last_modified_by)
|
||||
title_var = tk.StringVar(value=meta.title)
|
||||
subject_var = tk.StringVar(value=meta.subject)
|
||||
keywords_var = tk.StringVar(value=meta.keywords)
|
||||
category_var = tk.StringVar(value=meta.category)
|
||||
comments_var = tk.StringVar(value=meta.comments)
|
||||
|
||||
ttk.Label(frame, text="Автор").grid(row=1, column=0, sticky="nw", pady=(4, 0))
|
||||
src_box = ttk.Frame(frame)
|
||||
src_box.grid(row=1, column=1, sticky="ew", pady=(4, 0), padx=(8, 0))
|
||||
os_name = os_user() or "—"
|
||||
student_name = (profile.student or "").strip()
|
||||
student_text = (
|
||||
f"ФИО студента ({student_name})"
|
||||
if student_name
|
||||
else "ФИО студента (сначала Настройки → Студент…)"
|
||||
)
|
||||
ttk.Radiobutton(
|
||||
src_box, text=f"Имя пользователя Windows ({os_name})",
|
||||
variable=author_src, value="os",
|
||||
).pack(anchor="w")
|
||||
ttk.Radiobutton(
|
||||
src_box, text=student_text, variable=author_src, value="student",
|
||||
).pack(anchor="w")
|
||||
ttk.Radiobutton(
|
||||
src_box, text="Указать вручную", variable=author_src, value="custom",
|
||||
).pack(anchor="w")
|
||||
|
||||
author_entry = ttk.Entry(frame, textvariable=author_var)
|
||||
author_entry.grid(row=2, column=1, sticky="ew", pady=(2, 4), padx=(8, 0))
|
||||
_tip(author_entry, "Используется, если выбран пункт «Указать вручную»")
|
||||
|
||||
def _sync_author_entry(*_a) -> None:
|
||||
state = "normal" if author_src.get() == "custom" else "disabled"
|
||||
try:
|
||||
author_entry.configure(state=state)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
author_src.trace_add("write", _sync_author_entry)
|
||||
_sync_author_entry()
|
||||
|
||||
def _row(row: int, label: str, var: tk.StringVar, tip: str) -> None:
|
||||
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
|
||||
entry = ttk.Entry(frame, textvariable=var)
|
||||
entry.grid(row=row, column=1, sticky="ew", pady=4, padx=(8, 0))
|
||||
_tip(entry, tip)
|
||||
|
||||
_row(3, "Кем изменён", last_mod_var, "Пусто — то же значение, что у автора")
|
||||
_row(4, "Название", title_var, "dc:title в свойствах файла; пусто — не задавать")
|
||||
_row(5, "Тема", subject_var, "Тема документа в свойствах файла")
|
||||
_row(6, "Ключевые слова", keywords_var, "Теги в свойствах файла Word")
|
||||
_row(7, "Категория", category_var, "Категория в свойствах файла")
|
||||
_row(
|
||||
8,
|
||||
"Примечание",
|
||||
comments_var,
|
||||
f"По умолчанию: «{DEFAULT_DOC_COMMENTS}». Пусто — не писать примечание.",
|
||||
)
|
||||
ttk.Label(
|
||||
frame,
|
||||
text="Пустое примечание убирает и текст md2gost, и «generated by python-docx» из шаблона.",
|
||||
style="Hint.TLabel",
|
||||
wraplength=540,
|
||||
).grid(row=9, column=0, columnspan=2, sticky="w", pady=(4, 0))
|
||||
|
||||
def _save() -> None:
|
||||
src = author_src.get() if author_src.get() in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
|
||||
current = load_user_profile()
|
||||
current.metadata = DocumentMetadata(
|
||||
author_source=src,
|
||||
author=author_var.get().strip(),
|
||||
last_modified_by=last_mod_var.get().strip(),
|
||||
title=title_var.get().strip(),
|
||||
subject=subject_var.get().strip(),
|
||||
keywords=keywords_var.get().strip(),
|
||||
comments=comments_var.get().strip(),
|
||||
category=category_var.get().strip(),
|
||||
)
|
||||
save_user_profile(current)
|
||||
self._close_toplevel(win, "_win_meta")
|
||||
|
||||
def _reset() -> None:
|
||||
author_src.set(DEFAULT_AUTHOR_SOURCE)
|
||||
author_var.set("")
|
||||
last_mod_var.set("")
|
||||
title_var.set("")
|
||||
subject_var.set("")
|
||||
keywords_var.set("")
|
||||
category_var.set("")
|
||||
comments_var.set(DEFAULT_DOC_COMMENTS)
|
||||
|
||||
btns = ttk.Frame(frame)
|
||||
btns.grid(row=10, column=0, columnspan=2, sticky="e", pady=(12, 0))
|
||||
ttk.Button(btns, text="Сбросить", command=_reset).pack(side=tk.LEFT, padx=(0, 8))
|
||||
ttk.Button(btns, text="Сохранить", command=_save).pack(side=tk.RIGHT)
|
||||
|
||||
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_meta"))
|
||||
return win
|
||||
|
||||
def _create_diagrams_window(self) -> tk.Toplevel:
|
||||
win = tk.Toplevel(self.root)
|
||||
win.title("Настройки — Диаграммы")
|
||||
@@ -362,10 +591,10 @@ class Md2GostApp:
|
||||
hint = ttk.Label(
|
||||
extra,
|
||||
text=(
|
||||
"Блоки ```uml / ```uml-c4 / ```bpmn / ```mermaid сами станут рисунками.\n"
|
||||
"Блоки ```uml / ```uml-c4 / ```mermaid / ```idef0 / ```dfd сами станут рисунками.\n"
|
||||
"Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n"
|
||||
"UML: PlantUML + Java, иначе Kroki. Mermaid: системный браузер → "
|
||||
"Chromium → QuickJS → Kroki (kroki.io остаётся запасным)."
|
||||
"Chromium → QuickJS → Kroki. IDEF0 — Pillow. DFD — Graphviz / Kroki."
|
||||
),
|
||||
style="Hint.TLabel",
|
||||
wraplength=640,
|
||||
@@ -377,14 +606,17 @@ class Md2GostApp:
|
||||
status_row.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||||
status_row.columnconfigure(0, weight=1)
|
||||
self.diag_status = ttk.Label(status_row, text="", style="Hint.TLabel", wraplength=480, justify=tk.LEFT)
|
||||
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=2)
|
||||
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=3)
|
||||
btn_dl = ttk.Button(status_row, text="Скачать PlantUML", command=self._download_plantuml)
|
||||
btn_dl.grid(row=0, column=1, sticky="e", padx=(8, 0))
|
||||
_tip(btn_dl, "Скачать plantuml.jar для локальной отрисовки (нужна Java)")
|
||||
btn_gv = ttk.Button(status_row, text="Скачать Graphviz", command=self._download_graphviz)
|
||||
btn_gv.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
|
||||
_tip(btn_gv, "Скачать portable Graphviz (dot/neato) для локальных DFD")
|
||||
btn_cr = ttk.Button(
|
||||
status_row, text="Скачать headless Chromium", command=self._download_chromium,
|
||||
)
|
||||
btn_cr.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
|
||||
btn_cr.grid(row=2, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
|
||||
_tip(
|
||||
btn_cr,
|
||||
"Скачать Chromium для Mermaid в %LOCALAPPDATA%\\md2gost "
|
||||
@@ -629,7 +861,7 @@ class Md2GostApp:
|
||||
def _build_schemes_help(self, parent) -> None:
|
||||
ttk.Label(
|
||||
parent,
|
||||
text="Настраиваемые схемы диаграмм (uml-c4, bpmn, …)",
|
||||
text="Настраиваемые схемы диаграмм (uml-c4, usecase, …)",
|
||||
style="Hint.TLabel",
|
||||
).pack(anchor=tk.W, pady=(0, 6))
|
||||
box = self._readonly_text(parent, SCHEMES_HELP.strip() + "\n")
|
||||
@@ -641,7 +873,7 @@ class Md2GostApp:
|
||||
parent,
|
||||
text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. "
|
||||
"Кнопки схем дописывают макросы из md2gost.schemes.json — "
|
||||
"когда ИИ должен знать C4/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
|
||||
"когда ИИ должен знать C4/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
|
||||
style="Hint.TLabel",
|
||||
wraplength=640,
|
||||
).pack(anchor=tk.W, pady=(0, 8))
|
||||
@@ -800,7 +1032,7 @@ class Md2GostApp:
|
||||
self.zone.pack(fill=tk.X, pady=(0, 8))
|
||||
self.zone_label = tk.Label(
|
||||
self.zone,
|
||||
text="Перетащите .md сюда · или нажмите, чтобы выбрать файл",
|
||||
text="Перетащите .md или .docx сюда · или нажмите, чтобы выбрать файл",
|
||||
bg=ZONE, fg=NAVY, font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
|
||||
justify=tk.CENTER, pady=14,
|
||||
)
|
||||
@@ -812,8 +1044,8 @@ class Md2GostApp:
|
||||
self.zone_path.pack(fill=tk.X, pady=(0, 8))
|
||||
for w in (self.zone, self.zone_label, self.zone_path):
|
||||
w.bind("<Button-1>", lambda _e: self._browse_md())
|
||||
_tip(self.zone, "Перетащите .md или кликните, чтобы выбрать файл")
|
||||
_tip(self.zone_label, "Перетащите .md или кликните, чтобы выбрать файл")
|
||||
_tip(self.zone, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
|
||||
_tip(self.zone_label, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
|
||||
|
||||
grid = ttk.Frame(outer)
|
||||
grid.pack(fill=tk.X)
|
||||
@@ -857,6 +1089,11 @@ class Md2GostApp:
|
||||
checks = [
|
||||
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
|
||||
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
|
||||
(
|
||||
"Автотитул",
|
||||
self.auto_title_var,
|
||||
"Собрать титул из info_conv.yaml / ```title (нужны ФИО и группа). По умолчанию выкл.",
|
||||
),
|
||||
("Открыть после конвертации", self.open_var, "Открыть DOCX после успешной сборки"),
|
||||
("Проверить по ТЗ", self.check_var, "Замечания по структуре, «рис.», источникам"),
|
||||
("Только проверка", self.check_only_var, "Проверить без записи DOCX"),
|
||||
@@ -886,7 +1123,17 @@ class Md2GostApp:
|
||||
relief=tk.FLAT, padx=22, pady=7, cursor="hand2",
|
||||
)
|
||||
self.convert_btn.pack(side=tk.LEFT)
|
||||
_tip(self.convert_btn, "Собрать DOCX из выбранного markdown")
|
||||
_tip(self.convert_btn, "Собрать DOCX из markdown или импортировать DOCX → Markdown")
|
||||
self.import_btn = tk.Button(
|
||||
actions, text="Импорт DOCX→MD",
|
||||
command=self._import_docx,
|
||||
bg="#3d5a40", fg="white", activebackground="#2d4430", activeforeground="white",
|
||||
disabledforeground="#d0d5dc",
|
||||
font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
|
||||
relief=tk.FLAT, padx=14, pady=7, cursor="hand2",
|
||||
)
|
||||
self.import_btn.pack(side=tk.LEFT, padx=(8, 0))
|
||||
_tip(self.import_btn, "Выбрать .docx и сохранить диалект md2gost (.md + медиа)")
|
||||
self.progress = ttk.Progressbar(actions, mode="indeterminate", length=180)
|
||||
self.progress.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
|
||||
|
||||
@@ -901,7 +1148,7 @@ class Md2GostApp:
|
||||
self.log.configure(yscrollcommand=scroll.set)
|
||||
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.log.insert("1.0", "Готово к работе. Перетащите markdown-файл в верхнюю область.\n")
|
||||
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
|
||||
self.log.configure(state=tk.DISABLED)
|
||||
|
||||
def _build_schemes_tab(self, parent) -> None:
|
||||
@@ -1067,6 +1314,7 @@ class Md2GostApp:
|
||||
scheme = self._scheme_from_form(old_id)
|
||||
if scheme is None:
|
||||
return None
|
||||
scheme = claim_user_scheme_if_diverged(scheme)
|
||||
if old_id and old_id != scheme.id and old_id in self._schemes:
|
||||
del self._schemes[old_id]
|
||||
self._schemes[scheme.id] = scheme
|
||||
@@ -1197,7 +1445,7 @@ class Md2GostApp:
|
||||
if backend == "none":
|
||||
self._append_log("Перетаскивание недоступно — выберите файл кликом по области.")
|
||||
else:
|
||||
self._append_log("Можно перетаскивать .md из проводника в окно.")
|
||||
self._append_log("Можно перетаскивать .md / .docx из проводника в окно.")
|
||||
|
||||
def _on_drop(self, paths: list[str]) -> None:
|
||||
md = first_markdown(paths)
|
||||
@@ -1205,8 +1453,16 @@ class Md2GostApp:
|
||||
self._set_markdown(md)
|
||||
self._flash_zone(ZONE_OK)
|
||||
return
|
||||
docx = first_docx(paths)
|
||||
if docx:
|
||||
self._set_docx(docx)
|
||||
self._flash_zone(ZONE_OK)
|
||||
return
|
||||
if paths:
|
||||
self._append_log("Нужен файл .md, а не: " + ", ".join(os.path.basename(p) for p in paths))
|
||||
self._append_log(
|
||||
"Нужен файл .md или .docx, а не: "
|
||||
+ ", ".join(os.path.basename(p) for p in paths)
|
||||
)
|
||||
self._flash_zone("#f8d7da")
|
||||
|
||||
def _flash_zone(self, color: str) -> None:
|
||||
@@ -1222,10 +1478,19 @@ class Md2GostApp:
|
||||
|
||||
def _browse_md(self) -> None:
|
||||
path = filedialog.askopenfilename(
|
||||
title="Исходный Markdown",
|
||||
filetypes=[("Markdown", "*.md"), ("Все файлы", "*.*")],
|
||||
title="Исходный Markdown или Word",
|
||||
filetypes=[
|
||||
("Markdown / Word", "*.md;*.docx"),
|
||||
("Markdown", "*.md"),
|
||||
("Word", "*.docx"),
|
||||
("Все файлы", "*.*"),
|
||||
],
|
||||
)
|
||||
if path:
|
||||
if not path:
|
||||
return
|
||||
if path.lower().endswith(".docx"):
|
||||
self._set_docx(path)
|
||||
else:
|
||||
self._set_markdown(path)
|
||||
|
||||
def _browse_output(self) -> None:
|
||||
@@ -1290,6 +1555,55 @@ class Md2GostApp:
|
||||
"Можно ничего не делать — диаграммы нарисует kroki.io.",
|
||||
)
|
||||
|
||||
def _download_graphviz(self) -> None:
|
||||
from .dfd import (
|
||||
cached_graphviz_root,
|
||||
fetch_graphviz,
|
||||
vendor_graphviz_root,
|
||||
)
|
||||
|
||||
dest = vendor_graphviz_root()
|
||||
try:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
probe = dest / ".write_test"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
dest = cached_graphviz_root()
|
||||
self._append_log(f"Скачиваю Graphviz → {dest}")
|
||||
self.root.update_idletasks()
|
||||
|
||||
def work() -> None:
|
||||
ok = fetch_graphviz(dest)
|
||||
self.root.after(0, lambda: self._on_graphviz_installed(ok, dest))
|
||||
|
||||
import threading
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _on_graphviz_installed(self, ok: bool, dest) -> None:
|
||||
self._refresh_diagram_status()
|
||||
if ok:
|
||||
engine = None
|
||||
try:
|
||||
from .dfd import resolve_graphviz_engine
|
||||
|
||||
engine = resolve_graphviz_engine(False)
|
||||
except Exception:
|
||||
pass
|
||||
messagebox.showinfo(
|
||||
"md2gost",
|
||||
f"Graphviz сохранён:\n{dest}\n\n"
|
||||
f"Движок: {engine or 'dot в каталоге'}",
|
||||
)
|
||||
else:
|
||||
messagebox.showerror(
|
||||
"md2gost",
|
||||
"Не удалось скачать Graphviz.\n"
|
||||
"DFD может пойти через kroki.io "
|
||||
"(«Как рисовать» → через интернет) или системный PATH.",
|
||||
)
|
||||
|
||||
def _download_chromium(self) -> None:
|
||||
from .mermaid_renderer import install_playwright_chromium, playwright_browsers_dir, playwright_available
|
||||
|
||||
@@ -1351,6 +1665,32 @@ class Md2GostApp:
|
||||
self._auto_output = True
|
||||
self._append_log(f"Файл: {path}")
|
||||
|
||||
def _set_docx(self, path: str) -> None:
|
||||
path = os.path.abspath(path)
|
||||
self._last_md = path
|
||||
self.zone_label.configure(text=os.path.basename(path))
|
||||
self.zone_path.configure(text=path)
|
||||
if self._auto_output:
|
||||
stem = os.path.splitext(path)[0]
|
||||
self.output_var.set(stem + ".md")
|
||||
self._auto_output = True
|
||||
self._append_log(f"DOCX для импорта: {path}")
|
||||
|
||||
def _import_docx(self) -> None:
|
||||
"""Pick a DOCX (or use current) and run word2md."""
|
||||
if self._busy:
|
||||
return
|
||||
path = self._last_md if self._last_md.lower().endswith(".docx") else ""
|
||||
if not path:
|
||||
path = filedialog.askopenfilename(
|
||||
title="Импорт DOCX → Markdown",
|
||||
filetypes=[("Word", "*.docx"), ("Все файлы", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self._set_docx(path)
|
||||
self._run_convert(force_import=True)
|
||||
|
||||
def _apply_request(self, req: ConvertRequest) -> None:
|
||||
self.type_var.set(_display(DOC_TYPE_LABELS, req.doc_type if req.doc_type in DOC_TYPES else "practice"))
|
||||
self.heading_var.set(_display(HEADING_LABELS, req.heading_numbering or DEFAULT_HEADING_NUMBERING))
|
||||
@@ -1363,6 +1703,7 @@ class Md2GostApp:
|
||||
self.scale_var.set(str(scale).rstrip("0").rstrip(".") if isinstance(scale, float) else str(scale))
|
||||
self.emdash_var.set(bool(req.emdash_to_hyphen))
|
||||
self.hr_pagebreak_var.set(bool(req.hr_pagebreak))
|
||||
self.auto_title_var.set(bool(getattr(req, "auto_title", False)))
|
||||
self.open_var.set(True)
|
||||
self.check_var.set(bool(req.check))
|
||||
self.check_only_var.set(bool(req.check_only))
|
||||
@@ -1398,6 +1739,7 @@ class Md2GostApp:
|
||||
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
|
||||
emdash_to_hyphen=self.emdash_var.get(),
|
||||
hr_pagebreak=self.hr_pagebreak_var.get(),
|
||||
auto_title=self.auto_title_var.get(),
|
||||
title=self.title_var.get().strip() or None,
|
||||
assignment=self.assignment_var.get().strip() or None,
|
||||
check=self.check_var.get() or self.check_only_var.get(),
|
||||
@@ -1436,13 +1778,39 @@ class Md2GostApp:
|
||||
return value if value >= 1 else 1.0
|
||||
|
||||
def _convert(self) -> None:
|
||||
self._run_convert(force_import=False)
|
||||
|
||||
def _run_convert(self, *, force_import: bool) -> None:
|
||||
if self._busy:
|
||||
return
|
||||
req = self._collect()
|
||||
if not req.filename:
|
||||
messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.")
|
||||
messagebox.showinfo(
|
||||
"md2gost",
|
||||
"Сначала перетащите или выберите .md (сборка) или .docx (импорт).",
|
||||
)
|
||||
return
|
||||
if not req.check_only:
|
||||
is_docx = req.filename.lower().endswith(".docx")
|
||||
if force_import and not is_docx:
|
||||
messagebox.showinfo("md2gost", "Для импорта нужен файл .docx.")
|
||||
return
|
||||
if is_docx:
|
||||
target = (req.output or "").strip()
|
||||
if target.lower().endswith(".docx"):
|
||||
target = os.path.splitext(req.filename)[0] + ".md"
|
||||
req.output = target
|
||||
self.output_var.set(target)
|
||||
if target and os.path.isfile(target):
|
||||
if not messagebox.askyesno(
|
||||
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
|
||||
):
|
||||
base, ext = os.path.splitext(target)
|
||||
target = f"{base}_import{ext or '.md'}"
|
||||
self._append_log(f"Не перезаписываем, пишем в {target}")
|
||||
req.output = target
|
||||
self.output_var.set(target)
|
||||
self._append_log("Импорт DOCX → Markdown…")
|
||||
elif not req.check_only:
|
||||
target = (req.output or "").strip() or (
|
||||
default_output_path(req.filename) if req.filename else ""
|
||||
)
|
||||
@@ -1455,10 +1823,15 @@ class Md2GostApp:
|
||||
req.output = target
|
||||
self.output_var.set(target)
|
||||
self._auto_output = False
|
||||
self._append_log("Конвертация…")
|
||||
else:
|
||||
self._append_log("Проверка…")
|
||||
|
||||
self._busy = True
|
||||
self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM)
|
||||
if hasattr(self, "import_btn"):
|
||||
self.import_btn.configure(state=tk.DISABLED)
|
||||
self.progress.start(12)
|
||||
self._append_log("Конвертация…")
|
||||
|
||||
def work():
|
||||
result = convert(req, log=lambda m: self.root.after(0, self._append_log, m))
|
||||
@@ -1469,6 +1842,8 @@ class Md2GostApp:
|
||||
def _on_done(self, result) -> None:
|
||||
self._busy = False
|
||||
self.convert_btn.configure(state=tk.NORMAL, bg=NAVY)
|
||||
if hasattr(self, "import_btn"):
|
||||
self.import_btn.configure(state=tk.NORMAL, bg="#3d5a40")
|
||||
self.progress.stop()
|
||||
if self._debug_next or self.debug_var.get():
|
||||
self.debug_var.set(False)
|
||||
|
||||
+65
-38
@@ -13,6 +13,8 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
|
||||
1. Перетащите .md в верхнюю область (или кликните по ней).
|
||||
2. Выберите тип документа и параметры в блоке «Основные».
|
||||
Шаблон / титул / задание — Настройки → Файлы.
|
||||
ФИО и группа студента — Настройки → Студент… (спрашивают при первом запуске).
|
||||
Свойства DOCX (автор, название, примечание) — Настройки → Метаданные….
|
||||
PlantUML / Mermaid / Kroki — Настройки → Диаграммы.
|
||||
Свои UML-схемы — меню «Шаблоны UML».
|
||||
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
|
||||
@@ -28,14 +30,29 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
|
||||
• Проверить по ТЗ — замечания по структуре, «рис.», источникам.
|
||||
• Проверить вёрстку в Word — полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32).
|
||||
• Титул / задание — отдельные DOCX, вставляются перед телом отчёта.
|
||||
Автотитул — галочка «Автотитул» / CLI `--auto-title` (по умолчанию **выкл.**):
|
||||
info_conv.yaml рядом с .md + опционально блок ```title (number, year…);
|
||||
ФИО/группа — Настройки → Студент… или --student / --group.
|
||||
Явный путь к титулу отключает генератор. Подробно: docs/title.md.
|
||||
• Свойства файла Word — Настройки → Метаданные… / --doc-author-from, --doc-author, --doc-title, --doc-comments….
|
||||
По умолчанию автор = имя Windows, примечание «Создано при помощи md2gost (ТЗ МИРЭА)».
|
||||
|
||||
CLI (тот же движок)
|
||||
python -m md2gost report.md -o report.docx --type coursework --check
|
||||
python -m md2gost report.md --auto-title --student "Иванов И. И." --group ИНБО-31-23
|
||||
python -m md2gost report.md --doc-author-from student --doc-comments ""
|
||||
python -m md2gost report.docx -o report.md
|
||||
python -m word2md report.docx
|
||||
python -m md2gost --gui
|
||||
md2gost.exe report.md --type PIS_custom --title title.docx
|
||||
md2gost.exe report.md --schemes path/to/md2gost.schemes.json
|
||||
md2gost.exe report.md --styles path/to/md2gost.styles.json
|
||||
|
||||
Импорт Word → Markdown
|
||||
Перетащите .docx или кнопка «Импорт DOCX→MD». Подписи, таблицы, спецразделы
|
||||
переводятся в диалект md2gost. UML/Mermaid из картинок не восстанавливаются.
|
||||
Подробнее: docs/word2md.md.
|
||||
|
||||
Стили JSON (опционально)
|
||||
Оверлей поверх пресета типа документа (--type). Файл md2gost.styles.json рядом с .md
|
||||
или --styles / Настройки → Файлы → «Стили JSON». Меняет поля страницы и параметры
|
||||
@@ -101,12 +118,32 @@ CLI (тот же движок)
|
||||
Alice->>Bob: hello
|
||||
```
|
||||
|
||||
IDEF0 (контекст A-0, рамка NIST):
|
||||
%a0 Контекст +landscape
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A-0
|
||||
[A0] Распорядиться товаром
|
||||
<- Спрос
|
||||
^ Нормативная документация
|
||||
-> Товар
|
||||
v Персонал
|
||||
```
|
||||
|
||||
DFD (pbauermeister/dfd → Graphviz вшитый/PATH / Kroki):
|
||||
%dfd0 Контекстная DFD
|
||||
```dfd
|
||||
style context
|
||||
entity Client Клиент
|
||||
process System Система учёта заявок
|
||||
Client --> System заявка
|
||||
System --> Client статус
|
||||
```
|
||||
|
||||
+listing — ещё и листинг с исходником.
|
||||
+landscape — отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная.
|
||||
Языки: uml, plantuml, mermaid / mmd, или uml-<id> / <id> из файла схем (c4, usecase, bpmn, …).
|
||||
BPMN 2.0: оградка ```bpmn / ```uml-bpmn, макросы Start, UserTask, XOR, Flow, Pool.
|
||||
Формат в Word: PNG по умолчанию (PlantUML и локальный Mermaid ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
|
||||
IDEF0 конвертер не рисует — вставляйте готовый PNG как обычный Рисунок.
|
||||
Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, или uml-<id> / <id> из файла схем (c4, usecase, archimate, …).
|
||||
Формат в Word: PNG по умолчанию (PlantUML, локальный Mermaid, IDEF0 и DFD ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
|
||||
Подробнее — Справка → Схемы и раздел ниже в инструкции.
|
||||
|
||||
Формула (номер только если есть ссылка)
|
||||
@@ -129,13 +166,17 @@ IDEF0 конвертер не рисует — вставляйте готовы
|
||||
native — поле Word (обновить при открытии); manual — собирает md2gost.
|
||||
|
||||
Продолжение таблиц / листингов
|
||||
word — после сборки Word COM режет по реальной пагинации и вставляет «Продолжение…»
|
||||
(по умолчанию; нужны Windows + Word + pywin32).
|
||||
off — не резать, Word сам переносит.
|
||||
word — по умолчанию: после сборки Word COM режет по реальной пагинации и
|
||||
вставляет «Продолжение…» (нужны Windows + Word + pywin32). Также уводит
|
||||
таблицу целиком, если внизу страницы зависли только название или
|
||||
название + шапка граф (методичка: рис. Г.3).
|
||||
off — одна таблица, Word сам переносит строки; без авто«Продолжение».
|
||||
Защита от сирот: keep_with_next у подписи/шапки и оценка
|
||||
«подпись+шапка+1 строка» при рендере (без COM слабее, чем word).
|
||||
legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word).
|
||||
|
||||
Промпт для ИИ — Справка → Промпт для ИИ: выберите промпт, при необходимости
|
||||
включите схемы (C4, BPMN, …) кнопками — макросы допишутся в конец — скопируйте
|
||||
включите схемы (C4, usecase, …) кнопками — макросы допишутся в конец — скопируйте
|
||||
в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
|
||||
|
||||
Документация — Справка → Документация: встроенный просмотр docs/*.md
|
||||
@@ -151,15 +192,22 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
|
||||
Первый запуск
|
||||
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
|
||||
создаётся md2gost.schemes.json из встроенного шаблона.
|
||||
Если файл уже есть — программа его не перезаписывает (ваши схемы сохраняются).
|
||||
При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения
|
||||
(добавление новых id, обновление, удаление исчезнувших вроде bpmn).
|
||||
Схемы с пустым или другим author не трогаются.
|
||||
Если правите встроенную схему в GUI и сохраняете — author сбрасывается,
|
||||
чтобы следующая миграция не затёрла ваши правки.
|
||||
|
||||
Оградка в markdown
|
||||
```uml — обычный PlantUML (или ```plantuml)
|
||||
```uml-c4 — схема с id «c4» (то же, что ```c4)
|
||||
```uml-usecase — схема «usecase»
|
||||
```bpmn / ```uml-bpmn — BPMN 2.0 (макросы в diagrams/BPMN.puml)
|
||||
```uml-archimate — ArchiMate 3.2 (макросы Business_*, Rel_Serving, …)
|
||||
```mermaid / ```mmd — Mermaid локально (браузер / Chromium / QuickJS) или через Kroki
|
||||
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn.
|
||||
```idef0 — IDEF0 (FIPS 183): боксы, стрелки ICOM, рамка NIST; локальный Pillow
|
||||
```dfd / ```data-flow-diagram — DFD (pbauermeister/dfd): process/entity/store, --> ;
|
||||
Graphviz (вшитый / «Скачать Graphviz» / PATH) или Kroki
|
||||
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase.
|
||||
|
||||
Пример
|
||||
|
||||
@@ -171,18 +219,6 @@ System(app, "Портал")
|
||||
Rel(user, app, "логин")
|
||||
```
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw)
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
|
||||
Поля схемы в JSON
|
||||
title — подпись в GUI
|
||||
version — версия схемы
|
||||
@@ -207,27 +243,17 @@ URL из includes (и !include https://… внутри .puml) при перво
|
||||
«Сбросить кэш includes» в Настройки → Диаграммы или в «Шаблоны UML» удаляет индекс
|
||||
и только файлы, перечисленные в нём.
|
||||
|
||||
BPMN 2.0 (```bpmn)
|
||||
Пул: Pool(alias, "Участник") { Lane(alias, "Роль") { … } }
|
||||
События: Start, StartMessage, StartTimer, CatchMessage, ThrowMessage,
|
||||
CatchTimer, CatchError, End, EndMessage, EndError, EndTerminate
|
||||
Задачи: Task / UserTask / ServiceTask / ScriptTask / ManualTask /
|
||||
SendTask / ReceiveTask / BusinessRuleTask / SubProcess
|
||||
Шлюзы: XOR (Exclusive), AND (Parallel), OR (Inclusive), EventBased
|
||||
Потоки: Flow, CondFlow(from, to, "условие"), DefaultFlow, MessageFlow
|
||||
Данные: DataObject, DataStore, Annotation
|
||||
Граница: BoundaryError/Timer/Message + Attach(task, event)
|
||||
Sequence Flow только внутри пула; между пулами — MessageFlow.
|
||||
|
||||
Чего нет
|
||||
IDEF0 / DFD — PlantUML не умеет; вставляйте готовый PNG как Рисунок.
|
||||
У BPMN нет «прилипания» boundary-события к кромке задачи (ставьте Attach)
|
||||
и нет настоящей двойной окружности у intermediate (толщина линии).
|
||||
Балансировка родитель/потомок DFD между уровнями не проверяется автоматически.
|
||||
|
||||
Рендер
|
||||
UML: Java + plantuml.jar (локально) → иначе локальный Kroki → иначе kroki.io.
|
||||
Mermaid: системный Chrome/Edge → Playwright Chromium (кнопка / --install-chromium) →
|
||||
QuickJS (mermaidx, офлайн) → локальный Kroki → kroki.io.
|
||||
IDEF0: Pillow, без сети и Java.
|
||||
DFD: data-flow-diagram → Graphviz (dot/neato) или Kroki graphviz.
|
||||
Нужны: Graphviz (вшитый / «Скачать Graphviz» / PATH) либо Kroki;
|
||||
пакет data-flow-diagram ставится с md2gost.
|
||||
Формат: PNG по умолчанию (PlantUML / локальный Mermaid ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg — PNG + SVG (svgBlip в Word 2016+).
|
||||
+landscape у %подписи — альбомная страница под широкий рисунок/таблицу.
|
||||
"""
|
||||
@@ -236,6 +262,7 @@ PROMPT_FILES = (
|
||||
("generate-md.md", "Markdown для md2gost"),
|
||||
("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"),
|
||||
("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"),
|
||||
("emulate-student.md", "Стиль: человечный текст, не отчёт ИИ"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+2185
File diff suppressed because it is too large
Load Diff
+125
-15
@@ -2,13 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from docx.enum.section import WD_ORIENT
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Mm, Pt
|
||||
from lxml import etree
|
||||
|
||||
from .util import create_element
|
||||
|
||||
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
_W = f"{{{_W_NS}}}"
|
||||
|
||||
# GOST-like A4 margins (same as styles._apply_common_page_and_body)
|
||||
MARGIN_LEFT = Mm(30)
|
||||
MARGIN_RIGHT = Mm(10)
|
||||
@@ -91,6 +100,10 @@ def apply_section_geometry(section, *, landscape: bool) -> None:
|
||||
if landscape:
|
||||
sect_pr.append(create_element("w:vAlign", {"w:val": "center"}))
|
||||
|
||||
# add_section copies template sectPr including w:pgNumType/@w:start — strip so
|
||||
# landscape / return-to-portrait sections continue numbering instead of restarting.
|
||||
clear_section_page_start(section)
|
||||
|
||||
|
||||
def content_size(*, landscape: bool) -> tuple:
|
||||
"""Return (max_height, max_width) usable content area for LayoutTracker."""
|
||||
@@ -131,31 +144,31 @@ def clear_section_footer(section) -> None:
|
||||
footer.add_paragraph()
|
||||
|
||||
|
||||
def clear_section_page_start(section) -> None:
|
||||
"""Remove w:pgNumType/@w:start so PAGE continues from the previous section."""
|
||||
sect_pr = section._sectPr
|
||||
for child in list(sect_pr):
|
||||
if child.tag != qn("w:pgNumType"):
|
||||
continue
|
||||
if child.get(qn("w:start")) is not None:
|
||||
del child.attrib[qn("w:start")]
|
||||
# Empty pgNumType (no start/fmt) is useless — drop the element.
|
||||
if len(child.attrib) == 0:
|
||||
sect_pr.remove(child)
|
||||
|
||||
|
||||
def ensure_continuous_page_numbers(document) -> None:
|
||||
"""
|
||||
After docxcompose of title/assignment: no PAGE on early sections,
|
||||
continuous numbering (do not restart at 1 on body section).
|
||||
"""
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
sections = list(document.sections)
|
||||
if not sections:
|
||||
return
|
||||
|
||||
# Heuristic: sections before the first that already has a PAGE field /
|
||||
# or first N-1 if title was prepended — clear footer on all but keep
|
||||
# continuous pgNumType. Body renderer already puts PAGE on body section;
|
||||
# after compose, early sections may inherit footers — clear empty ones
|
||||
# that belong to front matter (no Heading-like body content is hard to
|
||||
# detect), so: clear footer on every section that has no PAGE field, and
|
||||
# strip w:pgNumType start=1 everywhere.
|
||||
# Strip w:pgNumType start everywhere so landscape / body sections stay continuous.
|
||||
for section in sections:
|
||||
sect_pr = section._sectPr
|
||||
for child in list(sect_pr):
|
||||
if child.tag == qn("w:pgNumType"):
|
||||
# Keep continuous: remove start attribute if present
|
||||
if child.get(qn("w:start")) is not None:
|
||||
del child.attrib[qn("w:start")]
|
||||
clear_section_page_start(section)
|
||||
|
||||
|
||||
def set_section_page_start(section, start: int) -> None:
|
||||
@@ -191,3 +204,100 @@ def apply_page_number_start(
|
||||
if idx >= len(sections):
|
||||
return
|
||||
set_section_page_start(sections[idx], int(start))
|
||||
|
||||
|
||||
def _iter_sect_pr(document_root) -> list:
|
||||
"""Return sectPr elements in document order (inline + final body sectPr)."""
|
||||
body = document_root.find(f"{_W}body")
|
||||
if body is None:
|
||||
return []
|
||||
sects: list = []
|
||||
for child in body:
|
||||
tag = child.tag
|
||||
if tag == f"{_W}sectPr":
|
||||
sects.append(child)
|
||||
continue
|
||||
if tag != f"{_W}p":
|
||||
continue
|
||||
p_pr = child.find(f"{_W}pPr")
|
||||
if p_pr is None:
|
||||
continue
|
||||
sect = p_pr.find(f"{_W}sectPr")
|
||||
if sect is not None:
|
||||
sects.append(sect)
|
||||
return sects
|
||||
|
||||
|
||||
def _strip_pg_num_start_xml(sect_pr) -> None:
|
||||
for child in list(sect_pr):
|
||||
if child.tag != f"{_W}pgNumType":
|
||||
continue
|
||||
if child.get(f"{_W}start") is not None:
|
||||
del child.attrib[f"{_W}start"]
|
||||
if len(child.attrib) == 0:
|
||||
sect_pr.remove(child)
|
||||
|
||||
|
||||
def _set_pg_num_start_xml(sect_pr, start: int) -> None:
|
||||
pg = sect_pr.find(f"{_W}pgNumType")
|
||||
if pg is None:
|
||||
pg = etree.SubElement(sect_pr, f"{_W}pgNumType")
|
||||
pg.set(f"{_W}start", str(int(start)))
|
||||
|
||||
|
||||
def patch_docx_page_starts(
|
||||
path: str | Path,
|
||||
start: int | None = None,
|
||||
*,
|
||||
front_sections: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
Strip (and optionally re-apply) w:pgNumType/@w:start inside word/document.xml.
|
||||
|
||||
Used after Word COM Save, which often rewrites start=\"1\" onto every section.
|
||||
Zip/lxml only — does not round-trip through python-docx (preserves
|
||||
continuation captions and other Word-written parts).
|
||||
"""
|
||||
abs_path = os.path.abspath(str(path))
|
||||
if not os.path.isfile(abs_path):
|
||||
return
|
||||
|
||||
with zipfile.ZipFile(abs_path, "r") as zin:
|
||||
names = zin.namelist()
|
||||
if "word/document.xml" not in names:
|
||||
return
|
||||
doc_bytes = zin.read("word/document.xml")
|
||||
other = {n: zin.read(n) for n in names if n != "word/document.xml"}
|
||||
|
||||
root = etree.fromstring(doc_bytes)
|
||||
sects = _iter_sect_pr(root)
|
||||
for sect in sects:
|
||||
_strip_pg_num_start_xml(sect)
|
||||
|
||||
if start is not None and int(start) >= 1:
|
||||
idx = max(0, int(front_sections))
|
||||
if idx < len(sects):
|
||||
_set_pg_num_start_xml(sects[idx], int(start))
|
||||
|
||||
new_doc = etree.tostring(
|
||||
root,
|
||||
xml_declaration=True,
|
||||
encoding="UTF-8",
|
||||
standalone=True,
|
||||
)
|
||||
|
||||
parent = os.path.dirname(abs_path) or "."
|
||||
fd, tmp_name = tempfile.mkstemp(suffix=".docx", dir=parent)
|
||||
os.close(fd)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_name, "w", compression=zipfile.ZIP_DEFLATED) as zout:
|
||||
for name, data in other.items():
|
||||
zout.writestr(name, data)
|
||||
zout.writestr("word/document.xml", new_doc)
|
||||
os.replace(tmp_name, abs_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
+144
-17
@@ -9,7 +9,7 @@ import subprocess
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from getpass import getuser
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from docx import Document
|
||||
@@ -41,6 +41,10 @@ class ConvertRequest:
|
||||
hr_pagebreak: bool = False
|
||||
title: str | None = None
|
||||
assignment: str | None = None
|
||||
student: str | None = None
|
||||
group: str | None = None
|
||||
# Auto-build title from info_conv.yaml / ```title (off by default).
|
||||
auto_title: bool = False
|
||||
check: bool = False
|
||||
check_only: bool = False
|
||||
strict: bool = False
|
||||
@@ -58,6 +62,15 @@ class ConvertRequest:
|
||||
table_repeat_header: bool = False
|
||||
# PAGE field start on body section; None = continuous from document start.
|
||||
page_number_start: int | None = None
|
||||
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
|
||||
doc_author_source: str | None = None
|
||||
doc_author: str | None = None
|
||||
doc_last_modified_by: str | None = None
|
||||
doc_title: str | None = None
|
||||
doc_subject: str | None = None
|
||||
doc_keywords: str | None = None
|
||||
doc_comments: str | None = None
|
||||
doc_category: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -127,16 +140,33 @@ def _apply_page_offset(document, req: ConvertRequest, *, had_title: bool, had_as
|
||||
from .page_geometry import apply_page_number_start
|
||||
|
||||
start = getattr(req, "page_number_start", None)
|
||||
start_i: int | None
|
||||
if start is None:
|
||||
return
|
||||
try:
|
||||
start_i = int(start)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
start_i = None
|
||||
else:
|
||||
try:
|
||||
start_i = int(start)
|
||||
except (TypeError, ValueError):
|
||||
start_i = None
|
||||
front = int(bool(had_title)) + int(bool(had_assignment))
|
||||
# Always strip copied w:pgNumType/@w:start (landscape sections); optional body restart.
|
||||
apply_page_number_start(document, start_i, front_sections=front)
|
||||
|
||||
|
||||
def _page_offset_args(req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> tuple[int | None, int]:
|
||||
"""Return (page_number_start, front_sections) for post-Word patch."""
|
||||
start = getattr(req, "page_number_start", None)
|
||||
if start is None:
|
||||
start_i = None
|
||||
else:
|
||||
try:
|
||||
start_i = int(start)
|
||||
except (TypeError, ValueError):
|
||||
start_i = None
|
||||
front = int(bool(had_title)) + int(bool(had_assignment))
|
||||
return start_i, front
|
||||
|
||||
|
||||
def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
"""Run check and/or conversion. Does not re-raise; errors go into ConvertResult."""
|
||||
emit: LogFn = log if log is not None else print
|
||||
@@ -154,6 +184,24 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
emit(report)
|
||||
return ConvertResult(True, 0, check_report=report, message=report)
|
||||
|
||||
# DOCX → Markdown (word2md) when input is .docx without --check-pages
|
||||
if filename.lower().endswith(".docx"):
|
||||
from word2md import ImportRequest, convert_docx
|
||||
|
||||
out = (req.output or "").strip() or None
|
||||
if out and out.lower().endswith(".docx"):
|
||||
out = str(Path(filename).with_suffix(".md"))
|
||||
result = convert_docx(
|
||||
ImportRequest(filename=filename, output=out),
|
||||
log=emit,
|
||||
)
|
||||
return ConvertResult(
|
||||
result.ok,
|
||||
result.exit_code,
|
||||
output_path=result.output_path,
|
||||
message=result.message,
|
||||
)
|
||||
|
||||
if not filename:
|
||||
return fail(2, "Укажите исходный markdown-файл")
|
||||
if not filename.lower().endswith(".md"):
|
||||
@@ -192,10 +240,57 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
md_text = f.read()
|
||||
|
||||
from .title_page import (
|
||||
build_title_docx,
|
||||
load_info_conv,
|
||||
merge_title_meta,
|
||||
parse_and_strip_title_fence,
|
||||
should_generate_title,
|
||||
)
|
||||
from .user_profile import load_user_profile
|
||||
|
||||
fence_fields, body_md = parse_and_strip_title_fence(md_text)
|
||||
yaml_fields = load_info_conv(md_dir)
|
||||
generated_title_path: str | None = None
|
||||
title_path = (req.title or "").strip() or None
|
||||
|
||||
if not output:
|
||||
output = default_output_path(filename)
|
||||
|
||||
if should_generate_title(
|
||||
explicit_title=title_path,
|
||||
fence_fields=fence_fields,
|
||||
md_dir=md_dir,
|
||||
enabled=bool(req.auto_title),
|
||||
):
|
||||
profile = load_user_profile()
|
||||
student = (req.student or "").strip() or profile.student
|
||||
group = (req.group or "").strip() or profile.group
|
||||
if not student or not group:
|
||||
return fail(
|
||||
2,
|
||||
"Для генерации титула нужны ФИО и группа студента "
|
||||
"(GUI: Настройки → Студент…, или CLI: --student / --group).",
|
||||
)
|
||||
meta = merge_title_meta(
|
||||
yaml_fields=yaml_fields,
|
||||
fence_fields=fence_fields,
|
||||
student=student,
|
||||
group=group,
|
||||
)
|
||||
out_dir = os.path.dirname(os.path.abspath(output)) or md_dir
|
||||
generated_title_path = os.path.join(out_dir, ".md2gost_title.docx")
|
||||
try:
|
||||
build_title_docx(meta, generated_title_path)
|
||||
title_path = generated_title_path
|
||||
emit(f"Титульный лист: {generated_title_path}")
|
||||
except Exception as exc:
|
||||
return fail(1, f"Не удалось сгенерировать титул: {exc}")
|
||||
|
||||
check_report = ""
|
||||
if req.check or req.check_only:
|
||||
issues = check_markdown(
|
||||
md_text,
|
||||
body_md,
|
||||
req.doc_type,
|
||||
table_continuation=req.table_continuation,
|
||||
listing_continuation=req.listing_continuation,
|
||||
@@ -211,9 +306,6 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
message=check_report,
|
||||
)
|
||||
|
||||
if not output:
|
||||
output = default_output_path(filename)
|
||||
|
||||
template = (req.template or "").strip() or default_template_path()
|
||||
|
||||
handler = _CallbackLogHandler(emit)
|
||||
@@ -236,11 +328,12 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
listing_continuation=req.listing_continuation,
|
||||
hr_pagebreak=req.hr_pagebreak,
|
||||
styles_path=req.styles_path or None,
|
||||
markdown_text=body_md,
|
||||
)
|
||||
converter.convert()
|
||||
document = converter.document
|
||||
|
||||
if req.title or req.assignment:
|
||||
if title_path or req.assignment:
|
||||
try:
|
||||
from docxcompose.composer import Composer
|
||||
except ImportError:
|
||||
@@ -256,8 +349,8 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
body.remove(child)
|
||||
|
||||
composer = Composer(shell)
|
||||
if req.title:
|
||||
composer.append(Document(req.title))
|
||||
if title_path:
|
||||
composer.append(Document(title_path))
|
||||
shell.add_page_break()
|
||||
if req.assignment:
|
||||
composer.append(Document(req.assignment))
|
||||
@@ -267,19 +360,36 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
apply_style_config(document, style_cfg)
|
||||
_fix_front_matter_after_compose(
|
||||
document,
|
||||
had_title=bool(req.title),
|
||||
had_title=bool(title_path),
|
||||
had_assignment=bool(req.assignment),
|
||||
)
|
||||
|
||||
_apply_page_offset(
|
||||
document,
|
||||
req,
|
||||
had_title=bool(req.title),
|
||||
had_title=bool(title_path),
|
||||
had_assignment=bool(req.assignment),
|
||||
)
|
||||
|
||||
document.core_properties.author = getuser()
|
||||
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||
from .doc_metadata import apply_document_metadata, resolve_document_metadata
|
||||
from .user_profile import load_user_profile as _load_user_profile
|
||||
|
||||
profile = _load_user_profile()
|
||||
apply_document_metadata(
|
||||
document,
|
||||
resolve_document_metadata(
|
||||
stored=profile.metadata,
|
||||
student=(req.student or "").strip() or profile.student,
|
||||
author_source=req.doc_author_source,
|
||||
author=req.doc_author,
|
||||
last_modified_by=req.doc_last_modified_by,
|
||||
title=req.doc_title,
|
||||
subject=req.doc_subject,
|
||||
keywords=req.doc_keywords,
|
||||
comments=req.doc_comments,
|
||||
category=req.doc_category,
|
||||
),
|
||||
)
|
||||
try:
|
||||
document.save(output)
|
||||
except PermissionError:
|
||||
@@ -297,12 +407,19 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
root_log.removeHandler(handler)
|
||||
attached_diag.removeHandler(handler)
|
||||
attached_diag.setLevel(prev_diag_level)
|
||||
if generated_title_path and os.path.isfile(generated_title_path):
|
||||
try:
|
||||
os.remove(generated_title_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
abs_out = os.path.abspath(output)
|
||||
emit(f"Generated document: {abs_out}")
|
||||
|
||||
if req.table_continuation == "word" or req.listing_continuation == "word":
|
||||
from .word_fix import fix_continuations
|
||||
from .page_geometry import patch_docx_page_starts
|
||||
|
||||
fix = fix_continuations(
|
||||
abs_out,
|
||||
tables=(req.table_continuation == "word"),
|
||||
@@ -317,6 +434,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
check_report = (
|
||||
(check_report + "\n" + fix.message).strip() if check_report else fix.message
|
||||
)
|
||||
# Word Save often rewrites w:pgNumType/@w:start=\"1\" on every section — strip again.
|
||||
start_i, front = _page_offset_args(
|
||||
req,
|
||||
had_title=bool(title_path),
|
||||
had_assignment=bool(req.assignment),
|
||||
)
|
||||
try:
|
||||
patch_docx_page_starts(abs_out, start_i, front_sections=front)
|
||||
except Exception as exc:
|
||||
emit(f"Не удалось восстановить сквозную нумерацию после Word: {exc}")
|
||||
|
||||
if req.check_pages:
|
||||
from .page_fill_check import check_docx_page_fill, format_page_fill_report
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ def get_profile(doc_type: str) -> DocProfile:
|
||||
|
||||
# --- text autofixes ---
|
||||
|
||||
# ```uml / ```mermaid / ```uml-c4 / ```bpmn … — кавычки и тире там синтаксис
|
||||
# ```uml / ```mermaid / ```uml-c4 … — кавычки и тире там синтаксис
|
||||
_FENCE_RE = re.compile(
|
||||
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n"
|
||||
r"(?P<body>[\s\S]*?)"
|
||||
|
||||
@@ -293,7 +293,9 @@ class Paragraph(Renderable):
|
||||
yield SubRenderable(image, False)
|
||||
layout_state.add_height(rendered_image_height)
|
||||
else:
|
||||
# Defer this image and any remaining siblings to the next page
|
||||
# (as SubRenderable — bare Image objects break the renderer).
|
||||
yield SubRenderable(image, True)
|
||||
break
|
||||
|
||||
yield from images
|
||||
for rest in images:
|
||||
yield SubRenderable(rest, True)
|
||||
return
|
||||
|
||||
+93
-11
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Persistent student profile (ФИО + группа) and DOCX metadata defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .diagram_includes import app_dir
|
||||
from .doc_metadata import DocumentMetadata
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
USER_PROFILE_FILENAME = "md2gost.user.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserProfile:
|
||||
student: str = ""
|
||||
group: str = ""
|
||||
metadata: DocumentMetadata = field(default_factory=DocumentMetadata)
|
||||
|
||||
def is_complete(self) -> bool:
|
||||
return bool(self.student.strip() and self.group.strip())
|
||||
|
||||
|
||||
def user_profile_path(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / USER_PROFILE_FILENAME
|
||||
|
||||
|
||||
def load_user_profile(base: Path | None = None) -> UserProfile:
|
||||
path = user_profile_path(base)
|
||||
if not path.is_file():
|
||||
return UserProfile()
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_log.warning("Не удалось прочитать %s: %s", path, exc)
|
||||
return UserProfile()
|
||||
if not isinstance(raw, dict):
|
||||
return UserProfile()
|
||||
return UserProfile(
|
||||
student=str(raw.get("student") or "").strip(),
|
||||
group=str(raw.get("group") or "").strip(),
|
||||
metadata=DocumentMetadata.from_dict(raw.get("metadata")),
|
||||
)
|
||||
|
||||
|
||||
def save_user_profile(profile: UserProfile, base: Path | None = None) -> Path:
|
||||
path = user_profile_path(base)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta = profile.metadata or DocumentMetadata()
|
||||
data = {
|
||||
"student": (profile.student or "").strip(),
|
||||
"group": (profile.group or "").strip(),
|
||||
"metadata": meta.to_dict(),
|
||||
}
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return path
|
||||
+396
-15
@@ -81,6 +81,24 @@ def find_page_break_row(page_numbers: list[int]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
|
||||
"""True if a Word split would leave only the heading row on the previous page.
|
||||
|
||||
Method guide §3: do not leave «Таблица N» + column headers alone at page end.
|
||||
"""
|
||||
return bool(had_header and break_at == 2)
|
||||
|
||||
|
||||
def is_caption_orphaned(caption_page: int | None, first_row_page: int | None) -> bool:
|
||||
"""True if «Таблица N — …» sits on an earlier page than the table body.
|
||||
|
||||
Method guide: do not leave only the table title at the bottom of a page.
|
||||
"""
|
||||
if caption_page is None or first_row_page is None:
|
||||
return False
|
||||
return caption_page < first_row_page
|
||||
|
||||
|
||||
def continuation_label(kind: str, number: str) -> str:
|
||||
if kind == "listing":
|
||||
return f"Продолжение Листинга {number}"
|
||||
@@ -146,6 +164,11 @@ def fix_continuations(
|
||||
splits += made
|
||||
|
||||
skipped = sum(1 for d in details if d.startswith("skip:"))
|
||||
_clear_section_page_restarts(doc)
|
||||
try:
|
||||
doc.Fields.Update()
|
||||
except Exception as exc:
|
||||
details.append(f"skip: fields update: {exc}")
|
||||
doc.Save()
|
||||
msg = (
|
||||
f"Пост-разрыв Word: разрезов {splits}"
|
||||
@@ -175,25 +198,260 @@ def fix_continuations(
|
||||
pass
|
||||
|
||||
|
||||
def _clear_section_page_restarts(doc) -> None:
|
||||
"""Turn off page-number restart on every section (continuous PAGE through landscape)."""
|
||||
# wdHeaderFooterPrimary = 1, wdHeaderFooterFirstPage = 2, wdHeaderFooterEvenPages = 3
|
||||
for i in range(1, int(doc.Sections.Count) + 1):
|
||||
sec = doc.Sections(i)
|
||||
for hf_kind in (1, 2, 3):
|
||||
for getter in (sec.Headers, sec.Footers):
|
||||
try:
|
||||
hf = getter(hf_kind)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
pns = hf.PageNumbers
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if int(pns.Count) < 1:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
pns.RestartNumberingAtSection = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _row_page_numbers(table) -> list[int]:
|
||||
"""Page number at the start of each row (more reliable than end-of-range)."""
|
||||
"""Page number at the start of each row (more reliable than end-of-range).
|
||||
|
||||
Tables with vertical merges (`^` in md2gost) often raise on ``Rows(i).Range``;
|
||||
fall back to ``Cell(i, j).Range`` per column.
|
||||
"""
|
||||
pages: list[int] = []
|
||||
n = int(table.Rows.Count)
|
||||
for i in range(1, n + 1):
|
||||
try:
|
||||
rng = table.Rows(i).Range
|
||||
# Collapse to start so a tall row reports where it begins
|
||||
start = int(rng.Start)
|
||||
page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
try:
|
||||
page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
page = pages[-1] if pages else 1
|
||||
page = _page_at_row(table, i)
|
||||
if page is None:
|
||||
page = pages[-1] if pages else 1
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def _page_at_row(table, row_1based: int) -> int | None:
|
||||
"""Best-effort page number for a table row (handles vertical merges)."""
|
||||
# Prefer Cells — works when Rows(i) is blocked by vMerge.
|
||||
for j in range(1, 32):
|
||||
try:
|
||||
start = int(table.Cell(row_1based, j).Range.Start)
|
||||
return int(
|
||||
table.Range.Document.Range(start, start).Information(
|
||||
_WD_ACTIVE_END_PAGE_NUMBER
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Missing cell in this column (merge continue) or past last column
|
||||
if j == 1:
|
||||
continue
|
||||
# After first column, consecutive failures → stop scanning columns
|
||||
break
|
||||
try:
|
||||
rng = table.Rows(row_1based).Range
|
||||
start = int(rng.Start)
|
||||
return int(
|
||||
rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER)
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
return int(table.Rows(row_1based).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _probe_ncols(table) -> int:
|
||||
"""Max column index reachable from row 1 (and a few other rows)."""
|
||||
ncols = 1
|
||||
for row in (1, 2, 3):
|
||||
for probe in range(1, 64):
|
||||
try:
|
||||
table.Cell(row, probe)
|
||||
ncols = max(ncols, probe)
|
||||
except Exception:
|
||||
break
|
||||
return max(ncols, 1)
|
||||
|
||||
|
||||
def _rows_individually_accessible(table) -> bool:
|
||||
"""False when Word blocks ``Rows(i)`` due to vertical merges."""
|
||||
try:
|
||||
n = int(table.Rows.Count)
|
||||
if n < 1:
|
||||
return True
|
||||
_ = table.Rows(1).Range
|
||||
if n > 1:
|
||||
_ = table.Rows(n).Range
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _capture_vmerge_spans(table) -> list[tuple[int, int, int, str]]:
|
||||
"""Return ``(row, col, span, text)`` for each vertical merge (1-based, span>1)."""
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
return []
|
||||
ncols = _probe_ncols(table)
|
||||
spans: list[tuple[int, int, int, str]] = []
|
||||
r = 1
|
||||
while r <= nrows:
|
||||
for c in range(1, ncols + 1):
|
||||
try:
|
||||
cell = table.Cell(r, c)
|
||||
except Exception:
|
||||
continue
|
||||
span = 1
|
||||
while r + span <= nrows:
|
||||
try:
|
||||
table.Cell(r + span, c)
|
||||
break
|
||||
except Exception:
|
||||
row_exists = False
|
||||
for cc in range(1, ncols + 1):
|
||||
try:
|
||||
table.Cell(r + span, cc)
|
||||
row_exists = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not row_exists:
|
||||
break
|
||||
span += 1
|
||||
if span > 1:
|
||||
spans.append((r, c, span, _cell_plain_text(cell)))
|
||||
r += 1
|
||||
return spans
|
||||
|
||||
|
||||
def split_vmerge_spans_at_break(
|
||||
spans: list[tuple[int, int, int, str]],
|
||||
break_at: int,
|
||||
) -> tuple[list[tuple[int, int, int, str]], list[tuple[int, int, int, str]]]:
|
||||
"""Split captured merges across a 1-based ``Table.Split`` row index."""
|
||||
first: list[tuple[int, int, int, str]] = []
|
||||
cont: list[tuple[int, int, int, str]] = []
|
||||
for r, c, span, text in spans:
|
||||
if span <= 1:
|
||||
continue
|
||||
end = r + span - 1
|
||||
if end < break_at:
|
||||
first.append((r, c, span, text))
|
||||
elif r >= break_at:
|
||||
cont.append((r - break_at + 1, c, span, text))
|
||||
else:
|
||||
top = break_at - r
|
||||
if top > 1:
|
||||
first.append((r, c, top, text))
|
||||
bottom = end - break_at + 1
|
||||
if bottom >= 1:
|
||||
cont.append((1, c, bottom, text))
|
||||
return first, cont
|
||||
|
||||
|
||||
def _set_cell_plain_text(cell, text: str) -> None:
|
||||
"""Write plain text into a cell without eating the end-of-cell mark badly."""
|
||||
raw = (text or "").replace("\r", "").replace("\x07", "").replace("\a", "")
|
||||
try:
|
||||
rng = cell.Range.Duplicate
|
||||
# Exclude the cell's terminal marker characters
|
||||
if int(rng.End) > int(rng.Start) + 1:
|
||||
rng.End = int(rng.End) - 1
|
||||
rng.Text = raw
|
||||
except Exception:
|
||||
try:
|
||||
cell.Range.Text = raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_vmerge_spans(table, spans: list[tuple[int, int, int, str]]) -> int:
|
||||
"""Re-apply vertical merges after unmerge+Split. Returns merges restored."""
|
||||
made = 0
|
||||
for r, c, span, text in spans:
|
||||
if span <= 1:
|
||||
continue
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
if r < 1 or r > nrows or r + span - 1 > nrows:
|
||||
continue
|
||||
start = table.Cell(r, c)
|
||||
if text and not _cell_plain_text(start):
|
||||
_set_cell_plain_text(start, text)
|
||||
if span == 1:
|
||||
made += 1
|
||||
continue
|
||||
end = table.Cell(r + span - 1, c)
|
||||
start.Merge(end)
|
||||
made += 1
|
||||
except Exception:
|
||||
continue
|
||||
return made
|
||||
|
||||
|
||||
def _unmerge_vertical_cells(table) -> int:
|
||||
"""Split vertically merged cells so ``Table.Split`` can run.
|
||||
|
||||
Returns number of cell-groups unmerged. Safe no-op if nothing to do.
|
||||
Prefer capturing spans with ``_capture_vmerge_spans`` first and restoring
|
||||
after the table split.
|
||||
"""
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
return 0
|
||||
ncols = _probe_ncols(table)
|
||||
|
||||
made = 0
|
||||
r = 1
|
||||
while r <= nrows:
|
||||
for c in range(1, ncols + 1):
|
||||
try:
|
||||
cell = table.Cell(r, c)
|
||||
except Exception:
|
||||
continue
|
||||
span = 1
|
||||
while r + span <= nrows:
|
||||
try:
|
||||
table.Cell(r + span, c)
|
||||
break
|
||||
except Exception:
|
||||
row_exists = False
|
||||
for cc in range(1, ncols + 1):
|
||||
try:
|
||||
table.Cell(r + span, cc)
|
||||
row_exists = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not row_exists:
|
||||
break
|
||||
span += 1
|
||||
if span > 1:
|
||||
try:
|
||||
cell.Split(span, 1)
|
||||
made += 1
|
||||
except Exception:
|
||||
pass
|
||||
r += 1
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
break
|
||||
return made
|
||||
|
||||
|
||||
def _paragraph_page_numbers_in_cell(table) -> list[int]:
|
||||
"""Fallback: page of each paragraph in a single-cell table (legacy 1-row listings)."""
|
||||
try:
|
||||
@@ -245,6 +503,25 @@ def _expand_single_cell_listing_to_rows(table) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _paragraph_page(paragraph) -> int | None:
|
||||
try:
|
||||
start = int(paragraph.Range.Start)
|
||||
return int(
|
||||
paragraph.Range.Document.Range(start, start).Information(
|
||||
_WD_ACTIVE_END_PAGE_NUMBER
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _caption_page_before_table(table) -> int | None:
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return None
|
||||
return _paragraph_page(p)
|
||||
|
||||
|
||||
def _fix_pass(
|
||||
doc,
|
||||
*,
|
||||
@@ -285,18 +562,81 @@ def _fix_pass(
|
||||
doc.Repaginate()
|
||||
|
||||
pages = _row_page_numbers(table)
|
||||
# Method: only the title («Таблица N») at page bottom is forbidden.
|
||||
if caption.kind == "table" and pages:
|
||||
if is_caption_orphaned(_caption_page_before_table(table), pages[0]):
|
||||
try:
|
||||
if _page_break_before_table_caption(table):
|
||||
details.append(
|
||||
f"orphan-caption: {caption.kind} {caption.number} "
|
||||
f"→ page break before caption"
|
||||
)
|
||||
doc.Repaginate()
|
||||
else:
|
||||
details.append(
|
||||
f"skip: orphan-caption {caption.kind} {caption.number}: "
|
||||
f"no caption para"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: orphan-caption page break: {exc}")
|
||||
continue
|
||||
|
||||
break_at = find_page_break_row(pages)
|
||||
if break_at is None:
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
continue
|
||||
|
||||
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
|
||||
vmerge_spans: list[tuple[int, int, int, str]] = []
|
||||
if not _rows_individually_accessible(table):
|
||||
try:
|
||||
vmerge_spans = _capture_vmerge_spans(table)
|
||||
n_un = _unmerge_vertical_cells(table)
|
||||
if n_un:
|
||||
details.append(
|
||||
f"unmerge: {caption.kind} {caption.number} ({n_un} groups)"
|
||||
)
|
||||
doc.Repaginate()
|
||||
table = doc.Tables(ti)
|
||||
pages = _row_page_numbers(table)
|
||||
break_at = find_page_break_row(pages)
|
||||
if break_at is None:
|
||||
details.append(
|
||||
f"skip: {caption.kind} {caption.number} "
|
||||
f"no page break after unmerge"
|
||||
)
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
continue
|
||||
except Exception as exc:
|
||||
details.append(f"skip: unmerge {caption.kind} {caption.number}: {exc}")
|
||||
continue
|
||||
|
||||
had_header = False
|
||||
try:
|
||||
had_header = bool(table.Rows(1).HeadingFormat)
|
||||
except Exception:
|
||||
had_header = False
|
||||
|
||||
# Method guide: caption + column headers alone on a page is forbidden.
|
||||
# If Word broke right after the heading row, push the table (caption) to
|
||||
# the next page instead of splitting into a header-only fragment.
|
||||
if is_orphan_header_break(break_at, had_header=had_header):
|
||||
try:
|
||||
if _page_break_before_table_caption(table):
|
||||
details.append(
|
||||
f"orphan-header: {caption.kind} {caption.number} → page break before caption"
|
||||
)
|
||||
doc.Repaginate()
|
||||
else:
|
||||
details.append(
|
||||
f"skip: orphan-header {caption.kind} {caption.number}: no caption para"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: orphan-header page break: {exc}")
|
||||
continue
|
||||
|
||||
try:
|
||||
table.Split(break_at)
|
||||
except Exception as exc:
|
||||
@@ -304,12 +644,28 @@ def _fix_pass(
|
||||
continue
|
||||
|
||||
try:
|
||||
table = doc.Tables(ti)
|
||||
cont = doc.Tables(ti + 1)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
|
||||
made += 1
|
||||
continue
|
||||
|
||||
if vmerge_spans:
|
||||
try:
|
||||
first_spans, cont_spans = split_vmerge_spans_at_break(
|
||||
vmerge_spans, break_at
|
||||
)
|
||||
n1 = _restore_vmerge_spans(table, first_spans)
|
||||
n2 = _restore_vmerge_spans(cont, cont_spans)
|
||||
if n1 or n2:
|
||||
details.append(
|
||||
f"remerge: {caption.kind} {caption.number} "
|
||||
f"(first={n1}, cont={n2})"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: remerge {caption.kind} {caption.number}: {exc}")
|
||||
|
||||
before_cont = _paragraph_text_before_table(cont)
|
||||
first_line = before_cont.strip().split("\n", 1)[0] if before_cont else ""
|
||||
if not (first_line and _CONTINUATION_RE.match(first_line)):
|
||||
@@ -332,7 +688,7 @@ def _fix_pass(
|
||||
except Exception as exc:
|
||||
details.append(f"skip: listing borders: {exc}")
|
||||
|
||||
# Header repeat is opt-in (default off)
|
||||
# Header repeat is opt-in (default off) — not Word's native tblHeader paint
|
||||
if repeat_header and had_header and caption.kind == "table":
|
||||
try:
|
||||
_ensure_header_on_continuation(first_table=table, cont_table=cont)
|
||||
@@ -351,19 +707,44 @@ def _caption_before_table(table) -> CaptionInfo | None:
|
||||
return parse_caption_text(text)
|
||||
|
||||
|
||||
def _paragraph_text_before_table(table) -> str:
|
||||
def _paragraph_before_table(table):
|
||||
"""Return the Word Paragraph immediately before ``table``, or None."""
|
||||
try:
|
||||
rng = table.Range
|
||||
if rng.Start <= 1:
|
||||
return ""
|
||||
return None
|
||||
doc = table.Range.Document
|
||||
prev = doc.Range(rng.Start - 1, rng.Start)
|
||||
p = prev.Paragraphs(1)
|
||||
return prev.Paragraphs(1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _paragraph_text_before_table(table) -> str:
|
||||
try:
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return ""
|
||||
return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _page_break_before_table_caption(table) -> bool:
|
||||
"""Set PageBreakBefore on the caption paragraph above the table."""
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return False
|
||||
info = parse_caption_text(
|
||||
(p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
)
|
||||
if info is None or info.is_continuation:
|
||||
return False
|
||||
p.Format.PageBreakBefore = True
|
||||
p.Format.KeepWithNext = True
|
||||
return True
|
||||
|
||||
|
||||
def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None:
|
||||
"""Insert caption paragraph *outside* the table (before its start).
|
||||
|
||||
|
||||
+2
-1
@@ -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 / сообщение агенту;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Промпт: человечный учебный текст (не отчёт ИИ)
|
||||
|
||||
Скопируйте **весь** текст ниже **после** базового промпта md2gost (`generate-md` / `generate-mirea-report` / `generate-pis-custom-report`) или вторым сообщением.
|
||||
|
||||
Тема, объект, факты — из следующего сообщения пользователя. Этот блок **не привязан** к конкретному предмету: он задаёт только голос.
|
||||
|
||||
---
|
||||
|
||||
Ты пишешь учебный текст так, чтобы он читался как работа студента-человека, а не как отчёт модели о выполненных командах.
|
||||
|
||||
## Главное
|
||||
|
||||
Пиши **про предмет работы**. Не пиши про то, что задание сделано, пункт закрыт или следующий этап курса уже «зафиксирован».
|
||||
|
||||
Читатель должен узнать объект / тему. Он не должен видеть пайплайн промпта, чеклист ТЗ и разговор с невидимым проверяющим.
|
||||
|
||||
## Как строить кусок текста
|
||||
|
||||
1. Тезис по сути.
|
||||
2. Опора: таблица, рисунок, число, факт.
|
||||
3. Короткий вывод **про сам предмет** (1–3 предложения).
|
||||
|
||||
Не начинай раздел преамбулой «зачем этот блок существует». Запрещены обороты вроде: «X является исходным условием…», «без X невозможны…», «что позволяет связать предмет…», «а не подменять…». Сразу давай содержание.
|
||||
|
||||
Факт имеет право просто быть. Не пристёгивай каждое предложение к следующей главе, «предмету исследования», автоматизации, пользе для проверяющего или «прикладной ценности полученной характеристики» — если пользователь сам не сделал это темой.
|
||||
|
||||
## Введение и заключение
|
||||
|
||||
**Введение:** цель → объект / тема → почему взято → методы. Без оглавления остальных работ курса. Без фраз вроде «роботизация закрепляется за…», «предметом последующих работ принимается…», «целевая архитектура должна…».
|
||||
|
||||
**Заключение:** сожми предмет — что это, как устроено, что важно.
|
||||
Запрещены глаголы сдачи работы: «сформирована характеристика», «установлены», «определён», «построена», «зафиксирован предмет».
|
||||
Запрещён план следующих лаб вместо вывода по теме.
|
||||
|
||||
## Таблицы
|
||||
|
||||
Таблица описывает предмет. Последняя колонка — смысл в теме (содержание, влияние, назначение в деятельности).
|
||||
Не делай колонки: «значение для объекта исследования», «роль в предмете исследования», «назначение для характеристики», «как пригодится дальше / для автоматизации».
|
||||
|
||||
## Тон и язык
|
||||
|
||||
- Безличность нормальна: «выбрано», «представлены», «систематизированы». Без «я» / «мы».
|
||||
- Ссылки по делу, не в каждом абзаце. Методички «как писать работу» в содержательную часть не тащи.
|
||||
- Допустима лёгкая неровность: не каждый абзац одной длины, не каждое предложение «отрабатывает ценность».
|
||||
- Не заканчивай абзацы мета-моралью: «следовательно, целевое решение должно…», «полученная характеристика задаёт основу…», «прикладное значение работы состоит в…» — если это не запрошенная тема.
|
||||
|
||||
## Чего избегать (запахи отчёта ИИ)
|
||||
|
||||
- Текст отвечает на вопрос «что я сделал по пунктам», а не «что это за объект / тема».
|
||||
- Каждый абзац продаёт себя проверяющему.
|
||||
- Идеальная симметрия, плотный ряд умных ярлыков подряд, консультантский лоск без живого факта.
|
||||
- Спор с невидимым рецензентом внутри основного текста.
|
||||
- Комментарии к собственному методу вместо содержания.
|
||||
|
||||
## Выход
|
||||
|
||||
Соблюдай формат и диалект из базового промпта md2gost. Этот блок меняет только **голос и логику изложения**, не синтаксис разметки.
|
||||
+47
-5
@@ -78,19 +78,61 @@ md2gost читает расширения ниже, нумерует объек
|
||||
|
||||
**Не** экспортируй UML/Mermaid в PNG вручную и **не** пиши «вставь картинку сюда».
|
||||
|
||||
В `.md` оставь fenced-блок — md2gost превратит его в **Рисунок** (PlantUML jar / Kroki; Mermaid — через Kroki).
|
||||
В `.md` оставь fenced-блок — md2gost превратит его в **Рисунок** (PlantUML jar / Kroki; Mermaid — локально или Kroki; IDEF0 — Pillow; DFD — data-flow-diagram + Graphviz/Kroki).
|
||||
|
||||
| Оградка | Содержание тела |
|
||||
|---------|-----------------|
|
||||
| `` ```uml `` / `` ```plantuml `` | Полный PlantUML, обычно с `@startuml`…`@enduml` |
|
||||
| `` ```uml-<id> `` / `` ```<id> `` | Только макросы схемы (C4, BPMN, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама |
|
||||
| `` ```uml-<id> `` / `` ```<id> `` | Только макросы схемы (C4, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама |
|
||||
| `` ```mermaid `` / `` ```mmd `` | Синтаксис Mermaid |
|
||||
| `` ```idef0 `` | IDEF0: боксы, ICOM-стрелки, рамка A-0 |
|
||||
| `` ```dfd `` / `` ```data-flow-diagram `` | DFD ([pbauermeister/dfd](https://github.com/pbauermeister/dfd)): process/entity/store, `-->` |
|
||||
|
||||
Перед блоком — `%id Подпись` (и ссылка `@Рисунок:id` в тексте).
|
||||
|
||||
**IDEF0** конвертер не рисует — только готовый PNG как обычный Рисунок.
|
||||
IDEF0 пиши в ````idef0`, не внешним PNG. Пример контекста A-0:
|
||||
|
||||
Если в конец этого промпта добавлены блоки «Схема: …» — используй их макросы и оградки. Если блоков нет — для C4/BPMN/usecase всё равно можно писать `` ```uml-c4 ``, `` ```bpmn ``, `` ```usecase `` с типичными макросами; детали пользователь может дописать из GUI.
|
||||
~~~markdown
|
||||
%a0 Контекстная диаграмма A-0
|
||||
|
||||
```idef0
|
||||
title Распорядиться товаром
|
||||
node A-0
|
||||
number 1
|
||||
author Кинзябулатов Рамиль
|
||||
project Разработка универсальной модели торгового предприятия
|
||||
date 08.02.2022
|
||||
rev 31.03.2022
|
||||
status PUBLICATION
|
||||
context TOP
|
||||
|
||||
[A0] Распорядиться товаром
|
||||
<- Спрос
|
||||
^ Нормативная документация
|
||||
-> Товар
|
||||
v Персонал
|
||||
```
|
||||
~~~
|
||||
|
||||
DFD пиши в ````dfd` (синтаксис [pbauermeister/dfd](https://github.com/pbauermeister/dfd)), не внешним PNG. Пример контекста:
|
||||
|
||||
~~~markdown
|
||||
%dfd0 Контекстная DFD
|
||||
|
||||
```dfd
|
||||
style context
|
||||
|
||||
entity Client Клиент
|
||||
entity Warehouse Склад
|
||||
process System Система учёта заявок
|
||||
|
||||
Client --> System заявка
|
||||
System --> Client статус
|
||||
System --> Warehouse накладная
|
||||
```
|
||||
~~~
|
||||
|
||||
Если в конец этого промпта добавлены блоки «Схема: …» — используй их макросы и оградки. Если блоков нет — для C4/usecase всё равно можно писать `` ```uml-c4 ``, `` ```usecase `` с типичными макросами; детали пользователь может дописать из GUI.
|
||||
|
||||
Пример схемы:
|
||||
|
||||
@@ -110,7 +152,7 @@ Rel(user, app, "логин")
|
||||
|
||||
1. Структура соответствует типу работы (МИРЭА / ПИС / иное — из входа пользователя).
|
||||
2. У каждого `%id` / title картинки есть `@Тип:id` в тексте.
|
||||
3. Диаграммы — fence в md, не внешние PNG (кроме IDEF0 и готовых иллюстраций).
|
||||
3. Диаграммы — fence в md, не внешние PNG (кроме готовых иллюстраций).
|
||||
4. Нет «рис.» и ссылок `[n]` во введении/заключении.
|
||||
|
||||
После проверки выдай только markdown.
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
~~~
|
||||
|
||||
### Листинг
|
||||
|
||||
@@ -62,7 +62,7 @@ User --> (Login)
|
||||
```
|
||||
```
|
||||
|
||||
Языки fence: `uml`, `plantuml`, `mermaid` / `mmd`, `c4` / `uml-c4`, `usecase`, `bpmn` / `uml-bpmn` и другие id из `md2gost.schemes.json`. Mermaid — через Kroki. IDEF0 не поддерживается. Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником; `+landscape` — альбомная страница.
|
||||
Языки fence: `uml`, `plantuml`, `mermaid` / `mmd`, `idef0`, `dfd` / `data-flow-diagram`, `c4` / `uml-c4`, `usecase` и другие id из `md2gost.schemes.json`. Mermaid — через Kroki. IDEF0 — локально в ````idef0`. DFD — [pbauermeister/dfd](https://github.com/pbauermeister/dfd) в ````dfd` (рендер: Graphviz вшитый/PATH или Kroki). Флаг `+listing` в строке `%` добавляет ещё Листинг с исходником; `+landscape` — альбомная страница.
|
||||
Ссылки: `@Рисунок:usecase1`, `@Листинг:usecase1`.
|
||||
|
||||
Формула (обычно работа №8): `$$ … $$` с `%eq_id` и ссылкой `@Формула:eq_id`.
|
||||
|
||||
+11
-3
@@ -3,7 +3,7 @@ name = "md2gost"
|
||||
version = "0.1.0"
|
||||
description = "Markdown to DOCX (MIREA TZ / GOST 7.32)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"python-docx>=0.8.11,<1",
|
||||
"marko>=2.0.0,<3",
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"lxml>=4.9,<6",
|
||||
"mermaidx>=0.9.0,<1",
|
||||
"playwright>=1.40.0,<2",
|
||||
"data-flow-diagram>=1.16.0,<2",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -23,6 +24,7 @@ md2gost = "md2gost.__main__:main"
|
||||
md2gost-gui = "md2gost.gui:main"
|
||||
md2latex = "md2latex.__main__:main"
|
||||
md2fodt = "md2fodt.__main__:main"
|
||||
word2md = "word2md.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -39,24 +41,29 @@ requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["md2gost", "md2latex", "md2fodt"]
|
||||
packages = ["md2gost", "md2latex", "md2fodt", "word2md"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"md2gost/Template.docx" = "md2gost/Template.docx"
|
||||
"md2gost/TitleTemplate.docx" = "md2gost/TitleTemplate.docx"
|
||||
"md2gost/mml2omml" = "md2gost/mml2omml"
|
||||
"md2gost/diagrams" = "md2gost/diagrams"
|
||||
"latex/mirea" = "latex/mirea"
|
||||
"prompts/generate-md.md" = "md2gost/prompts/generate-md.md"
|
||||
"prompts/generate-mirea-report.md" = "md2gost/prompts/generate-mirea-report.md"
|
||||
"prompts/generate-pis-custom-report.md" = "md2gost/prompts/generate-pis-custom-report.md"
|
||||
"prompts/emulate-student.md" = "md2gost/prompts/emulate-student.md"
|
||||
"docs/README.md" = "md2gost/docs/README.md"
|
||||
"docs/quickstart.md" = "md2gost/docs/quickstart.md"
|
||||
"docs/markdown.md" = "md2gost/docs/markdown.md"
|
||||
"docs/schemes.md" = "md2gost/docs/schemes.md"
|
||||
"docs/archimate.md" = "md2gost/docs/archimate.md"
|
||||
"docs/types.md" = "md2gost/docs/types.md"
|
||||
"docs/cli-gui.md" = "md2gost/docs/cli-gui.md"
|
||||
"docs/title.md" = "md2gost/docs/title.md"
|
||||
"docs/styles.md" = "md2gost/docs/styles.md"
|
||||
"docs/prompts.md" = "md2gost/docs/prompts.md"
|
||||
"docs/word2md.md" = "md2gost/docs/word2md.md"
|
||||
|
||||
# Legacy Poetry metadata (optional; prefer uv + [project] above)
|
||||
[tool.poetry]
|
||||
@@ -67,7 +74,7 @@ authors = ["witelokk <death.thunder@yandex.ru>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
python = "^3.11"
|
||||
python-docx = "^0.8.11"
|
||||
marko = "^2.0.0"
|
||||
docxcompose = "^1.4.0"
|
||||
@@ -79,6 +86,7 @@ latex2mathml = "^3.76.0"
|
||||
pygments = "^2.16.1"
|
||||
mermaidx = "^0.9.0"
|
||||
playwright = "^1.40.0"
|
||||
data-flow-diagram = "^1.16.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
md2gost = "md2gost.__main__:main"
|
||||
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -0,0 +1,33 @@
|
||||
# Runtime hook: load this CPython's Tcl/Tk before Graphviz's tcl86t.dll (8.6.10)
|
||||
# can shadow them. PyInstaller then points TCL_LIBRARY at bundled _tcl_data (8.6.12).
|
||||
def _pyi_rthook():
|
||||
import os
|
||||
import sys
|
||||
|
||||
if not getattr(sys, "frozen", False):
|
||||
return
|
||||
base = getattr(sys, "_MEIPASS", None) or os.path.dirname(sys.executable)
|
||||
os.environ["PATH"] = base + os.pathsep + os.environ.get("PATH", "")
|
||||
try:
|
||||
os.add_dll_directory(base)
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
tcldir = os.path.join(base, "_tcl_data")
|
||||
tkdir = os.path.join(base, "_tk_data")
|
||||
if os.path.isdir(tcldir):
|
||||
os.environ["TCL_LIBRARY"] = tcldir
|
||||
if os.path.isdir(tkdir):
|
||||
os.environ["TK_LIBRARY"] = tkdir
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
for name in ("tcl86t.dll", "tk86t.dll"):
|
||||
path = os.path.join(base, name)
|
||||
if os.path.isfile(path):
|
||||
ctypes.WinDLL(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_pyi_rthook()
|
||||
del _pyi_rthook
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,78 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2400" height="1700" viewBox="0 0 2400 1700">
|
||||
<rect width="100%" height="100%" fill="#fff"/>
|
||||
<rect x="16.0" y="16.0" width="2368.0" height="1668.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<rect x="22.4" y="22.4" width="2355.2" height="1655.2" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="22.4" y1="214.4" x2="2377.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="22.4" y1="1549.6" x2="2377.6" y2="1549.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="363.9" y1="22.4" x2="363.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1200.0" y1="22.4" x2="1200.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1612.2" y1="22.4" x2="1612.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1953.7" y1="22.4" x2="1953.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1200.0" y1="66.4" x2="1612.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1426.7" y1="22.4" x2="1426.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
|
||||
<text x="375.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
|
||||
<text x="487.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
|
||||
<text x="375.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
|
||||
<text x="503.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
|
||||
<text x="375.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
|
||||
<text x="479.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
|
||||
<text x="507.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
|
||||
<text x="535.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
|
||||
<text x="563.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
|
||||
<text x="591.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
|
||||
<text x="619.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
|
||||
<text x="647.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
|
||||
<text x="675.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
|
||||
<text x="703.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
|
||||
<text x="731.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
|
||||
<text x="1210.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
|
||||
<text x="1284.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
|
||||
<text x="1434.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
|
||||
<text x="1498.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
|
||||
<rect x="1212.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1236.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
|
||||
<rect x="1212.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1236.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
|
||||
<rect x="1212.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1236.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
|
||||
<rect x="1212.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1236.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
|
||||
<text x="1624.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
|
||||
<line x1="1612.2" y1="66.4" x2="1953.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="1624.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
|
||||
<text x="1965.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
|
||||
<text x="2165.6" y="134.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="700" fill="#000000">TOP</text>
|
||||
<line x1="446.3" y1="1549.6" x2="446.3" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1906.6" y1="1549.6" x2="1906.6" y2="1677.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="34.4" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
|
||||
<text x="234.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A-0</text>
|
||||
<text x="458.3" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
|
||||
<text x="1176.4" y="1625.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
|
||||
<text x="1918.6" y="1557.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
|
||||
<text x="2142.1" y="1621.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">1</text>
|
||||
<rect x="2341.6" y="1641.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="2359.6" y="1659.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">1</text>
|
||||
<line x1="38.4" y1="870.0" x2="888.0" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="888.0,870.0 872.0,863.3 872.0,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1208.0" y1="230.4" x2="1208.0" y2="732.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1208.0,732.0 1201.3,716.0 1214.7,716.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1528.0" y1="870.0" x2="2361.6" y2="870.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="2361.6,870.0 2345.6,863.3 2345.6,876.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1208.0" y1="1533.6" x2="1208.0" y2="1008.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1208.0,1008.0 1201.3,1024.0 1214.7,1024.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<rect x="406.6" y="799.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="446.2" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
|
||||
<rect x="1007.8" y="438.7" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1091.9" y="452.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
|
||||
<text x="1091.9" y="479.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
|
||||
<rect x="1889.0" y="799.9" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1928.1" y="814.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
|
||||
<rect x="1056.8" y="1262.3" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1116.4" y="1276.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
|
||||
<rect x="896.0" y="740.0" width="640.0" height="276.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
|
||||
<rect x="888.0" y="732.0" width="640.0" height="276.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="888.0,732.0 904.0,732.0 888.0,748.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
|
||||
<text x="1208.0" y="864.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Распорядиться товаром</text>
|
||||
<text x="1516.0" y="998.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A0</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,166 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2800" height="1840" viewBox="0 0 2800 1840">
|
||||
<rect width="100%" height="100%" fill="#fff"/>
|
||||
<rect x="16.0" y="16.0" width="2768.0" height="1808.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<rect x="22.4" y="22.4" width="2755.2" height="1795.2" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="22.4" y1="214.4" x2="2777.6" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="22.4" y1="1689.6" x2="2777.6" y2="1689.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="421.9" y1="22.4" x2="421.9" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1400.0" y1="22.4" x2="1400.0" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1882.2" y1="22.4" x2="1882.2" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="2281.7" y1="22.4" x2="2281.7" y2="214.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1400.0" y1="66.4" x2="1882.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1665.2" y1="22.4" x2="1665.2" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="34.4" y="32.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">USED AT:</text>
|
||||
<text x="433.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">AUTHOR:</text>
|
||||
<text x="545.9" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">Кинзябулатов Рамиль</text>
|
||||
<text x="433.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">PROJECT:</text>
|
||||
<text x="561.9" y="66.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">Разработка универсальной модели торгового предприятия</text>
|
||||
<text x="433.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NOTES:</text>
|
||||
<text x="537.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">1</text>
|
||||
<text x="565.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">2</text>
|
||||
<text x="593.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">3</text>
|
||||
<text x="621.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">4</text>
|
||||
<text x="649.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">5</text>
|
||||
<text x="677.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">6</text>
|
||||
<text x="705.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">7</text>
|
||||
<text x="733.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">8</text>
|
||||
<text x="761.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">9</text>
|
||||
<text x="789.9" y="130.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">10</text>
|
||||
<text x="1410.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE:</text>
|
||||
<text x="1484.0" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">08.02.2022</text>
|
||||
<text x="1673.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">REV:</text>
|
||||
<text x="1737.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="22.0" font-weight="400" fill="#000000">31.03.2022</text>
|
||||
<rect x="1412.0" y="80.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1436.0" y="78.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">WORKING</text>
|
||||
<rect x="1412.0" y="112.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1436.0" y="110.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">DRAFT</text>
|
||||
<rect x="1412.0" y="144.4" width="16.0" height="16.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1436.0" y="142.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="400" fill="#000000">RECOMMENDED</text>
|
||||
<rect x="1412.0" y="176.4" width="16.0" height="16.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="1436.0" y="174.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">PUBLICATION</text>
|
||||
<text x="1894.2" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">READER</text>
|
||||
<line x1="1882.2" y1="66.4" x2="2281.7" y2="66.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="1894.2" y="74.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">DATE</text>
|
||||
<text x="2293.7" y="30.4" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">CONTEXT:</text>
|
||||
<rect x="2475.6" y="90.4" width="116.0" height="56.0" fill="#c4c4c4" stroke="none" stroke-width="0.00"/>
|
||||
<rect x="2471.6" y="86.4" width="116.0" height="56.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="2579.6" y="136.4" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">A-0</text>
|
||||
<line x1="518.3" y1="1689.6" x2="518.3" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="2226.6" y1="1689.6" x2="2226.6" y2="1817.6" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<text x="34.4" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NODE:</text>
|
||||
<text x="270.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">A0</text>
|
||||
<text x="530.3" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">TITLE:</text>
|
||||
<text x="1372.4" y="1765.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="32.0" font-weight="700" fill="#000000">Распорядиться товаром</text>
|
||||
<text x="2238.6" y="1697.6" dx="0" dy="0.9em" text-anchor="start" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="18.0" font-weight="400" fill="#000000">NUMBER:</text>
|
||||
<text x="2502.1" y="1761.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="40.0" font-weight="700" fill="#000000">2</text>
|
||||
<rect x="2741.6" y="1781.6" width="36.0" height="36.0" fill="none" stroke="#000000" stroke-width="2.00"/>
|
||||
<text x="2759.6" y="1799.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="19.0" font-weight="700" fill="#000000">2</text>
|
||||
<line x1="38.4" y1="652.0" x2="696.0" y2="652.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="696.0,652.0 680.0,645.3 680.0,658.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="38.4" y1="891.8" x2="1208.0" y2="891.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1208.0,891.8 1192.0,885.1 1192.0,898.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1296.0" y1="230.4" x2="1296.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1296.0" y1="278.4" x2="784.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="784.0" y1="278.4" x2="784.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1296.0" y1="230.4" x2="1296.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1296.0" y1="278.4" x2="1808.0" y2="278.4" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1808.0" y1="278.4" x2="1808.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="784.0,566.0 777.3,550.0 790.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1296.0,854.0 1289.3,838.0 1302.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1808.0,1142.0 1801.3,1126.0 1814.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1008.0" y1="230.4" x2="1008.0" y2="566.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1008.0,566.0 1001.3,550.0 1014.7,550.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="2032.0" y1="230.4" x2="2032.0" y2="1142.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="2032.0,1142.0 2025.3,1126.0 2038.7,1126.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1096.0" y1="603.8" x2="1152.0" y2="603.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1152.0" y1="603.8" x2="1152.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1152.0" y1="988.2" x2="1208.0" y2="988.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1208.0,988.2 1192.0,981.4 1192.0,994.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1096.0" y1="700.2" x2="1216.0" y2="700.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1216.0" y1="700.2" x2="1216.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1216.0" y1="798.0" x2="1520.0" y2="798.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1520.0" y1="798.0" x2="1520.0" y2="854.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1520.0,854.0 1513.3,838.0 1526.7,838.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1608.0" y1="940.0" x2="1664.0" y2="940.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1664.0" y1="940.0" x2="1664.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1664.0" y1="1228.0" x2="1720.0" y2="1228.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1720.0,1228.0 1704.0,1221.3 1704.0,1234.7" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="2120.0" y1="1179.8" x2="2761.6" y2="1179.8" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="2761.6,1179.8 2745.6,1173.1 2745.6,1186.6" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="2120.0" y1="1276.2" x2="2761.6" y2="1276.2" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="2761.6,1276.2 2745.6,1269.4 2745.6,1282.9" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1408.0" y1="1673.6" x2="1408.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1408.0" y1="1362.0" x2="896.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="896.0" y1="1362.0" x2="896.0" y2="738.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1408.0" y1="1362.0" x2="1296.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1296.0" y1="1362.0" x2="1296.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1408.0" y1="1362.0" x2="1920.0" y2="1362.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<line x1="1920.0" y1="1362.0" x2="1920.0" y2="1314.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="896.0,738.0 889.3,754.0 902.7,754.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1296.0,1026.0 1289.3,1042.0 1302.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1920.0,1314.0 1913.3,1330.0 1926.7,1330.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<line x1="1520.0" y1="1673.6" x2="1520.0" y2="1026.0" stroke="#000" stroke-width="2.00" stroke-linecap="square"/>
|
||||
<polygon points="1520.0,1026.0 1513.3,1042.0 1526.7,1042.0" fill="#000000" stroke="#000000" stroke-width="2.00"/>
|
||||
<rect x="42.4" y="627.0" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="54.1" y="637.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I1</text>
|
||||
<rect x="42.4" y="866.8" width="23.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="54.1" y="877.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">I2</text>
|
||||
<rect x="1306.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1321.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C1</text>
|
||||
<rect x="1018.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1033.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C2</text>
|
||||
<rect x="2042.0" y="234.4" width="31.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="2057.7" y="245.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">C3</text>
|
||||
<rect x="2724.2" y="1154.8" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="2740.9" y="1165.5" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O1</text>
|
||||
<rect x="2724.2" y="1251.2" width="33.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="2740.9" y="1261.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">O2</text>
|
||||
<rect x="1418.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1435.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M1</text>
|
||||
<rect x="1530.0" y="1648.2" width="34.4" height="21.4" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1547.2" y="1658.9" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="700" fill="#000000">M2</text>
|
||||
<rect x="314.4" y="581.9" width="79.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="354.0" y="596.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Спрос</text>
|
||||
<rect x="518.2" y="794.9" width="163.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="599.8" y="809.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Предложения</text>
|
||||
<text x="599.8" y="836.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставщиков</text>
|
||||
<rect x="1211.9" y="295.0" width="168.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1296.0" y="309.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Нормативная</text>
|
||||
<text x="1296.0" y="336.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">документация</text>
|
||||
<rect x="1040.0" y="392.6" width="195.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1137.6" y="406.8" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Ассортиментная</text>
|
||||
<text x="1137.6" y="433.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">политика</text>
|
||||
<rect x="1889.8" y="635.5" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1944.9" y="649.7" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Условия</text>
|
||||
<text x="1944.9" y="676.6" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставки</text>
|
||||
<rect x="1122.9" y="533.8" width="155.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1200.5" y="548.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">план закупок</text>
|
||||
<rect x="1165.6" y="603.2" width="110.2" height="65.0" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1220.7" y="617.4" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">график</text>
|
||||
<text x="1220.7" y="644.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">поставок</text>
|
||||
<rect x="1634.9" y="869.9" width="192.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1731.0" y="884.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">товар к отгрузке</text>
|
||||
<rect x="2388.9" y="1109.8" width="78.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="2428.0" y="1124.0" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Товар</text>
|
||||
<rect x="2352.9" y="1206.1" width="150.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="2428.0" y="1220.3" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Акт отгрузки</text>
|
||||
<rect x="1256.8" y="1505.0" width="119.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1316.4" y="1519.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Персонал</text>
|
||||
<rect x="1407.8" y="1279.0" width="80.2" height="38.1" fill="#ffffff" stroke="none" stroke-width="0.00"/>
|
||||
<text x="1447.9" y="1293.2" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="24.0" font-weight="400" fill="#000000">Склад</text>
|
||||
<rect x="704.0" y="574.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
|
||||
<rect x="696.0" y="566.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="696.0,566.0 712.0,566.0 696.0,582.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
|
||||
<text x="896.0" y="646.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Принять и оценить спрос</text>
|
||||
<text x="1084.0" y="728.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A1</text>
|
||||
<rect x="1216.0" y="862.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
|
||||
<rect x="1208.0" y="854.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1208.0,854.0 1224.0,854.0 1208.0,870.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
|
||||
<text x="1408.0" y="934.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Закупить и хранить</text>
|
||||
<text x="1596.0" y="1016.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A2</text>
|
||||
<rect x="1728.0" y="1150.0" width="400.0" height="172.0" fill="#c0c0c0" stroke="none" stroke-width="0.00"/>
|
||||
<rect x="1720.0" y="1142.0" width="400.0" height="172.0" fill="#ffffff" stroke="#000000" stroke-width="2.00"/>
|
||||
<polygon points="1720.0,1142.0 1736.0,1142.0 1720.0,1158.0" fill="#000000" stroke="#000000" stroke-width="1.00"/>
|
||||
<text x="1920.0" y="1222.1" dx="0" dy="0.35em" text-anchor="middle" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="26.0" font-weight="400" fill="#000000">Отгрузить товар</text>
|
||||
<text x="2108.0" y="1304.0" dx="0" dy="0" text-anchor="end" dominant-baseline="alphabetic" font-family="Arial, DejaVu Sans, sans-serif" font-size="20.0" font-weight="400" fill="#000000">A3</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,194 @@
|
||||
"""DFD via pbauermeister/data-flow-diagram (Graphviz / Kroki)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from md2gost.diagram_renderer import (
|
||||
DIAGRAM_LANGS,
|
||||
configure_diagrams,
|
||||
is_diagram_lang,
|
||||
prepare_source,
|
||||
render_diagram,
|
||||
)
|
||||
from md2gost.dfd import (
|
||||
SAMPLE_CONTEXT,
|
||||
SAMPLE_LEVEL0,
|
||||
DfdParseError,
|
||||
_compile_dot,
|
||||
write_dfd_outputs,
|
||||
)
|
||||
from md2gost.extended_markdown import markdown
|
||||
from md2gost.extended_markdown.caption import Caption as CapEl
|
||||
from md2gost.renderable.caption import CaptionInfo
|
||||
from md2gost.renderable.diagram import DiagramFigure
|
||||
from md2gost.renderable_factory import RenderableFactory
|
||||
|
||||
|
||||
def test_is_diagram_lang_dfd():
|
||||
assert is_diagram_lang("dfd")
|
||||
assert is_diagram_lang("uml-dfd")
|
||||
assert is_diagram_lang("data-flow-diagram")
|
||||
assert is_diagram_lang("yourdon")
|
||||
assert is_diagram_lang("uml-yourdon")
|
||||
assert "dfd" in DIAGRAM_LANGS
|
||||
|
||||
|
||||
def test_prepare_dfd_stays_native():
|
||||
src, dtype = prepare_source("dfd", SAMPLE_CONTEXT)
|
||||
assert dtype == "dfd"
|
||||
assert "@startuml" not in src.lower()
|
||||
assert "process" in src
|
||||
assert "System" in src
|
||||
for lang in ("uml-dfd", "data-flow-diagram", "yourdon"):
|
||||
_, dtype2 = prepare_source(lang, SAMPLE_LEVEL0)
|
||||
assert dtype2 == "dfd"
|
||||
|
||||
|
||||
def test_compile_dot_empty_raises():
|
||||
with pytest.raises(DfdParseError, match="пустой"):
|
||||
_compile_dot(" \n\n ")
|
||||
|
||||
|
||||
def test_compile_dot_sample(monkeypatch):
|
||||
"""When the package is importable, real compile; else accept mock."""
|
||||
try:
|
||||
dot, is_ctx = _compile_dot(SAMPLE_CONTEXT)
|
||||
except DfdParseError as exc:
|
||||
if "data-flow-diagram не найден" in str(exc) or "не найден" in str(exc):
|
||||
pytest.skip("data-flow-diagram not installed for this Python")
|
||||
raise
|
||||
assert "digraph" in dot.lower() or "graph" in dot.lower()
|
||||
assert is_ctx is True
|
||||
|
||||
|
||||
def test_strip_graphviz_tcl_libs(tmp_path):
|
||||
from md2gost.dfd import GRAPHVIZ_TCL_DLL_NAMES, strip_graphviz_tcl_libs
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
(bin_dir / "dot.exe").write_bytes(b"mz")
|
||||
(bin_dir / "tcl86t.dll").write_bytes(b"tcl")
|
||||
(bin_dir / "gdtclft.dll").write_bytes(b"gd")
|
||||
(bin_dir / "cgraph.dll").write_bytes(b"keep")
|
||||
tcl_mod = tmp_path / "lib" / "graphviz" / "tcl"
|
||||
tcl_mod.mkdir(parents=True)
|
||||
(tcl_mod / "pkgIndex.tcl").write_text("x", encoding="utf-8")
|
||||
|
||||
removed = strip_graphviz_tcl_libs(tmp_path)
|
||||
assert removed >= 3
|
||||
assert not (bin_dir / "tcl86t.dll").exists()
|
||||
assert not (bin_dir / "gdtclft.dll").exists()
|
||||
assert (bin_dir / "dot.exe").exists()
|
||||
assert (bin_dir / "cgraph.dll").exists()
|
||||
assert not tcl_mod.exists()
|
||||
for name in GRAPHVIZ_TCL_DLL_NAMES:
|
||||
assert not (bin_dir / name).exists()
|
||||
|
||||
|
||||
def test_write_dfd_outputs_uses_local_or_kroki(tmp_path):
|
||||
png = tmp_path / "a.png"
|
||||
svg = tmp_path / "a.svg"
|
||||
fake_dot = "digraph G { a -> b; }\n"
|
||||
|
||||
def fake_local(dot, out_path, fmt, *, context):
|
||||
Path(out_path).write_bytes(
|
||||
b"\x89PNG\r\n\x1a\n" if fmt == "png" else b"<svg></svg>"
|
||||
)
|
||||
if fmt == "png":
|
||||
# minimal valid-enough PNG header for startswith checks
|
||||
Path(out_path).write_bytes(
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
|
||||
+ b"\x00" * 20
|
||||
)
|
||||
else:
|
||||
Path(out_path).write_text(
|
||||
"<svg xmlns='http://www.w3.org/2000/svg'><text>Клиент</text></svg>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return True
|
||||
|
||||
with patch("md2gost.dfd._compile_dot", return_value=(fake_dot, False)), \
|
||||
patch("md2gost.dfd._render_dot_local", side_effect=fake_local), \
|
||||
patch("md2gost.dfd._render_dot_kroki", return_value=False):
|
||||
assert write_dfd_outputs(SAMPLE_LEVEL0, png, svg, scale=1)
|
||||
assert png.read_bytes().startswith(b"\x89PNG")
|
||||
assert "Клиент" in svg.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_write_dfd_outputs_falls_back_to_kroki(tmp_path):
|
||||
png = tmp_path / "a.png"
|
||||
|
||||
def fake_kroki(dot, out_path, fmt):
|
||||
Path(out_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
|
||||
return True
|
||||
|
||||
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
|
||||
patch("md2gost.dfd._render_dot_local", return_value=False), \
|
||||
patch("md2gost.dfd._render_dot_kroki", side_effect=fake_kroki):
|
||||
assert write_dfd_outputs(SAMPLE_CONTEXT, png, None, scale=1)
|
||||
assert png.read_bytes().startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def test_write_dfd_outputs_raises_when_no_renderer(tmp_path):
|
||||
with patch("md2gost.dfd._compile_dot", return_value=("digraph G {}", False)), \
|
||||
patch("md2gost.dfd._render_dot_local", return_value=False), \
|
||||
patch("md2gost.dfd._render_dot_kroki", return_value=False):
|
||||
with pytest.raises(DfdParseError, match="Graphviz|Kroki"):
|
||||
write_dfd_outputs(SAMPLE_CONTEXT, tmp_path / "x.png", None)
|
||||
|
||||
|
||||
def test_render_diagram_dfd_skips_jar(tmp_path):
|
||||
calls = {"jar": 0}
|
||||
|
||||
def fake_jar(*args, **kwargs):
|
||||
calls["jar"] += 1
|
||||
return False
|
||||
|
||||
def fake_write(source, out_png, out_svg, *, scale=2.0):
|
||||
Path(out_png).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32)
|
||||
return True
|
||||
|
||||
configure_diagrams(fallback="off", cache_dir=str(tmp_path), diagram_scale=1)
|
||||
with patch("md2gost.diagram_renderer._render_plantuml_jar", side_effect=fake_jar), \
|
||||
patch("md2gost.dfd.write_dfd_outputs", side_effect=fake_write):
|
||||
result = render_diagram(
|
||||
"dfd", SAMPLE_CONTEXT, cache_dir=str(tmp_path), diagram_scale=1,
|
||||
)
|
||||
assert calls["jar"] == 0
|
||||
assert Path(result.png_path).read_bytes().startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def test_render_diagram_dfd_bad_source(tmp_path):
|
||||
configure_diagrams(fallback="off", cache_dir=str(tmp_path))
|
||||
with patch("md2gost.dfd._compile_dot", side_effect=DfdParseError("пустой DFD")):
|
||||
with pytest.raises(RuntimeError, match="пустой|DFD"):
|
||||
render_diagram("dfd", " ", cache_dir=str(tmp_path))
|
||||
|
||||
|
||||
def test_factory_routes_dfd_to_diagram():
|
||||
from docx import Document
|
||||
from md2gost import package_dir
|
||||
import os
|
||||
|
||||
md = "%ctx Контекст DFD\n\n```dfd\n" + SAMPLE_CONTEXT + "```\n"
|
||||
parsed = markdown.parse(md)
|
||||
doc = Document(os.path.join(package_dir(), "Template.docx"))
|
||||
factory = RenderableFactory(doc._body)
|
||||
caption = None
|
||||
renderables = []
|
||||
for el in parsed.children:
|
||||
from marko.block import BlankLine
|
||||
if isinstance(el, BlankLine):
|
||||
continue
|
||||
if isinstance(el, CapEl):
|
||||
caption = CaptionInfo(el.unique_name, el.text, el.with_listing)
|
||||
continue
|
||||
renderables.append(factory.create(el, caption))
|
||||
caption = None
|
||||
assert len(renderables) == 1
|
||||
assert isinstance(renderables[0], DiagramFigure)
|
||||
assert renderables[0]._lang == "dfd"
|
||||
+151
-53
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from md2gost.diagram_includes import (
|
||||
@@ -25,8 +26,12 @@ from md2gost.diagram_renderer import (
|
||||
from md2gost.diagram_schemes import (
|
||||
DiagramScheme,
|
||||
apply_scheme,
|
||||
bundled_schemes_path,
|
||||
claim_user_scheme_if_diverged,
|
||||
configure_schemes,
|
||||
ensure_user_schemes,
|
||||
load_schemes_from_path,
|
||||
migrate_user_schemes,
|
||||
prepare_with_schemes,
|
||||
scheme_id_from_lang,
|
||||
user_schemes_path,
|
||||
@@ -83,35 +88,21 @@ def test_unknown_scheme_raises(tmp_path, monkeypatch):
|
||||
prepare_with_schemes("uml-nosuch", "A -> B", base=tmp_path)
|
||||
|
||||
|
||||
def test_bpmn_scheme_prepare(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
ensure_user_schemes(tmp_path)
|
||||
configure_schemes(base=tmp_path)
|
||||
assert is_diagram_lang("bpmn")
|
||||
assert is_diagram_lang("uml-bpmn")
|
||||
src, dtype = prepare_with_schemes("bpmn", "Start(s)\nEnd(e)\nFlow(s, e)", base=tmp_path)
|
||||
assert dtype == "plantuml"
|
||||
assert "Start(s)" in src
|
||||
assert "!include" in src
|
||||
assert "BPMN.puml" in src.replace("\\", "/")
|
||||
assert src.lower().count("@startuml") == 1
|
||||
|
||||
|
||||
def test_bundled_bpmn_survives_user_file_without_it(tmp_path, monkeypatch):
|
||||
def test_bundled_c4_survives_user_file_without_it(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = ensure_user_schemes(tmp_path)
|
||||
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
|
||||
configure_schemes(base=tmp_path)
|
||||
assert is_diagram_lang("mine")
|
||||
assert is_diagram_lang("bpmn")
|
||||
assert is_diagram_lang("c4")
|
||||
|
||||
|
||||
def test_scheme_id_from_lang():
|
||||
assert scheme_id_from_lang("uml") is None
|
||||
assert scheme_id_from_lang("uml-c4") == "c4"
|
||||
assert scheme_id_from_lang("c4") == "c4"
|
||||
assert scheme_id_from_lang("bpmn") == "bpmn"
|
||||
assert scheme_id_from_lang("uml-bpmn") == "bpmn"
|
||||
assert scheme_id_from_lang("usecase") == "usecase"
|
||||
assert scheme_id_from_lang("uml-usecase") == "usecase"
|
||||
|
||||
|
||||
def test_ensure_user_schemes_once(tmp_path):
|
||||
@@ -121,10 +112,129 @@ def test_ensure_user_schemes_once(tmp_path):
|
||||
path.write_text('{"mine":{"title":"x","prefix":"","postfix":""}}\n', encoding="utf-8")
|
||||
again = ensure_user_schemes(tmp_path)
|
||||
assert again == path
|
||||
assert "mine" in path.read_text(encoding="utf-8")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert "mine" in text
|
||||
assert "c4" in text # migration re-adds bundled ids
|
||||
assert path == user_schemes_path(tmp_path)
|
||||
|
||||
|
||||
def test_migrate_removes_orphaned_builtin_and_adds_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = user_schemes_path(tmp_path)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"bpmn": {
|
||||
"title": "BPMN 2.0",
|
||||
"author": "md2gost",
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\n@enduml",
|
||||
"includes": ["BPMN.puml"],
|
||||
},
|
||||
"mine": {"title": "x", "author": "", "prefix": "", "postfix": ""},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = migrate_user_schemes(tmp_path)
|
||||
assert result.written
|
||||
assert "bpmn" in result.removed
|
||||
assert "c4" in result.added
|
||||
schemes = load_schemes_from_path(path)
|
||||
assert "bpmn" not in schemes
|
||||
assert "mine" in schemes
|
||||
assert schemes["mine"].title == "x"
|
||||
bundled = load_schemes_from_path(bundled_schemes_path())
|
||||
for sid in bundled:
|
||||
assert sid in schemes
|
||||
|
||||
|
||||
def test_migrate_preserves_user_override_of_builtin(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = user_schemes_path(tmp_path)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"c4": {
|
||||
"title": "My C4",
|
||||
"author": "",
|
||||
"docs": "custom docs",
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\n@enduml",
|
||||
"includes": [],
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = migrate_user_schemes(tmp_path)
|
||||
schemes = load_schemes_from_path(path)
|
||||
assert schemes["c4"].docs == "custom docs"
|
||||
assert schemes["c4"].author == ""
|
||||
assert "c4" not in result.updated
|
||||
assert "usecase" in schemes # other bundled ids still added
|
||||
|
||||
|
||||
def test_migrate_updates_app_owned_builtin(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = user_schemes_path(tmp_path)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"c4": {
|
||||
"title": "Old C4",
|
||||
"author": "md2gost",
|
||||
"version": "0.0.1",
|
||||
"docs": "stale",
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\n@enduml",
|
||||
"includes": [],
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = migrate_user_schemes(tmp_path)
|
||||
assert "c4" in result.updated
|
||||
schemes = load_schemes_from_path(path)
|
||||
bundled = load_schemes_from_path(bundled_schemes_path())
|
||||
assert schemes["c4"].to_dict() == bundled["c4"].to_dict()
|
||||
|
||||
|
||||
def test_migrate_idempotent(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
ensure_user_schemes(tmp_path)
|
||||
path = user_schemes_path(tmp_path)
|
||||
before = path.read_text(encoding="utf-8")
|
||||
mtime1 = path.stat().st_mtime_ns
|
||||
result = migrate_user_schemes(tmp_path)
|
||||
assert not result.written
|
||||
assert not result.changed
|
||||
assert path.read_text(encoding="utf-8") == before
|
||||
assert path.stat().st_mtime_ns == mtime1
|
||||
|
||||
|
||||
def test_claim_user_scheme_if_diverged():
|
||||
bundled = load_schemes_from_path(bundled_schemes_path())
|
||||
stock = bundled["c4"]
|
||||
same = DiagramScheme.from_dict("c4", stock.to_dict())
|
||||
claimed_same = claim_user_scheme_if_diverged(same, bundled=bundled)
|
||||
assert claimed_same.author == "md2gost"
|
||||
edited = DiagramScheme.from_dict(
|
||||
"c4",
|
||||
{**stock.to_dict(), "docs": "my docs"},
|
||||
)
|
||||
claimed = claim_user_scheme_if_diverged(edited, bundled=bundled)
|
||||
assert claimed.author == ""
|
||||
assert claimed.docs == "my docs"
|
||||
|
||||
|
||||
def test_apply_scheme_no_double_start():
|
||||
scheme = DiagramScheme(
|
||||
id="t",
|
||||
@@ -137,6 +247,18 @@ def test_apply_scheme_no_double_start():
|
||||
assert "skinparam monochrome true" in out
|
||||
|
||||
|
||||
def test_archimate_scheme_includes_stdlib():
|
||||
bundled = load_schemes_from_path(bundled_schemes_path())
|
||||
scheme = bundled["archimate"]
|
||||
assert scheme.title == "ArchiMate 3.2"
|
||||
out = apply_scheme(scheme, 'Business_Actor(a, "X")\n')
|
||||
assert "!include <archimate/Archimate>" in out
|
||||
assert "Business_Actor(a, \"X\")" in out
|
||||
assert out.strip().lower().startswith("@startuml")
|
||||
assert "@enduml" in out.lower()
|
||||
assert out.lower().count("@startuml") == 1
|
||||
|
||||
|
||||
def test_include_cache_index_no_overwrite(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
calls = []
|
||||
@@ -455,39 +577,15 @@ def test_diagram_engine_status_mentions_java_or_kroki():
|
||||
def test_diagram_langs():
|
||||
assert "uml" in DIAGRAM_LANGS
|
||||
assert "c4" in DIAGRAM_LANGS
|
||||
assert "bpmn" in DIAGRAM_LANGS
|
||||
assert "usecase" in DIAGRAM_LANGS
|
||||
assert "idef0" in DIAGRAM_LANGS
|
||||
assert "dfd" in DIAGRAM_LANGS
|
||||
assert is_diagram_lang("uml")
|
||||
assert is_diagram_lang("uml-c4")
|
||||
assert is_diagram_lang("uml-bpmn")
|
||||
|
||||
|
||||
def test_render_bpmn_with_jar(tmp_path, monkeypatch):
|
||||
import shutil
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
ensure_user_schemes(tmp_path)
|
||||
configure_schemes(base=tmp_path)
|
||||
jar = resolve_plantuml_jar(download=False)
|
||||
if not shutil.which("java") or not jar:
|
||||
pytest.skip("нужны Java и plantuml.jar")
|
||||
configure_diagrams(fallback="off", cache_dir=str(tmp_path), plantuml_jar=jar)
|
||||
body = "\n".join(
|
||||
[
|
||||
"Start(s)",
|
||||
"StartMessage(sm, \"msg\")",
|
||||
"UserTask(t, \"Шаг\")",
|
||||
"XOR(gw)",
|
||||
"AND(p)",
|
||||
"OR(o)",
|
||||
"End(e)",
|
||||
"EndTerminate(et)",
|
||||
"Flow(s, t)",
|
||||
"Flow(t, gw)",
|
||||
"CondFlow(gw, e, \"да\")",
|
||||
"DefaultFlow(gw, et)",
|
||||
]
|
||||
)
|
||||
result = render_diagram("bpmn", body, cache_dir=str(tmp_path), fallback="off")
|
||||
data = Path(result.png_path).read_bytes()
|
||||
assert data.startswith(b"\x89PNG")
|
||||
assert len(data) > 500
|
||||
assert is_diagram_lang("uml-usecase")
|
||||
assert is_diagram_lang("idef0")
|
||||
assert is_diagram_lang("uml-idef0")
|
||||
assert is_diagram_lang("dfd")
|
||||
assert is_diagram_lang("uml-dfd")
|
||||
assert is_diagram_lang("data-flow-diagram")
|
||||
assert is_diagram_lang("yourdon")
|
||||
|
||||
@@ -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 == "Тема профиля"
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
|
||||
@@ -201,6 +201,15 @@ def test_preprocess_skips_uml_and_mermaid_fences():
|
||||
"```\n\n"
|
||||
"```uml-c4context\n"
|
||||
'Person(user, "Заявитель")\n'
|
||||
"```\n\n"
|
||||
"```idef0\n"
|
||||
'title "Выручка"\n'
|
||||
"[A0] Процесс\n"
|
||||
"```\n\n"
|
||||
"```dfd\n"
|
||||
'entity User\n'
|
||||
'process App\n'
|
||||
'User --> App "request"\n'
|
||||
"```\n"
|
||||
)
|
||||
out = preprocess_markdown(md)
|
||||
@@ -209,6 +218,10 @@ def test_preprocess_skips_uml_and_mermaid_fences():
|
||||
assert 'title "Выручка"' in out
|
||||
assert 'Person(user, "Заявитель")' in out
|
||||
assert "«Акционеры»" not in out
|
||||
assert "[A0] Процесс" in out
|
||||
assert "entity User" in out
|
||||
assert 'User --> App "request"' in out
|
||||
assert "«request»" not in out
|
||||
|
||||
|
||||
def test_formula_refs():
|
||||
@@ -247,6 +260,42 @@ def test_biblio_entries_from_concatenated_paragraph():
|
||||
assert flat[0].key == "1"
|
||||
|
||||
|
||||
def test_fold_bibliography_drops_citation_mangled_residue():
|
||||
"""Citation linking turns [5]: into []: in .text — must still be consumed."""
|
||||
import docx
|
||||
from md2gost.biblio_processor import (
|
||||
fold_bibliography,
|
||||
is_biblio_source_paragraph,
|
||||
)
|
||||
from md2gost.bibliography_renderable import Bibliography
|
||||
from md2gost.renderable.heading import Heading
|
||||
from md2gost.renderable.paragraph import Paragraph
|
||||
|
||||
assert is_biblio_source_paragraph("[1]: Книга. — М., 2024.")
|
||||
assert is_biblio_source_paragraph("[]: Книга. — М., 2024.")
|
||||
assert is_biblio_source_paragraph("[]: https://example.com/a")
|
||||
assert not is_biblio_source_paragraph("Обычный абзац без источника.")
|
||||
|
||||
doc = docx.Document(r"md2gost/Template.docx")
|
||||
doc._body.clear_content()
|
||||
h = Heading(doc._body, 1, numbered=False)
|
||||
h.add_run("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ")
|
||||
residue = Paragraph(doc._body)
|
||||
residue.add_run("[]: Остаток после гиперссылки на цитату.")
|
||||
prose = Paragraph(doc._body)
|
||||
prose.add_run("Это уже не библиография.")
|
||||
|
||||
md = """# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
|
||||
[1]: Книга. — М., 2024.
|
||||
"""
|
||||
out = fold_bibliography([h, residue, prose], doc._body, raw_markdown=md)
|
||||
types = [type(x).__name__ for x in out]
|
||||
assert types == ["Heading", "Bibliography", "Paragraph"]
|
||||
assert isinstance(out[1], Bibliography)
|
||||
assert out[2] is prose
|
||||
|
||||
|
||||
def test_emdash_to_hyphen_preprocess():
|
||||
from md2gost.profiles import preprocess_markdown, set_emdash_to_hyphen, dash_separator
|
||||
out = preprocess_markdown("Цель — тест. Слово - слово.", emdash_to_hyphen=True)
|
||||
|
||||
+113
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -13,7 +13,10 @@ from md2gost.word_fix import (
|
||||
continuation_label,
|
||||
find_page_break_row,
|
||||
fix_continuations,
|
||||
is_caption_orphaned,
|
||||
is_orphan_header_break,
|
||||
parse_caption_text,
|
||||
split_vmerge_spans_at_break,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +57,33 @@ def test_find_page_break_row():
|
||||
assert find_page_break_row([1, 1, 2, 2]) == 3
|
||||
assert find_page_break_row([2, 2, 3]) == 3
|
||||
assert find_page_break_row([1, 2]) == 2
|
||||
# typical vMerge table pages after cell-based detection
|
||||
assert find_page_break_row([14, 14, 14, 14, 14, 14, 15, 15]) == 7
|
||||
|
||||
|
||||
def test_orphan_header_and_caption_guards():
|
||||
"""Method guide: no title-only / title+column-headers alone at page end."""
|
||||
# Split would leave only heading row on previous page
|
||||
assert is_orphan_header_break(2, had_header=True) is True
|
||||
assert is_orphan_header_break(2, had_header=False) is False
|
||||
assert is_orphan_header_break(3, had_header=True) is False
|
||||
assert is_orphan_header_break(None, had_header=True) is False
|
||||
# Caption on earlier page than first table row
|
||||
assert is_caption_orphaned(5, 6) is True
|
||||
assert is_caption_orphaned(5, 5) is False
|
||||
assert is_caption_orphaned(None, 6) is False
|
||||
|
||||
|
||||
def test_split_vmerge_spans_at_break():
|
||||
# (row, col, span, text) — merge rows 2..6 (span 5), break at row 7 → all in first
|
||||
spans = [(2, 1, 5, "A"), (7, 1, 2, "B")]
|
||||
first, cont = split_vmerge_spans_at_break(spans, 7)
|
||||
assert first == [(2, 1, 5, "A")]
|
||||
assert cont == [(1, 1, 2, "B")]
|
||||
# merge crosses break: rows 5..8, break at 7
|
||||
first, cont = split_vmerge_spans_at_break([(5, 1, 4, "X")], 7)
|
||||
assert first == [(5, 1, 2, "X")]
|
||||
assert cont == [(1, 1, 2, "X")]
|
||||
|
||||
|
||||
def test_continuation_label():
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
- Ожидаем внедрение BPMN в mermaid
|
||||
- Оценки с текстом через сам Word а не предугадываание
|
||||
- Проверка «текст могут не принять»: эвристика отчёта ИИ (пишет про сдачу ТЗ, а не про предмет). `--check` + отдельный разбор по абзацам (`--check-voice` / кнопка в GUI). Идеи правил — сравнение живой практики и сгенерированного отчёта: телеология «последующие работы», преамбулы разделов, заключение-чеклист, колонки «значение для объекта», плотность ссылок, консультантский лоск.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# word2md
|
||||
|
||||
Импорт **DOCX → Markdown** в диалекте md2gost.
|
||||
|
||||
```bash
|
||||
python -m word2md report.docx -o report.md
|
||||
```
|
||||
|
||||
Документация: [`docs/word2md.md`](../docs/word2md.md).
|
||||
@@ -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__"]
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python
|
||||
"""CLI: DOCX → Markdown (диалект md2gost)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from .pipeline import ImportRequest, convert_docx
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="word2md",
|
||||
description=(
|
||||
"Импорт DOCX в Markdown диалекта md2gost "
|
||||
"(заголовки # *, подписи %id, @Рисунок:id, таблицы, листинги)."
|
||||
),
|
||||
)
|
||||
p.add_argument("filename", help="Исходный .docx")
|
||||
p.add_argument(
|
||||
"-o", "--output",
|
||||
default=None,
|
||||
help="Путь к .md (по умолчанию: <stem>.md рядом с docx)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--media-dir",
|
||||
default=None,
|
||||
help="Каталог для картинок (по умолчанию: <stem>_media рядом с .md)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--keep-toc-pages",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="Не выкидывать строки содержания Word (по умолчанию выкл.: только [TOC])",
|
||||
)
|
||||
p.add_argument(
|
||||
"--pagebreaks",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=False,
|
||||
help="Писать --- на разрывах страниц Word (для --hr-pagebreak при обратной сборке)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
result = convert_docx(
|
||||
ImportRequest(
|
||||
filename=args.filename,
|
||||
output=args.output,
|
||||
media_dir=args.media_dir,
|
||||
keep_toc_pages=args.keep_toc_pages,
|
||||
emit_page_breaks=args.pagebreaks,
|
||||
)
|
||||
)
|
||||
sys.exit(result.exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Parse ГОСТ-style captions: Рисунок / Таблица / Листинг (+ продолжения)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from md2gost.word_fix import parse_caption_text
|
||||
|
||||
_FIGURE_RE = re.compile(
|
||||
r"^(?:Рисунок|Рис\.?)\s+(?P<number>[\d.]+)\s*(?:[—–\-]\s*(?P<title>.+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TABLE_FULL_RE = re.compile(
|
||||
r"^(?:Продолжение\s+)?"
|
||||
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
|
||||
r"(?P<number>[\d.]+)"
|
||||
r"(?:\s*[—–\-]\s*(?P<title>.+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCaption:
|
||||
kind: str # figure | table | listing
|
||||
number: str
|
||||
text: str | None = None
|
||||
is_continuation: bool = False
|
||||
|
||||
|
||||
def caption_unique_id(kind: str, number: str) -> str:
|
||||
"""Stable id from display number: 1.1 → fig1_1 / tbl1_1 / lst1_1."""
|
||||
safe = str(number).replace(".", "_")
|
||||
prefix = {"figure": "fig", "table": "tbl", "listing": "lst"}.get(kind, "obj")
|
||||
return f"{prefix}{safe}"
|
||||
|
||||
|
||||
def parse_any_caption(text: str) -> ParsedCaption | None:
|
||||
"""Parse figure / table / listing captions (including «Продолжение…»)."""
|
||||
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
first = raw.split("\n", 1)[0].strip()
|
||||
|
||||
m = _FIGURE_RE.match(first)
|
||||
if m:
|
||||
title = (m.group("title") or "").strip().rstrip(".") or None
|
||||
return ParsedCaption(kind="figure", number=m.group("number"), text=title)
|
||||
|
||||
info = parse_caption_text(first)
|
||||
if info is not None:
|
||||
title = None
|
||||
m2 = _TABLE_FULL_RE.match(first)
|
||||
if m2 and m2.group("title"):
|
||||
title = m2.group("title").strip().rstrip(".") or None
|
||||
else:
|
||||
# Strip leading «Таблица N — » / «Продолжение…» for leftover title
|
||||
rest = _TABLE_FULL_RE.sub("", first).strip()
|
||||
if rest.startswith(("—", "–", "-")):
|
||||
rest = rest[1:].strip()
|
||||
title = rest.rstrip(".") or None if rest else None
|
||||
return ParsedCaption(
|
||||
kind=info.kind,
|
||||
number=info.number,
|
||||
text=title,
|
||||
is_continuation=info.is_continuation,
|
||||
)
|
||||
|
||||
m3 = _TABLE_FULL_RE.match(first)
|
||||
if m3:
|
||||
kind_raw = m3.group("kind").lower()
|
||||
if kind_raw.startswith("табл"):
|
||||
kind = "table"
|
||||
elif kind_raw.startswith("лист"):
|
||||
kind = "listing"
|
||||
else:
|
||||
return None
|
||||
return ParsedCaption(
|
||||
kind=kind,
|
||||
number=m3.group("number"),
|
||||
text=(m3.group("title") or "").strip().rstrip(".") or None,
|
||||
is_continuation=bool(_CONTINUATION_RE.match(first)),
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Classify DOCX paragraphs/tables into IR kinds (style first, then heuristics)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from md2gost.renderable.toc import SPECIAL_TITLES
|
||||
|
||||
from .captions import parse_any_caption
|
||||
|
||||
CAPTION_STYLE_KINDS = {
|
||||
"Caption Figure": "figure",
|
||||
"Название таблицы": "table",
|
||||
"Caption Table": "table",
|
||||
"Caption Listing": "listing",
|
||||
"Caption": None, # need text heuristic
|
||||
}
|
||||
|
||||
HEADING_STYLE_RE = re.compile(r"^(?:Heading|Заголовок)\s*(\d+)$", re.IGNORECASE)
|
||||
TOC_STYLE_RE = re.compile(r"^toc\s*\d+$", re.IGNORECASE)
|
||||
|
||||
SPECIAL_UPPER = {t.upper() for t in SPECIAL_TITLES}
|
||||
APPENDIX_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
NUMBERED_HEADING_RE = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.+)$")
|
||||
PRACTICE_RE = re.compile(r"^Практическая\s+работа\b", re.IGNORECASE)
|
||||
LIST_MARKER_RE = re.compile(r"^(?:[–—\-•▪]|(\d+)\.)\s*\t?\s*(.*)$")
|
||||
BIBLIO_LINE_RE = re.compile(r"^\[(\d+(?:\.\d+)?)\]\s*[:.]?\s*(.+)$")
|
||||
BODY_START_TITLES = SPECIAL_UPPER | {"СОДЕРЖАНИЕ"}
|
||||
|
||||
|
||||
def style_name(paragraph) -> str:
|
||||
try:
|
||||
if paragraph.style is None:
|
||||
return ""
|
||||
return paragraph.style.name or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_toc_style(name: str) -> bool:
|
||||
return bool(TOC_STYLE_RE.match((name or "").strip()))
|
||||
|
||||
|
||||
def heading_level_from_style(name: str) -> int | None:
|
||||
m = HEADING_STYLE_RE.match((name or "").strip())
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
level = int(m.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return level if 1 <= level <= 9 else None
|
||||
|
||||
|
||||
def is_code_style(name: str) -> bool:
|
||||
n = (name or "").strip().lower()
|
||||
return n == "code" or n.startswith("code ")
|
||||
|
||||
|
||||
def is_bibliography_style(name: str) -> bool:
|
||||
n = (name or "").strip().lower()
|
||||
return n in ("bibliography", "bibliography heading")
|
||||
|
||||
|
||||
def is_caption_style(name: str) -> bool:
|
||||
return (name or "").strip() in CAPTION_STYLE_KINDS
|
||||
|
||||
|
||||
def caption_kind_from_style(name: str) -> str | None:
|
||||
return CAPTION_STYLE_KINDS.get((name or "").strip())
|
||||
|
||||
|
||||
def is_special_title(text: str) -> bool:
|
||||
t = (text or "").strip().upper()
|
||||
if t in SPECIAL_UPPER:
|
||||
return True
|
||||
# allow «СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ» variants already in set
|
||||
if t.startswith("СПИСОК ИСПОЛЬЗ"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_body_start_heading(text: str, style: str = "") -> bool:
|
||||
"""True if this heading marks the start of report body (skip title pages before it)."""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
if heading_level_from_style(style) or style.lower().startswith("heading"):
|
||||
upper = t.upper()
|
||||
if upper.lstrip("* ").upper() in BODY_START_TITLES or is_special_title(t):
|
||||
return True
|
||||
if PRACTICE_RE.match(t):
|
||||
return True
|
||||
if NUMBERED_HEADING_RE.match(t):
|
||||
return True
|
||||
if APPENDIX_RE.match(t):
|
||||
return True
|
||||
# Any Heading 1 after front matter
|
||||
if heading_level_from_style(style) == 1:
|
||||
return True
|
||||
# Heuristic without style: special titles alone
|
||||
if is_special_title(t) or PRACTICE_RE.match(t):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def classify_heading_text(text: str, style_level: int | None) -> tuple[int, str, bool]:
|
||||
"""
|
||||
Return (level, title_text, numbered).
|
||||
numbered=False → emit as # *TITLE
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
level = style_level or 1
|
||||
|
||||
app = APPENDIX_RE.match(raw)
|
||||
if app:
|
||||
letter = app.group(1).upper()
|
||||
name = (app.group(2) or "").strip()
|
||||
title = f"Приложение {letter}" + (f" {name}" if name else "")
|
||||
return 2, title, True
|
||||
|
||||
if is_special_title(raw):
|
||||
return 1, raw.upper(), False
|
||||
|
||||
m = NUMBERED_HEADING_RE.match(raw)
|
||||
if m:
|
||||
# keep digits in text for manual numbering dialect
|
||||
return level, raw, True
|
||||
|
||||
if PRACTICE_RE.match(raw):
|
||||
return 1, raw, True
|
||||
|
||||
return level, raw, True
|
||||
|
||||
|
||||
def look_like_list_item(text: str) -> tuple[bool, bool, str] | None:
|
||||
"""
|
||||
Detect md2gost-style list lines (–\\t / 1.\\t) or plain markers.
|
||||
Returns (ordered, marker_ok, body) or None.
|
||||
"""
|
||||
t = (text or "").strip()
|
||||
if not t:
|
||||
return None
|
||||
if t[0] in ("–", "—", "-", "•", "▪") and (len(t) == 1 or t[1] in "\t "):
|
||||
body = t[1:].lstrip("\t ").strip()
|
||||
return False, True, body
|
||||
m = re.match(r"^(\d+)\.\s*\t?\s*(.+)$", t)
|
||||
if m:
|
||||
return True, True, m.group(2).strip()
|
||||
return None
|
||||
|
||||
|
||||
def parse_biblio_line(text: str) -> tuple[str, str] | None:
|
||||
m = BIBLIO_LINE_RE.match((text or "").strip())
|
||||
if not m:
|
||||
return None
|
||||
return m.group(1), m.group(2).strip()
|
||||
|
||||
|
||||
def paragraph_has_numpr(paragraph) -> bool:
|
||||
pPr = paragraph._element.pPr
|
||||
if pPr is None:
|
||||
return False
|
||||
return pPr.numPr is not None
|
||||
+249
@@ -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"")
|
||||
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"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Extract images from a DOCX package into a media folder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
|
||||
def _r_id_from_blip(blip) -> str | None:
|
||||
if blip is None:
|
||||
return None
|
||||
return blip.get(qn("r:embed")) or blip.get(
|
||||
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed"
|
||||
)
|
||||
|
||||
|
||||
def iter_blip_rids(paragraph_element) -> list[str]:
|
||||
"""Collect relationship ids for drawings in a paragraph."""
|
||||
if paragraph_element is None:
|
||||
return []
|
||||
rids: list[str] = []
|
||||
for blip in paragraph_element.findall(".//" + qn("a:blip")):
|
||||
rid = _r_id_from_blip(blip)
|
||||
if rid:
|
||||
rids.append(rid)
|
||||
return rids
|
||||
|
||||
|
||||
class MediaExtractor:
|
||||
"""Copy word/media parts referenced by rIds into dest_dir."""
|
||||
|
||||
def __init__(self, docx_path: str | Path, dest_dir: str | Path, *, rel_prefix: str):
|
||||
self.docx_path = Path(docx_path)
|
||||
self.dest_dir = Path(dest_dir)
|
||||
self.rel_prefix = rel_prefix.replace("\\", "/").rstrip("/")
|
||||
self.dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._zf = zipfile.ZipFile(self.docx_path, "r")
|
||||
self._cache: dict[str, str] = {} # rid -> relative path for md
|
||||
self._used_names: set[str] = set()
|
||||
|
||||
def close(self) -> None:
|
||||
self._zf.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
def extract_rid(self, document_part, rid: str) -> str | None:
|
||||
"""Return path relative to the .md file, or None on failure."""
|
||||
if rid in self._cache:
|
||||
return self._cache[rid]
|
||||
try:
|
||||
rel = document_part.rels[rid]
|
||||
except KeyError:
|
||||
return None
|
||||
target = getattr(rel, "target_ref", None) or getattr(rel, "target_part", None)
|
||||
if target is None:
|
||||
return None
|
||||
# target_ref like "media/image1.png"
|
||||
if hasattr(rel, "target_part"):
|
||||
part = rel.target_part
|
||||
blob = part.blob
|
||||
name = os.path.basename(part.partname)
|
||||
else:
|
||||
ref = str(target).lstrip("/")
|
||||
zip_name = "word/" + ref if not ref.startswith("word/") else ref
|
||||
try:
|
||||
blob = self._zf.read(zip_name)
|
||||
except KeyError:
|
||||
return None
|
||||
name = os.path.basename(ref)
|
||||
|
||||
stem, ext = os.path.splitext(name)
|
||||
if not ext:
|
||||
ext = ".png"
|
||||
safe = f"{stem}{ext}"
|
||||
if safe in self._used_names:
|
||||
digest = hashlib.md5(blob).hexdigest()[:8]
|
||||
safe = f"{stem}_{digest}{ext}"
|
||||
self._used_names.add(safe)
|
||||
out_path = self.dest_dir / safe
|
||||
out_path.write_bytes(blob)
|
||||
rel_path = f"{self.rel_prefix}/{safe}" if self.rel_prefix else safe
|
||||
self._cache[rid] = rel_path
|
||||
return rel_path
|
||||
+149
@@ -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))
|
||||
@@ -0,0 +1,110 @@
|
||||
"""DOCX → md2gost Markdown pipeline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from docx import Document
|
||||
|
||||
from .emit import emit_markdown
|
||||
from .media import MediaExtractor
|
||||
from .refs import apply_reference_pass
|
||||
from .walker import DocxWalker, postprocess_blocks
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportRequest:
|
||||
filename: str = ""
|
||||
output: str | None = None
|
||||
media_dir: str | None = None
|
||||
keep_toc_pages: bool = False
|
||||
emit_page_breaks: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
ok: bool
|
||||
exit_code: int = 0
|
||||
output_path: str | None = None
|
||||
media_dir: str | None = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
def default_output_path(docx_path: str | Path) -> Path:
|
||||
p = Path(docx_path)
|
||||
return p.with_suffix(".md")
|
||||
|
||||
|
||||
def default_media_dir(md_path: Path) -> Path:
|
||||
return md_path.parent / f"{md_path.stem}_media"
|
||||
|
||||
|
||||
def convert_docx(req: ImportRequest, log: LogFn | None = None) -> ImportResult:
|
||||
"""Convert DOCX to md2gost dialect Markdown. Errors go into ImportResult."""
|
||||
emit: LogFn = log if log is not None else print
|
||||
|
||||
def fail(code: int, message: str) -> ImportResult:
|
||||
emit(message)
|
||||
return ImportResult(False, code, message=message)
|
||||
|
||||
filename = (req.filename or "").strip()
|
||||
if not filename:
|
||||
return fail(2, "Укажите исходный .docx")
|
||||
path = Path(filename)
|
||||
if not path.is_file():
|
||||
return fail(2, f"Файл не найден: {path}")
|
||||
if path.suffix.lower() != ".docx":
|
||||
return fail(1, "Исходный файл должен быть в формате .docx")
|
||||
|
||||
out = Path(req.output) if req.output else default_output_path(path)
|
||||
if out.suffix.lower() != ".md":
|
||||
out = out.with_suffix(".md")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
media_path = Path(req.media_dir) if req.media_dir else default_media_dir(out)
|
||||
rel_prefix = media_path.name
|
||||
# If media dir is not sibling of md, use relative path from md parent
|
||||
try:
|
||||
rel_prefix = str(media_path.resolve().relative_to(out.parent.resolve())).replace("\\", "/")
|
||||
except ValueError:
|
||||
rel_prefix = str(media_path).replace("\\", "/")
|
||||
|
||||
try:
|
||||
document = Document(str(path))
|
||||
except Exception as exc:
|
||||
return fail(1, f"Не удалось открыть DOCX: {exc}")
|
||||
|
||||
try:
|
||||
with MediaExtractor(path, media_path, rel_prefix=rel_prefix) as media:
|
||||
walker = DocxWalker(
|
||||
document,
|
||||
media,
|
||||
keep_toc_pages=req.keep_toc_pages,
|
||||
emit_page_breaks=req.emit_page_breaks,
|
||||
log=emit,
|
||||
)
|
||||
blocks = walker.walk()
|
||||
blocks = postprocess_blocks(blocks)
|
||||
blocks = apply_reference_pass(blocks)
|
||||
md_text = emit_markdown(blocks, emit_page_breaks=req.emit_page_breaks)
|
||||
out.write_text(md_text, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
emit(traceback.format_exc())
|
||||
return fail(1, f"Ошибка импорта: {exc}")
|
||||
|
||||
msg = f"Markdown: {out}"
|
||||
emit(msg)
|
||||
if media_path.is_dir() and any(media_path.iterdir()):
|
||||
emit(f"Медиа: {media_path}")
|
||||
return ImportResult(
|
||||
True,
|
||||
0,
|
||||
output_path=str(out.resolve()),
|
||||
media_dir=str(media_path.resolve()) if media_path.exists() else None,
|
||||
message=msg,
|
||||
)
|
||||
+170
@@ -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
|
||||
@@ -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
|
||||
@@ -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", "")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user