From 818a044aa1641e827532ab7578b87b6704f71423 Mon Sep 17 00:00:00 2001 From: Igor20264 Date: Sun, 6 Sep 2026 11:04:01 +0300 Subject: [PATCH] update 0.4.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update документация - промт для ии полу конфигурируемый --- .gitignore | 4 + ReadMe.md | 221 ++----------- docs/README.md | 17 + docs/cli-gui.md | 58 ++++ docs/markdown.md | 153 +++++++++ docs/prompts.md | 33 ++ docs/quickstart.md | 61 ++++ docs/schemes.md | 121 +++++++ docs/styles.md | 67 ++++ docs/types.md | 66 ++++ examples/md2gost.styles.json | 21 ++ md2gost.fast.spec | 1 + md2gost.spec | 1 + md2gost/README.md | 4 +- md2gost/__main__.py | 9 + md2gost/converter.py | 18 +- md2gost/gui.py | 190 ++++++++++- md2gost/help_content.py | 112 ++++++- md2gost/pipeline.py | 10 +- md2gost/style_config.py | 597 +++++++++++++++++++++++++++++++++++ md2gost/styles.py | 386 +++++++++------------- prompts/README.md | 17 +- prompts/generate-md.md | 116 +++++++ pyproject.toml | 9 + tests/test_pipeline.py | 38 +++ tests/test_style_config.py | 114 +++++++ 26 files changed, 1987 insertions(+), 457 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/cli-gui.md create mode 100644 docs/markdown.md create mode 100644 docs/prompts.md create mode 100644 docs/quickstart.md create mode 100644 docs/schemes.md create mode 100644 docs/styles.md create mode 100644 docs/types.md create mode 100644 examples/md2gost.styles.json create mode 100644 md2gost/style_config.py create mode 100644 prompts/generate-md.md create mode 100644 tests/test_style_config.py diff --git a/.gitignore b/.gitignore index a58632c..5c577f9 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,7 @@ md2gost.include-cache.json include-cache/ .md2gost-cache/ /build-fast +build-fast/md2gost.fast/xref-md2gost.fast.html +build-fast/md2gost.fast/PYZ-00.pyz +build-fast/md2gost.fast/md2gost.pkg +build-fast/md2gost.fast/base_library.zip diff --git a/ReadMe.md b/ReadMe.md index 900fa43..c796df0 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -1,209 +1,34 @@ -# GhostEditor -Программа для конвертации из формата `md` в формат `docx`(x это без макросов, m с макросами) (`Office Open XML`),`odt`(`OpenDocument`) +# GhostEditor / md2gost -#### **1. python-docx** -**Назначение**: Создание и модификация Word-документов. -**Ключевые возможности**: -- Добавление/редактирование текста, таблиц, изображений. -- Управление стилями, колонтитулами, сносками. -- Работа с XML через абстракции (Paragraph, Run, Table). +Конвертер **Markdown → DOCX** по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) и профилям ПИС / АПИД. Есть GUI, CLI и промпты для генерации `.md` через ИИ. -**Пример**: -```python -from docx import Document -from docx.shared import Pt +## Быстрый старт -doc = Document() -paragraph = doc.add_paragraph("Hello ") -run = paragraph.add_run("OOXML!") -run.bold = True -run.font.size = Pt(14) - -# Таблица -table = doc.add_table(rows=2, cols=2) -table.cell(0, 0).text = "Cell A1" - -doc.save("report.docx") +```bash +pip install -e . +python -m md2gost # GUI +python -m md2gost report.md -o report.docx --type coursework --check ``` -**Плюсы**: -- Интуитивный API, близкий к логике Word. -- Поддержка всех ключевых частей пакета (headers, footers, styles). +Windows: `build-exe.bat` → `dist\md2gost.exe` (двойной клик — GUI). -**Минусы**: -- Нет поддержки макросов. -- Ограниченная работа с графикой (изображения добавляются, но редактирование сложно). +## Документация -**Ссылка**: [python-docx.readthedocs.io](https://python-docx.readthedocs.io/) +Полное описание — в [`docs/`](docs/): ---- +| Раздел | Содержание | +|--------|------------| +| [Быстрый старт](docs/quickstart.md) | Установка, GUI, типы документов | +| [Синтаксис Markdown](docs/markdown.md) | Диалект md2gost vs обычный MD | +| [Схемы и диаграммы](docs/schemes.md) | PlantUML, Mermaid, `md2gost.schemes.json` | +| [Типы работ](docs/types.md) | coursework, practice, vkr, PIS, APID | +| [CLI и GUI](docs/cli-gui.md) | Флаги и где они в окне | +| [Промпты для ИИ](docs/prompts.md) | Генерация MD, кнопки схем в GUI | -#### **2. docxtpl** -**Назначение**: Генерация документов по шаблонам (Jinja2). -**Особенности**: -- Шаблоны .docx с переменными (`{{ variable }}`) и логикой (`{% if %}`). -- Поддержка таблиц, изображений, HTML-фрагментов. +Краткая справка по пакету: [`md2gost/README.md`](md2gost/README.md). +Промпты: [`prompts/`](prompts/). -**Пример**: -```python -from docxtpl import DocxTemplate +## Связанные форматы -doc = DocxTemplate("template.docx") -context = { - "title": "Отчет за 2023", - "items": [{"name": "Item 1"}, {"name": "Item 2"}] -} -doc.render(context) -doc.save("output.docx") -``` - -**Плюсы**: -- Идеален для шаблонизированных отчетов (договоры, счета). -- Интеграция с Django/Flask. - -**Минусы**: -- Требует предварительной настройки шаблонов в Word. - -**Ссылка**: [github.com/elapouya/python-docx-template](https://github.com/elapouya/python-docx-template) - ---- - -#### **3. mammoth** -**Назначение**: Конвертация .docx → HTML с сохранением структуры. -**Особенности**: -- Преобразует стили Word в CSS-классы. -- Подходит для извлечения контента из сложных документов. - -**Пример**: -```python -import mammoth - -with open("document.docx", "rb") as docx_file: - result = mammoth.convert_to_html(docx_file) - html = result.value # HTML-строка - warnings = result.messages # Предупреждения -``` - -**Плюсы**: -- Сохраняет семантику (заголовки, списки, таблицы). -- Легко интегрируется с парсерами (BeautifulSoup). - -**Минусы**: -- Не подходит для обратной конвертации (HTML → docx). - -**Ссылка**: [github.com/mwilliamson/mammoth.py](https://github.com/mwilliamson/mammoth.py) - ---- - -### **Низкоуровневые инструменты (для кастомных задач)** - -#### **1. zipfile + lxml** -**Назначение**: Прямой доступ к XML-структуре OOXML. -**Когда использовать**: -- При работе с нестандартными частями пакета (напр., кастомные XML-маппинги в Excel). -- Для восстановления поврежденных файлов. - -**Пример для Excel**: -```python -import zipfile -from lxml import etree - -# Распаковываем workbook.xml -with zipfile.ZipFile("report.xlsx") as xlsx: - xml_data = xlsx.read("xl/workbook.xml") - -# Парсим XML -workbook = etree.fromstring(xml_data) -namespaces = {"ns": "http://schemas.../spreadsheetml/2006/main"} -sheets = workbook.findall(".//ns:sheet", namespaces) - -# Меняем имя листа -sheets[0].set("{http://.../officeDocument/2006/relationships}name", "New Sheet") - -# Сохраняем изменения -with zipfile.ZipFile("fixed.xlsx", "w") as new_xlsx: - for file in xlsx.namelist(): - if file != "xl/workbook.xml": - new_xlsx.writestr(file, xlsx.read(file)) - new_xlsx.writestr("xl/workbook.xml", etree.tostring(workbook)) -``` - -**Плюсы**: -- Полный контроль над структурой. -- Работает с любыми частями пакета. - -**Минусы**: -- Требует знания XML-схем OOXML. -- Риск повреждения файла при ошибках. - ---- - -#### **2. officedoc** -**Назначение**: Универсальный доступ к форматам Office (docx, xlsx, pptx). -**Особенности**: -- Объединяет функционал `python-docx`, `openpyxl`, `python-pptx` в едином API. -- Упрощает обработку мультимедийных вложений. - -**Пример**: -```python -from officedoc import Document - -doc = Document("mixed.docx") -for image in doc.images: - image.save("extracted_" + image.filename) - -for table in doc.tables: - print(table.to_dataframe()) # Конвертация в pandas DataFrame -``` - -**Ссылка**: [github.com/mikem1701/officedoc](https://github.com/mikem1701/officedoc) - ---- - -### **Сравнение библиотек** - -| **Библиотека** | **Скорость** | **Сложность** | **Особенности** | -|----------------------|-------------|---------------|----------------------------| -| **python-docx** | Средняя | Средняя | Стандарт для работы с Word | -| **docxtpl** | Средняя | Низкая | Шаблонизация через Jinja2 | -| **zipfile + lxml** | Высокая | Высокая | Полный контроль над XML | - ---- - -#### Для Word (.docx) -- **Простое создание документов**: `python-docx`. -- **Шаблонные документы** (договоры, счета): `docxtpl`. -- **Извлечение контента**: `mammoth` → обработка HTML через BeautifulSoup. -- **Редактирование структуры**: `python-docx` + `zipfile` для кастомных частей. - -#### Для кастомных задач -- **Восстановление файлов**: `zipfile` + `lxml` (ручное исправление XML). -- **Интеграция с внешними системами**: `openpyxl`/`python-docx` + REST API (напр., выгрузка данных из БД в Excel). - ---- - -### Типичные проблемы и решения - -2. Стили не применяются в Word - - Проблема: `python-docx` использует стили из `styles.xml`, но не создает новые автоматически. - - Решение: - ```python - from docx.shared import Pt - style = doc.styles.add_style("Heading3", WD_STYLE_TYPE.PARAGRAPH) - font = style.font - font.name = "Arial" - font.size = Pt(12) - ``` - ---- - -### **Заключение** -Для 90% задач достаточно: -- **Excel**: `openpyxl` или `pandas`. -- **Word**: `python-docx` + `docxtpl`. - -**Используйте низкоуровневые методы (zipfile/lxml) только когда**: - -*Для углубленного изучения:* -- [Open XML SDK 2.5](https://learn.microsoft.com/ru-ru/office/open-xml/open-xml-sdk) (официальная документация Microsoft). -- [ECMA-376 стандарт](https://www.ecma-international.org/publications-and-standards/standards/ecma-376/) (полная спецификация). \ No newline at end of file +- FODT (LibreOffice): `python -m md2fodt` — [`md2fodt/`](md2fodt/) +- PDF через LaTeX: [`md2latex/`](md2latex/) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dd2a772 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Документация md2gost + +Конвертер Markdown → DOCX для учебных работ (ТЗ МИРЭА / ГОСТ 7.32, ПИС, АПИД). + +## Содержание + +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`, расширение промпта схемами + +Исходный код конвертера: [`md2gost/`](../md2gost/). Примеры: [`examples/`](../examples/). + +В GUI: **Справка → Документация** — тот же набор страниц (вшит в exe). diff --git a/docs/cli-gui.md b/docs/cli-gui.md new file mode 100644 index 0000000..dd82003 --- /dev/null +++ b/docs/cli-gui.md @@ -0,0 +1,58 @@ +# CLI и GUI + +Движок один: GUI собирает те же параметры, что и аргументы `python -m md2gost`. + +## Основные команды + +```bash +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 --check-pages # только проверка вёрстки готового DOCX +``` + +Справка: `python -m md2gost -h`. + +## Соответствие флагов и GUI + +| CLI | Где в GUI | Смысл | +|-----|-----------|--------| +| `--type` | Основные → тип | practice, coursework, vkr, PIS_custom, APID_coursework | +| `-o` / `--output` | Выходной DOCX | путь результата | +| `--heading-numbering` | Основные | `manual` (цифры в md) / `auto` (Word) | +| `--toc` | Основные | `native` (поле Word) / `manual` (собирает md2gost) | +| `--emdash-to-hyphen` | Галочка «—» → «-» | по умолчанию выкл. | +| `--hr-pagebreak` | Галочка --- → разрыв | по умолчанию `---` игнорируется | +| `--title` / `--assignment` / `-t` | Настройки → Файлы | титул, задание, шаблон | +| `--check` / `--check-only` / `--strict` | Галочки проверки ТЗ | замечания по структуре и источникам | +| `--check-pages` | Проверить вёрстку в Word | эвристика полупустых страниц (Word + pywin32) | +| `--table-continuation` | Основные | `word` (по умолчанию), `off`, `legacy`, `caption`, `soft` | +| `--listing-continuation` | Основные | то же для листингов | +| `--table-repeat-header` | Галочка | шапка на фрагментах после word-split | +| `--plantuml-jar` / `--kroki-url` | Настройки → Диаграммы | локальный рендер | +| `--diagram-fallback` | Диаграммы | `remote` / `local` / `off` | +| `--diagram-format` | Диаграммы | `png` / `svg` | +| `--diagram-scale` | Диаграммы | качество PNG PlantUML (по умолчанию 2) | +| `--schemes` | файл схем / Шаблоны UML | путь к `md2gost.schemes.json` | +| `--styles` | Настройки → Файлы → Стили JSON | оверлей `md2gost.styles.json` (также рядом с `.md`); см. [styles.md](styles.md) | +| `--syntax-highlighting` | (CLI) | подсветка в листингах | +| `--debug` | меню Дебаг | отладочные данные в документе (на одну сборку в GUI) | + +## Продолжение таблиц и листингов + +DOCX не умеет сам писать «Продолжение Таблицы N» только со 2-й страницы. Режим **`word`** (по умолчанию): после сохранения Word COM режет по реальной пагинации. Нужны Windows + Word + pywin32. Без Word: `--table-continuation off` (и то же для листингов). + +## Меню GUI + +- **Настройки → Файлы** — шаблон, титул, задание, стили JSON, выходной путь +- **Настройки → Диаграммы** — PlantUML, Kroki, формат, кэш includes +- **Шаблоны UML** — редактор `md2gost.schemes.json` +- **Справка → Инструкция / Документация / Схемы / Промпт для ИИ** + (Документация — встроенный просмотр `docs/*.md`, вшито в exe) + +## Другие форматы + +```bash +python -m md2fodt report.md -o report.fodt # LibreOffice +# PDF через LaTeX — см. md2latex/ +``` diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..a08f487 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1,153 @@ +# Синтаксис Markdown (диалект md2gost) + +Обычный Markdown описывает структуру текста. Для отчёта по ГОСТ нужны ещё: спецразделы без номера, подписи объектов, перекрёстные ссылки, библиография в заданном виде. **md2gost** расширяет MD ровно этими элементами; конвертер сам нумерует рисунки/таблицы/листинги и оформляет DOCX. + +## Что совпадает с обычным MD + +- Заголовки `#` … `######` +- Абзацы, **жирный**, *курсив* +- Маркированные и нумерованные списки +- Таблицы `| … |` +- Картинки `![alt](path.png)` +- Блоки кода в ограде ` ```язык ` +- Формулы `$$ … $$` (и инлайн `$…$` где поддерживается) + +## Зачем расширения + +| Задача | Обычный MD | md2gost | +|--------|------------|---------| +| Введение без номера «1» | `# Введение` → станет разделом 1 | `# *ВВЕДЕНИЕ` | +| Подпись «Рисунок 1.1 — …» | руками / HTML | `%id` или title у картинки | +| «см. рис. 2» в тексте | нет семантики | `@Рисунок:id` | +| UML → картинка в Word | экспорт PNG вручную | fence `` ```uml `` / схема → Рисунок | + +## Спецразделы + +Звёздочка `*` = без автоматической нумерации раздела. Текст — ПРОПИСНЫМИ: + +```markdown +# *СОДЕРЖАНИЕ +[TOC] + +# *ВВЕДЕНИЕ + +# 1 Название первого раздела +## 1.1 Подраздел + +# *ЗАКЛЮЧЕНИЕ + +# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ + +# *ПРИЛОЖЕНИЯ +``` + +- После `# *СОДЕРЖАНИЕ` обязателен `[TOC]`. +- В конце названия заголовка точку не ставить. +- Для `PIS_custom` структура другая (практические работы) — см. [types.md](types.md). + +## Рисунок (файл) + +```markdown +Текст со ссылкой на @Рисунок:arch. + +![описание](images/arch.png "%arch Название рисунка") +``` + +В тексте пишите **«Рисунок»**, не «рис.» / «рис». + +## Таблица + +```markdown +См. @Таблица:cmp. + +%cmp Название таблицы + +| A | B | +|---|---| +| 1 | 2 | +``` + +Склеивание ячеек: + +- `^` — rowspan (продолжение ячейки сверху) +- `>` — colspan (продолжение слева) + +Не ставить `^`/`>` в заголовочной строке; `>` — не в первом столбце. Графу «№ п/п» не добавлять. + +## Листинг + +~~~markdown +Фрагмент в @Листинг:code1. + +%code1 Название листинга + +```python +def f(): + return 1 +``` +~~~ + +## Диаграмма → Рисунок + +Перед блоком — `%id Подпись`. Языки: `uml`, `plantuml`, `mermaid`/`mmd`, или id схемы (`c4`, `bpmn`, …). + +- `+listing` — ещё и Листинг с исходником +- `+landscape` — альбомная страница под широкий рисунок/таблицу + +Подробности: [schemes.md](schemes.md). + +## Формула + +```markdown +%eq1 +$$ +E = mc^2 +$$ + +Зависимость (@Формула:eq1) используется далее. +``` + +Нумеруются **только** формулы, на которые есть `@Формула:…`. + +## Ссылки на объекты + +`@Рисунок:id`, `@Таблица:id`, `@Листинг:id`, `@Формула:id` — id совпадает с меткой после `%` или в title картинки. + +## Источники + +В тексте: `[1]`, `[2, 3]` (для ВКР — `[1.5]`). + +В списке: + +```markdown +[1]: Иванов И. И. Название. — М.: Наука, 2024. — 120 с. +``` + +Во **ВВЕДЕНИИ** и **ЗАКЛЮЧЕНИИ** ссылок `[n]` быть не должно. + +## Приложения + +```markdown +# *ПРИЛОЖЕНИЯ + +## Приложение А Листинг модуля + +… + +## Приложение Б Графический материал +``` + +Буквы: А, Б, В, Г, Д, Е, Ж, И, К… **Нельзя:** Ё, З, Й, О, Ч, Ь, Ы, Ъ. + +## Тире и разрыв страницы + +- Тире в предложениях: «—» (по умолчанию сохраняется). Замена на «-»: `--emdash-to-hyphen`. +- Строка `---` / `***` / `___` по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI. + +## Запрещено в отчёте + +- «рис.», «табл.» +- графа «№ п/п» +- сноски `[^1]` +- формулы обычным текстом вместо `$$…$$` +- нумерация спецразделов (`# ВВЕДЕНИЕ` вместо `# *ВВЕДЕНИЕ`) diff --git a/docs/prompts.md b/docs/prompts.md new file mode 100644 index 0000000..0713cd7 --- /dev/null +++ b/docs/prompts.md @@ -0,0 +1,33 @@ +# Промпты для ИИ + +Промпты лежат в [`prompts/`](../prompts/). В GUI: **Справка → Промпт для ИИ** — выбрать, при необходимости **дописать схемы**, скопировать в буфер. Полная документация также доступна в **Справка → Документация** (вшита в exe). + +## Какие файлы + +| Файл | Когда | +|------|--------| +| [`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`) | + +## Как пользоваться + +1. Откройте промпт (файл или GUI). +2. В GUI включите нужные схемы (C4, BPMN, …) — в конец промпта добавятся макросы и `ai-prompt` из `md2gost.schemes.json`. +3. Скопируйте собранный текст в ChatGPT / Claude / Cursor / Copilot. +4. Следующим сообщением: тип работы, тема, черновик / требования. +5. Сохраните ответ как `.md` и конвертируйте: + +```bash +python -m md2gost report.md -o report.docx --type coursework --check --strict +``` + +## Зачем кнопки схем + +Базовый промпт уже говорит: блоки `` ```uml `` / `` ```mermaid `` / `` ```uml-c4 `` конвертер сам превратит в Рисунок. Кнопки нужны, когда модели надо знать **конкретные макросы** выбранной схемы (Person/Rel для C4, Start/XOR/Flow для BPMN и т.д.) — в том числе пользовательских из «Шаблоны UML». + +## Подробнее о синтаксисе + +- [markdown.md](markdown.md) +- [schemes.md](schemes.md) +- [prompts/README.md](../prompts/README.md) diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..65f5eb3 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,61 @@ +# Быстрый старт + +## Установка + +Нужен Python 3.10+. + +```bash +pip install -e . +# или +poetry install +``` + +Для продолжения таблиц/листингов через Word COM (режим `word` по умолчанию) на Windows: + +```bash +pip install pywin32 +``` + +Нужны установленный Microsoft Word. + +## Запуск + +```bash +python -m md2gost # GUI без файла +python -m md2gost --gui +python -m md2gost report.md -o report.docx --type coursework --check +md2gost-gui # то же GUI (entry point) +``` + +### Windows exe + +В корне репозитория: `build-exe.bat` → `dist\md2gost.exe`. + +- Двойной клик — GUI. +- CLI: `md2gost.exe report.md -o report.docx --type coursework`. + +## GUI за минуту + +1. Перетащите `.md` в верхнюю область (или кликните по ней). +2. Выберите тип документа (`practice` по умолчанию). +3. При необходимости: Настройки → Файлы (титул, задание), Настройки → Диаграммы, меню «Шаблоны UML». +4. Нажмите «Конвертировать». + +Справка в меню: **Инструкция**, **Схемы**, **Промпт для ИИ**. + +## Типы документов (кратко) + +| `--type` | Назначение | +|----------|------------| +| `practice` / `coursework` | Практика / курсовая по ТЗ МИРЭА | +| `vkr` | ВКР (раздельный список, графическое приложение) | +| `PIS_custom` | Итоговый отчёт по практическим работам | +| `APID_coursework` | Курсовая «Архитектура приложений и данных» | + +Подробнее: [types.md](types.md). + +## Следующие шаги + +- Написать отчёт в диалекте md2gost: [markdown.md](markdown.md) +- Вставить диаграммы: [schemes.md](schemes.md) +- Сгенерировать `.md` через ИИ: [prompts.md](prompts.md) diff --git a/docs/schemes.md b/docs/schemes.md new file mode 100644 index 0000000..ee66dc9 --- /dev/null +++ b/docs/schemes.md @@ -0,0 +1,121 @@ +# Схемы и диаграммы + +В отчёте блок кода с языком диаграммы превращается в **Рисунок** (PNG или SVG). PNG руками экспортировать не нужно: md2gost вызывает PlantUML (jar / Kroki) или Kroki для Mermaid. + +## Синтаксис в markdown + +````markdown +%usecase1 Диаграмма прецедентов +listing + +```uml +@startuml +actor Student +Student --> (Login) +@enduml +``` +```` + +Широкая схема: + +````markdown +%arch1 Архитектура +landscape + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +Rel(user, app, "логин") +``` +```` + +| Оградка | Что происходит | +|---------|----------------| +| `` ```uml `` / `` ```plantuml `` | Сырой PlantUML (`@startuml`…`@enduml`) | +| `` ```uml- `` / `` ``` `` | Схема из `md2gost.schemes.json` (обёртка и `!include` добавляются сами) | +| `` ```mermaid `` / `` ```mmd `` | Mermaid через Kroki | +| `` ```bpmn `` / `` ```uml-bpmn `` | BPMN 2.0 (макросы библиотеки md2gost) | + +**IDEF0 / DFD** конвертер не рисует — вставляйте готовый PNG как обычный Рисунок. + +## Встроенные схемы + +Файл шаблона: [`md2gost/diagrams/schemes.json`](../md2gost/diagrams/schemes.json). При первом запуске копируется в `md2gost.schemes.json` рядом с приложением (существующий файл не перезаписывается). + +| id | Название | Оградка | +|----|----------|---------| +| `c4` | C4 Container | `` ```uml-c4 `` / `` ```c4 `` | +| `c4context` | C4 Context | `` ```uml-c4context `` | +| `c4component` | C4 Component | `` ```uml-c4component `` | +| `usecase` | Прецеденты | `` ```uml-usecase `` / `` ```usecase `` | +| `bpmn` | BPMN 2.0 | `` ```bpmn `` / `` ```uml-bpmn `` | + +Для схем в теле пишите **только макросы** (Person, Start, XOR, …) — без `@startuml` и без `!include`, если схема сама их добавляет. + +### C4 (пример макросов) + +`Person`, `System`, `System_Ext`, `Container`, `ContainerDb`, `Rel` / `Rel_R`… + +### Use Case + +`actor`, `usecase`, связи `-->`, `<>`, `<>`. + +### BPMN + +`Pool` / `Lane`, `Start` / `StartMessage` / `End`, `UserTask` / `ServiceTask`, `XOR` / `AND` / `OR`, `Flow` / `CondFlow` / `DefaultFlow` / `MessageFlow`, … + +Sequence Flow только внутри пула; между пулами — `MessageFlow`. + +Полные шпаргалки — поля `docs` и `ai-prompt` в JSON; в GUI: **Шаблоны UML** и кнопки схем в **Справка → Промпт для ИИ**. + +## Файл схем: поля + +```json +{ + "c4": { + "title": "C4 Container", + "docs": "…макросы…", + "ai-prompt": "…инструкция для ИИ…", + "includes": ["https://…/C4_Container.puml"], + "prefix": "@startuml\n", + "postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml" + } +} +``` + +| Поле | Назначение | +|------|------------| +| `title` | Подпись в GUI | +| `docs` | Шпаргалка макросов | +| `ai-prompt` | Фрагмент для промпта ИИ | +| `includes` | Локальные `.puml` или URL | +| `prefix` / `postfix` | Обёртка вокруг тела | +| `theme` | опционально `!theme …` | + +id схемы: латиница, цифры, `_`; начинается с буквы. + +## Слои загрузки (позже побеждает) + +1. Встроенный шаблон `md2gost/diagrams/schemes.json` +2. Пользовательский файл рядом с приложением (`md2gost.schemes.json`) +3. `md2gost.schemes.json` рядом с `.md` +4. Явный путь `--schemes path.json` + +## Рендер + +**UML / схемы:** + +1. Java + `plantuml.jar` (вшитый / `%LOCALAPPDATA%\md2gost\` / `--plantuml-jar`) +2. Локальный Kroki (`KROKI_URL` / `--kroki-url`, по умолчанию `http://localhost:8000`) +3. `https://kroki.io` при `--diagram-fallback remote` (по умолчанию) + +**Mermaid** — только Kroki (шаги 2–3). + +Формат: `--diagram-format png` (по умолчанию) или `svg`. Масштаб рендера PlantUML: `--diagram-scale` (по умолчанию 2 — качество, размер на странице как при 1). + +Кэш картинок: `{каталог_md}/.md2gost-cache/`. +Кэш includes: `md2gost.include-cache.json` + папка `include-cache/` (сброс в GUI: Настройки → Диаграммы / Шаблоны UML). + +## Свои схемы + +1. Меню **Шаблоны UML** — добавить / править / сохранить. +2. Или править `md2gost.schemes.json` вручную («Открыть JSON»). +3. CLI: `--schemes путь.json`. diff --git a/docs/styles.md b/docs/styles.md new file mode 100644 index 0000000..299fd0b --- /dev/null +++ b/docs/styles.md @@ -0,0 +1,67 @@ +# Кастомные стили (JSON) + +Опциональный оверлей оформления поверх пресета `--type` (`mirea` или `pis_custom`). +Неуказанные поля остаются у пресета. Стили пишет конвертер в DOCX сам — вручную в шаблон добавлять не нужно. + +## Как подключить + +Позже побеждает (deep-merge только указанных ключей): + +1. Встроенный пресет типа документа +2. `md2gost.styles.json` рядом с `.md` (если есть) +3. `--styles путь.json` или путь в GUI: **Настройки → Файлы → Стили JSON** + +Файл рядом с exe при первом запуске **не создаётся**. + +```bash +python -m md2gost report.md -o report.docx --type practice --styles examples/md2gost.styles.json +``` + +Пример: [`examples/md2gost.styles.json`](../examples/md2gost.styles.json). + +## Схема + +Два блока. Неизвестный ключ или имя стиля — ошибка. + +```json +{ + "page": { + "left_mm": 30, + "right_mm": 10, + "top_mm": 20, + "bottom_mm": 20 + }, + "styles": { + "Heading 1": { + "alignment": "center", + "size_pt": 14, + "all_caps": true + } + } +} +``` + +### `page` + +| Ключ | Единица | Смысл | +|------|---------|--------| +| `left_mm` / `right_mm` / `top_mm` / `bottom_mm` | мм | Поля страницы | + +### Имена стилей + +`Normal`, `Heading 1`, `Heading 2`, `Heading 3`, `Caption Figure`, `Caption Table`, `Название таблицы`, `Caption Listing`, `Caption`, `Code`, `Table Text`, `Bibliography`, `Bibliography Heading`, `toc 1`, `toc 2`, `toc 3`, `Footer`, `Hyperlink`, `FollowedHyperlink`, `Space After Table`. + +### Поля стиля + +| Ключ | Тип / значения | +|------|----------------| +| `font_name` | строка | +| `size_pt` | число (pt) | +| `bold`, `italic`, `all_caps`, `underline` | bool | +| `alignment` | `left` \| `center` \| `justify` | +| `first_line_indent_cm`, `left_indent_cm`, `right_indent_cm` | см | +| `space_before_mm`, `space_after_mm` | мм | +| `line_spacing` | `1.0` \| `1.5` | +| `page_break_before`, `keep_with_next`, `widow_control` | bool | + +Не в JSON: табы оглавления (считаются от ширины полосы), отступы списков, формулы из шаблона, цвет (всегда чёрный). diff --git a/docs/types.md b/docs/types.md new file mode 100644 index 0000000..212d8b9 --- /dev/null +++ b/docs/types.md @@ -0,0 +1,66 @@ +# Типы документов + +Задаётся флагом `--type` / выбором в GUI. Профили: [`md2gost/profiles.py`](../md2gost/profiles.py). + +## Сводка + +| Тип | Стили | Нумерация объектов | Источники | Структура | +|-----|--------|-------------------|-----------|-----------| +| `practice` | МИРЭА | по разделам (1.1, 2.1) | 5–7, ≤5 лет | Введение, разделы, заключение, список, приложения | +| `coursework` | МИРЭА | по разделам | 5–7, ≤5 лет | то же | +| `vkr` | МИРЭА | по разделам | ≥10 в **каждом** разделе списка, ≤5 лет | + приложение «Графический материал»; ссылки `[1.5]` | +| `PIS_custom` | ПИС | сквозная (1, 2, 3) | не требуются | H1 = практические работы; без введения/заключения по умолчанию | +| `APID_coursework` | МИРЭА | по разделам | 7–20, ≤5 лет | обязательные главы/пункты АПИД (см. ниже) | + +## coursework / practice + +Спецразделы с `*`, нумерованная основная часть. В DOCX: H1 слева с отступом (кроме СОДЕРЖАНИЕ / СПИСОК — по центру). + +Проверка: `--check` / `--strict`. + +## vkr + +- Список источников **с разделами**; в каждом ≥10 записей. +- Ссылки вида `[1.5]`, при цитировании `[2.18, c. 21-25]`. +- Обязательное приложение с текстом «Графический материал» в названии. + +## PIS_custom + +Итоговый отчёт по практическим работам. + +```markdown +# *СОДЕРЖАНИЕ +[TOC] + +# Практическая работа №1. Название работы + +## Цель работы +## Ход работы +## Выводы + +# Практическая работа №2. … +``` + +- H1 **без** `*` (кроме СОДЕРЖАНИЕ): в DOCX — ПРОПИСНЫМИ по центру. +- H2 с абзацным отступом. +- Титул («Отчёт по практическим работам …») — отдельный DOCX: `--title title.docx`. + +Пример: [`examples/pis_custom.md`](../examples/pis_custom.md). +Промпт: [`prompts/generate-pis-custom-report.md`](../prompts/generate-pis-custom-report.md). + +## APID_coursework + +Курсовая «Архитектура приложений и данных» (методичка Аншиной/Лагуновой). Оформление как у `coursework`, плюс проверка заголовков: + +1. Теоретические аспекты … +2. Прикладные аспекты … + - 2.1 Описание проекта … + - 2.2 Описание роли … + - 2.3 Описание архитектуры … + - 2.4 Варианты развития архитектуры … + +Источники: 7–20. + +```bash +python -m md2gost report.md -o report.docx --type APID_coursework --check --title title.docx --assignment assignment.docx +``` diff --git a/examples/md2gost.styles.json b/examples/md2gost.styles.json new file mode 100644 index 0000000..23d15a3 --- /dev/null +++ b/examples/md2gost.styles.json @@ -0,0 +1,21 @@ +{ + "page": { + "left_mm": 20, + "right_mm": 20, + "top_mm": 20, + "bottom_mm": 20 + }, + "styles": { + "Heading 1": { + "alignment": "center", + "size_pt": 14, + "all_caps": true, + "left_indent_cm": 0, + "first_line_indent_cm": 0 + }, + "Normal": { + "first_line_indent_cm": 1.25, + "line_spacing": 1.5 + } + } +} diff --git a/md2gost.fast.spec b/md2gost.fast.spec index bb63df0..301b375 100644 --- a/md2gost.fast.spec +++ b/md2gost.fast.spec @@ -24,6 +24,7 @@ datas = [ (str(ROOT / "md2gost" / "mml2omml"), "md2gost"), (str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"), (str(ROOT / "prompts"), "prompts"), + (str(ROOT / "docs"), "docs"), ] _plantuml = ROOT / "md2gost" / "vendor" / "plantuml.jar" if not _plantuml.is_file(): diff --git a/md2gost.spec b/md2gost.spec index a0f0d27..baa6ffe 100644 --- a/md2gost.spec +++ b/md2gost.spec @@ -14,6 +14,7 @@ datas = [ (str(ROOT / "md2gost" / "mml2omml"), "md2gost"), (str(ROOT / "md2gost" / "diagrams"), "md2gost/diagrams"), (str(ROOT / "prompts"), "prompts"), + (str(ROOT / "docs"), "docs"), ] _plantuml = ROOT / "md2gost" / "vendor" / "plantuml.jar" if _plantuml.is_file(): diff --git a/md2gost/README.md b/md2gost/README.md index 0a65e18..9c119d4 100644 --- a/md2gost/README.md +++ b/md2gost/README.md @@ -2,6 +2,8 @@ Конвертер Markdown → DOCX по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) на базе [md2gost](https://github.com/benzlokzik/md2gost). +**Полная документация:** [`docs/`](../docs/) (быстрый старт, синтаксис, схемы, типы, CLI/GUI, промпты). + ## Установка ```bash @@ -23,7 +25,7 @@ md2gost-gui На Windows файл можно бросить из Проводника в верхнюю область окна. Клик по области — выбор через диалог. Все флаги CLI есть в форме (тип, нумерация, TOC, тире, `---` → разрыв страницы, титул/задание, диаграммы, проверка ТЗ). -Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (МИРЭА / ПИС) в буфер. +Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (с опциональным дописыванием UML-схем) в буфер. ### Сборка exe (Windows) diff --git a/md2gost/__main__.py b/md2gost/__main__.py index bb1d358..75a53be 100644 --- a/md2gost/__main__.py +++ b/md2gost/__main__.py @@ -166,6 +166,14 @@ def build_parser() -> ArgumentParser: dest="schemes_path", help="Путь к md2gost.schemes.json (иначе рядом с приложением / с .md)", ) + parser.add_argument( + "--styles", + dest="styles_path", + help=( + "Путь к md2gost.styles.json (оверлей оформления поверх пресета --type; " + "также подхватывается md2gost.styles.json рядом с .md)" + ), + ) parser.add_argument("--debug", help="Добавляет отладочные данные в документ", action="store_true") return parser @@ -197,6 +205,7 @@ def request_from_args(args) -> ConvertRequest: diagram_format=args.diagram_format, diagram_scale=float(args.diagram_scale), schemes_path=args.schemes_path, + styles_path=getattr(args, "styles_path", None), debug=args.debug, open_when_done=bool(args.debug), ) diff --git a/md2gost/converter.py b/md2gost/converter.py index 548eec1..b6a9a8d 100644 --- a/md2gost/converter.py +++ b/md2gost/converter.py @@ -1,3 +1,5 @@ +import os + import docx from docx.document import Document @@ -43,7 +45,8 @@ class Converter: toc_mode: str = DEFAULT_TOC_MODE, table_continuation: str = DEFAULT_TABLE_CONTINUATION, listing_continuation: str = DEFAULT_LISTING_CONTINUATION, - hr_pagebreak: bool = False): + hr_pagebreak: bool = False, + styles_path: str | None = None): if heading_numbering not in HEADING_NUMBERING_MODES: raise ValueError( f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, " @@ -71,10 +74,17 @@ class Converter: self._table_continuation = table_continuation self._listing_continuation = listing_continuation self._hr_pagebreak = hr_pagebreak + self._styles_path = styles_path self._profile = get_profile(doc_type) self._document: Document = docx.Document(template_path) self._document._body.clear_content() - apply_document_styles(self._document, self._profile.style_preset) + md_dir = os.path.dirname(os.path.abspath(input_path)) or "." + self._style_config = apply_document_styles( + self._document, + self._profile.style_preset, + md_dir=md_dir, + 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() @@ -123,6 +133,10 @@ class Converter: def document(self) -> Document: return self._document + @property + def style_config(self): + return self._style_config + @property def raw_markdown(self) -> str: return self._raw_markdown diff --git a/md2gost/gui.py b/md2gost/gui.py index 126e4dc..5c6ccfd 100644 --- a/md2gost/gui.py +++ b/md2gost/gui.py @@ -14,13 +14,20 @@ from .diagram_schemes import ( DiagramScheme, SCHEME_ID_RE, ensure_user_schemes, + get_schemes, load_schemes_from_path, reload_schemes, save_schemes, user_schemes_path, ) from .dnd import enable_file_drop, first_markdown -from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog +from .help_content import ( + SCHEMES_HELP, + USAGE_HELP, + compose_prompt, + load_docs_catalog, + load_prompt_catalog, +) from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path from .profiles import ( DEFAULT_HEADING_NUMBERING, @@ -244,6 +251,7 @@ class Md2GostApp: self.template_var = tk.StringVar() self.title_var = tk.StringVar() self.assignment_var = tk.StringVar() + self.styles_var = tk.StringVar() self.fallback_var = tk.StringVar() self.format_var = tk.StringVar() self.plantuml_var = tk.StringVar() @@ -266,6 +274,7 @@ class Md2GostApp: help_menu = tk.Menu(menubar, tearoff=0) help_menu.add_command(label="Инструкция", command=lambda: self._open_help_window("usage")) + help_menu.add_command(label="Документация", command=lambda: self._open_help_window("docs")) help_menu.add_command(label="Схемы", command=lambda: self._open_help_window("schemes")) help_menu.add_command(label="Промпт для ИИ", command=lambda: self._open_help_window("prompts")) menubar.add_cascade(label="Справка", menu=help_menu) @@ -313,8 +322,8 @@ class Md2GostApp: win = tk.Toplevel(self.root) win.title("Настройки — Файлы") win.configure(bg=PAPER) - win.minsize(520, 200) - win.geometry("620x240") + win.minsize(520, 240) + win.geometry("620x280") win.transient(self.root) frame = ttk.Frame(win, padding=12) frame.pack(fill=tk.BOTH, expand=True) @@ -325,13 +334,16 @@ class Md2GostApp: tip="DOCX титула, вставляется перед телом отчёта") self._row_path(frame, 2, "Бланк задания", self.assignment_var, self._browse_assignment, clearable=True, tip="DOCX бланка задания, вставляется перед телом отчёта") + self._row_path(frame, 3, "Стили JSON", self.styles_var, self._browse_styles, clearable=True, + tip="Оверлей md2gost.styles.json поверх пресета типа документа") hint = ttk.Label( frame, - text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.", + text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.\n" + "Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.", style="Hint.TLabel", wraplength=560, ) - hint.grid(row=3, column=0, columnspan=2, sticky="w", pady=(8, 0)) + hint.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0)) win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files")) return win @@ -456,6 +468,7 @@ class Md2GostApp: def _create_help_window(self, kind: str) -> tk.Toplevel: titles = { "usage": "Справка — Инструкция", + "docs": "Справка — Документация", "schemes": "Справка — Схемы", "prompts": "Справка — Промпт для ИИ", } @@ -463,12 +476,17 @@ class Md2GostApp: win.title(titles.get(kind, "Справка")) win.configure(bg=PAPER) win.minsize(560, 400) - win.geometry("700x560") + if kind == "docs": + win.geometry("780x560") + else: + win.geometry("700x560") win.transient(self.root) frame = ttk.Frame(win, padding=12) frame.pack(fill=tk.BOTH, expand=True) if kind == "usage": self._build_help(frame) + elif kind == "docs": + self._build_docs_browser(frame) elif kind == "schemes": self._build_schemes_help(frame) else: @@ -540,6 +558,60 @@ class Md2GostApp: box = self._readonly_text(parent, USAGE_HELP.strip() + "\n") box.pack(fill=tk.BOTH, expand=True) + def _build_docs_browser(self, parent) -> None: + ttk.Label( + parent, + text="Страницы слева; ссылки вида (file.md) в тексте открывайте тем же списком. " + "Markdown показывается как есть.", + style="Hint.TLabel", + wraplength=740, + ).pack(anchor=tk.W, pady=(0, 8)) + + catalog = load_docs_catalog() + if not catalog: + ttk.Label( + parent, + text="Документация не найдена (папка docs/ рядом с программой или внутри сборки).", + style="Hint.TLabel", + ).pack(anchor=tk.W) + return + + self._docs_catalog = catalog + body = ttk.Frame(parent) + body.pack(fill=tk.BOTH, expand=True) + + left = ttk.Frame(body) + left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) + self._docs_list = tk.Listbox(left, height=22, width=28, exportselection=False) + self._docs_list.pack(side=tk.TOP, fill=tk.BOTH, expand=True) + for _name, title, _text in catalog: + self._docs_list.insert(tk.END, title) + self._docs_list.bind("<>", self._on_docs_select) + _tip(self._docs_list, "Разделы документации (docs/*.md)") + + right = ttk.Frame(body) + right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + self._docs_view = self._readonly_text(right, catalog[0][2]) + self._docs_view.pack(fill=tk.BOTH, expand=True) + + self._docs_list.selection_set(0) + self._docs_list.see(0) + + def _on_docs_select(self, _event=None) -> None: + if not hasattr(self, "_docs_list") or not hasattr(self, "_docs_catalog"): + return + sel = self._docs_list.curselection() + if not sel: + return + idx = int(sel[0]) + if idx < 0 or idx >= len(self._docs_catalog): + return + text = self._docs_catalog[idx][2] + self._docs_view.configure(state=tk.NORMAL) + self._docs_view.delete("1.0", tk.END) + self._docs_view.insert("1.0", text) + self._docs_view.configure(state=tk.DISABLED) + def _build_schemes_help(self, parent) -> None: ttk.Label( parent, @@ -553,7 +625,9 @@ class Md2GostApp: catalog = load_prompt_catalog() ttk.Label( parent, - text="Скопируйте промпт целиком и вставьте в ChatGPT / Cursor / Copilot. Следующим сообщением — тип, тема, черновик.", + text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. " + "Кнопки схем дописывают макросы из md2gost.schemes.json — " + "когда ИИ должен знать C4/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.", style="Hint.TLabel", wraplength=640, ).pack(anchor=tk.W, pady=(0, 8)) @@ -575,32 +649,113 @@ class Md2GostApp: ttk.Label(row, text="Промпт").pack(side=tk.LEFT, padx=(0, 8)) box = ttk.Combobox(row, textvariable=self._prompt_choice, values=titles, state="readonly") box.pack(side=tk.LEFT, fill=tk.X, expand=True) - box.bind("<>", lambda _e: self._show_selected_prompt()) + box.bind("<>", lambda _e: self._refresh_composed_prompt()) btn = ttk.Button(row, text="Скопировать", command=self._copy_selected_prompt) btn.pack(side=tk.LEFT, padx=(8, 0)) - _tip(btn, "Скопировать выбранный промпт в буфер обмена") + _tip(btn, "Скопировать собранный промпт (база + выбранные схемы) в буфер") - self._prompt_view = self._readonly_text(parent, self._prompt_texts[titles[0]]) + schemes_frame = ttk.LabelFrame(parent, text="Дописать схемы в промпт", padding=6) + schemes_frame.pack(fill=tk.X, pady=(0, 6)) + ttk.Label( + schemes_frame, + text="UML/Mermaid и так конвертируются в Рисунок; схемы нужны для макросов.", + style="Hint.TLabel", + wraplength=620, + ).pack(anchor=tk.W, pady=(0, 4)) + + try: + ensure_user_schemes() + schemes_map = get_schemes() + except Exception: + schemes_map = {} + self._prompt_scheme_ids = sorted(schemes_map.keys()) + self._prompt_scheme_vars: dict[str, tk.BooleanVar] = {} + self._prompt_schemes_by_id = dict(schemes_map) + + toggles = ttk.Frame(schemes_frame) + toggles.pack(fill=tk.X) + if not self._prompt_scheme_ids: + ttk.Label( + toggles, + text="Схемы не найдены (md2gost.schemes.json).", + style="Hint.TLabel", + ).pack(anchor=tk.W) + else: + for sid in self._prompt_scheme_ids: + var = tk.BooleanVar(value=False) + self._prompt_scheme_vars[sid] = var + title = schemes_map[sid].title or sid + cb = ttk.Checkbutton( + toggles, + text=f"{title} ({sid})", + variable=var, + command=self._refresh_composed_prompt, + ) + cb.pack(anchor=tk.W) + _tip(cb, f"Добавить в промпт макросы схемы ```uml-{sid}") + + ctrl = ttk.Frame(schemes_frame) + ctrl.pack(fill=tk.X, pady=(4, 0)) + btn_all = ttk.Button(ctrl, text="Все", command=self._prompt_schemes_select_all) + btn_all.pack(side=tk.LEFT, padx=(0, 6)) + _tip(btn_all, "Включить все схемы") + btn_none = ttk.Button(ctrl, text="Сбросить", command=self._prompt_schemes_clear) + btn_none.pack(side=tk.LEFT) + _tip(btn_none, "Выключить все схемы") + + self._prompt_view = self._readonly_text(parent, "") self._prompt_view.pack(fill=tk.BOTH, expand=True) self._copy_status = ttk.Label(parent, text="", style="Hint.TLabel") self._copy_status.pack(anchor=tk.W, pady=(6, 0)) + self._refresh_composed_prompt() - def _show_selected_prompt(self) -> None: - text = self._prompt_texts.get(self._prompt_choice.get(), "") + def _selected_prompt_schemes(self) -> list: + out = [] + for sid in getattr(self, "_prompt_scheme_ids", []): + var = self._prompt_scheme_vars.get(sid) + if var is not None and var.get(): + scheme = self._prompt_schemes_by_id.get(sid) + if scheme is not None: + out.append(scheme) + return out + + def _composed_prompt_text(self) -> str: + base = self._prompt_texts.get(self._prompt_choice.get(), "") + return compose_prompt(base, self._selected_prompt_schemes()) + + def _refresh_composed_prompt(self) -> None: + if not hasattr(self, "_prompt_view"): + return + text = self._composed_prompt_text() self._prompt_view.configure(state=tk.NORMAL) self._prompt_view.delete("1.0", tk.END) self._prompt_view.insert("1.0", text) self._prompt_view.configure(state=tk.DISABLED) - self._copy_status.configure(text="") + if hasattr(self, "_copy_status"): + self._copy_status.configure(text="") + + def _prompt_schemes_select_all(self) -> None: + for var in self._prompt_scheme_vars.values(): + var.set(True) + self._refresh_composed_prompt() + + def _prompt_schemes_clear(self) -> None: + for var in self._prompt_scheme_vars.values(): + var.set(False) + self._refresh_composed_prompt() def _copy_selected_prompt(self) -> None: - text = self._prompt_texts.get(self._prompt_choice.get(), "") + text = self._composed_prompt_text() if not text: return self.root.clipboard_clear() self.root.clipboard_append(text) self.root.update_idletasks() - self._copy_status.configure(text="Скопировано в буфер обмена — можно вставлять в ИИ.") + n = len(self._selected_prompt_schemes()) + extra = f" (+{n} схем)" if n else "" + self._copy_status.configure( + text=f"Скопировано в буфер обмена{extra} — можно вставлять в ИИ." + ) def _readonly_text(self, parent, content: str) -> tk.Text: wrap = ttk.Frame(parent) @@ -1065,6 +1220,9 @@ class Md2GostApp: def _browse_assignment(self) -> None: self._pick_into(self.assignment_var, "Бланк задания", [("Word", "*.docx")]) + def _browse_styles(self) -> None: + self._pick_into(self.styles_var, "Стили JSON", [("JSON", "*.json"), ("Все файлы", "*.*")]) + def _browse_jar(self) -> None: self._pick_into(self.plantuml_var, "Свой plantuml.jar", [("JAR", "*.jar"), ("Все файлы", "*.*")]) self._refresh_diagram_status() @@ -1149,6 +1307,7 @@ class Md2GostApp: self.template_var.set(req.template or "") self.title_var.set(req.title or "") self.assignment_var.set(req.assignment or "") + self.styles_var.set(getattr(req, "styles_path", None) or "") self.plantuml_var.set(req.plantuml_jar or "") self.kroki_var.set(req.kroki_url or "") self._refresh_diagram_status() @@ -1181,6 +1340,7 @@ class Md2GostApp: diagram_fallback=_combo_key(self.fallback_var.get(), ("remote", "local", "off"), "remote"), diagram_format=_combo_key(self.format_var.get(), ("png", "svg"), "png"), diagram_scale=self._parse_diagram_scale(), + styles_path=self.styles_var.get().strip() or None, debug=self.debug_var.get(), open_when_done=self.open_var.get(), ) diff --git a/md2gost/help_content.py b/md2gost/help_content.py index 6606122..0bf26c7 100644 --- a/md2gost/help_content.py +++ b/md2gost/help_content.py @@ -33,6 +33,13 @@ CLI (тот же движок) 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 + +Стили JSON (опционально) +Оверлей поверх пресета типа документа (--type). Файл md2gost.styles.json рядом с .md +или --styles / Настройки → Файлы → «Стили JSON». Меняет поля страницы и параметры +стилей абзацев (Normal, Heading 1–3, подписи…). В шаблон DOCX стили руками добавлять не нужно. +Подробнее: docs/styles.md. СИНТАКСИС MARKDOWN @@ -126,7 +133,12 @@ IDEF0 конвертер не рисует — вставляйте готовы off — не резать, Word сам переносит. legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word). -Промпт для ИИ — Справка → Промпт для ИИ: скопируйте и вставьте в ChatGPT / Cursor / Copilot, затем дайте тему и черновик. +Промпт для ИИ — Справка → Промпт для ИИ: выберите промпт, при необходимости +включите схемы (C4, BPMN, …) кнопками — макросы допишутся в конец — скопируйте +в ChatGPT / Cursor / Copilot, затем дайте тему и черновик. + +Документация — Справка → Документация: встроенный просмотр docs/*.md +(вшито в exe; внешняя папка docs/ не обязательна). """ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML) @@ -219,6 +231,7 @@ Mermaid: только Kroki (свой --kroki-url / localhost / kroki.io). """ PROMPT_FILES = ( + ("generate-md.md", "Markdown для md2gost"), ("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"), ("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"), ) @@ -259,3 +272,100 @@ def load_prompt_catalog() -> list[tuple[str, str, str]]: if text: catalog.append((name, title, text)) return catalog + + +def scheme_prompt_block(scheme) -> str: + """Format one DiagramScheme for appending to an AI prompt.""" + sid = getattr(scheme, "id", "") or "" + title = (getattr(scheme, "title", None) or sid).strip() + lines = [ + f"## Схема: {title} (`{sid}`)", + "", + f"Оградка в markdown: ```uml-{sid} или ```{sid}", + "", + ] + ai = (getattr(scheme, "ai_prompt", None) or "").strip() + if ai: + lines.append(ai) + lines.append("") + docs = (getattr(scheme, "docs", None) or "").strip() + if docs: + lines.append("Макросы / шпаргалка:") + lines.append(docs) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def compose_prompt(base: str, schemes: list | None = None) -> str: + """ + Base prompt text plus optional scheme blocks (order preserved). + schemes: iterable of DiagramScheme (or objects with id/title/docs/ai_prompt). + """ + text = (base or "").rstrip() + if not schemes: + return text + ("\n" if text else "") + parts = [text, "", "---", "", "# Дополнение: выбранные схемы диаграмм", ""] + for scheme in schemes: + parts.append(scheme_prompt_block(scheme)) + parts.append("") + return "\n".join(parts).rstrip() + "\n" + + +def docs_search_dirs() -> list[Path]: + dirs: list[Path] = [] + here = Path(package_dir()) + dirs.append(here / "docs") + dirs.append(here.parent / "docs") + if getattr(sys, "frozen", False): + mei = getattr(sys, "_MEIPASS", None) + if mei: + dirs.append(Path(mei) / "docs") + dirs.append(Path(sys.executable).resolve().parent / "docs") + seen: set[str] = set() + out: list[Path] = [] + for path in dirs: + key = str(path.resolve()) if path.exists() else str(path) + if key in seen: + continue + seen.add(key) + out.append(path) + return out + + +def _doc_title_from_text(filename: str, text: str) -> str: + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + return stripped.lstrip("#").strip() or Path(filename).stem + return Path(filename).stem + + +def load_docs_catalog() -> list[tuple[str, str, str]]: + """ + Return list of (filename, title, text) from the first existing docs/ folder. + README.md first, then other *.md alphabetically. + """ + folder: Path | None = None + for path in docs_search_dirs(): + if path.is_dir(): + folder = path + break + if folder is None: + return [] + + files = sorted(p for p in folder.glob("*.md") if p.is_file()) + if not files: + return [] + + readme = [p for p in files if p.name.lower() == "readme.md"] + rest = [p for p in files if p.name.lower() != "readme.md"] + ordered = readme + rest + + catalog: list[tuple[str, str, str]] = [] + for path in ordered: + try: + text = path.read_text(encoding="utf-8") + except OSError: + continue + catalog.append((path.name, _doc_title_from_text(path.name, text), text)) + return catalog diff --git a/md2gost/pipeline.py b/md2gost/pipeline.py index fa1483d..5c57500 100644 --- a/md2gost/pipeline.py +++ b/md2gost/pipeline.py @@ -22,7 +22,6 @@ from .profiles import ( DEFAULT_TABLE_CONTINUATION, DEFAULT_LISTING_CONTINUATION, DEFAULT_TOC_MODE, - get_profile, ) LogFn = Callable[[str], None] @@ -52,6 +51,7 @@ class ConvertRequest: diagram_format: str = "png" diagram_scale: float = 2.0 schemes_path: str | None = None + styles_path: str | None = None debug: bool = False open_when_done: bool = False check_pages: bool = False @@ -219,6 +219,7 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult: table_continuation=req.table_continuation, listing_continuation=req.listing_continuation, hr_pagebreak=req.hr_pagebreak, + styles_path=req.styles_path or None, ) converter.convert() document = converter.document @@ -228,10 +229,11 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult: from docxcompose.composer import Composer except ImportError: return fail(3, "Для титула/задания нужен пакет docxcompose", check_report) - from .styles import apply_document_styles + from .styles import apply_style_config + style_cfg = converter.style_config shell = Document(template) - apply_document_styles(shell, get_profile(req.doc_type).style_preset) + apply_style_config(shell, style_cfg) body = shell.element.body for child in list(body): if not child.tag.endswith("}sectPr"): @@ -246,7 +248,7 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult: shell.add_page_break() composer.append(document) document = composer.doc - apply_document_styles(document, get_profile(req.doc_type).style_preset) + apply_style_config(document, style_cfg) _fix_front_matter_after_compose( document, had_title=bool(req.title), diff --git a/md2gost/style_config.py b/md2gost/style_config.py new file mode 100644 index 0000000..c6dcc66 --- /dev/null +++ b/md2gost/style_config.py @@ -0,0 +1,597 @@ +"""JSON overlay for document styles (page margins + paragraph styles). + +Layers (later wins, deep-merge of specified keys only): +1) built-in preset from DocProfile.style_preset (mirea | pis_custom) +2) md2gost.styles.json next to the .md (if present) +3) --styles path / GUI path +""" + +from __future__ import annotations + +import copy +import json +import logging +from dataclasses import dataclass, field, fields +from pathlib import Path +from typing import Any, Callable + +_log = logging.getLogger(__name__) + +STYLES_FILENAME = "md2gost.styles.json" + +PAGE_KEYS = frozenset({"left_mm", "right_mm", "top_mm", "bottom_mm"}) + +STYLE_NAMES = frozenset({ + "Normal", + "Heading 1", + "Heading 2", + "Heading 3", + "Caption Figure", + "Caption Table", + "Название таблицы", + "Caption Listing", + "Caption", + "Code", + "Table Text", + "Bibliography", + "Bibliography Heading", + "toc 1", + "toc 2", + "toc 3", + "Footer", + "Hyperlink", + "FollowedHyperlink", + "Space After Table", +}) + +ALIGNMENT_VALUES = frozenset({"left", "center", "justify"}) +LINE_SPACING_VALUES = frozenset({1.0, 1.5}) + +STYLE_FIELD_KEYS = frozenset({ + "font_name", + "size_pt", + "bold", + "italic", + "all_caps", + "underline", + "alignment", + "first_line_indent_cm", + "left_indent_cm", + "right_indent_cm", + "space_before_mm", + "space_after_mm", + "line_spacing", + "page_break_before", + "keep_with_next", + "widow_control", +}) + +TOP_LEVEL_KEYS = frozenset({"page", "styles"}) + + +class StyleConfigError(ValueError): + """Invalid styles JSON or unknown key/style name.""" + + +@dataclass +class PageSpec: + left_mm: float | None = None + right_mm: float | None = None + top_mm: float | None = None + bottom_mm: float | None = None + + def merge(self, overlay: "PageSpec | dict[str, Any] | None") -> "PageSpec": + if overlay is None: + return copy.copy(self) + if isinstance(overlay, PageSpec): + data = { + f.name: getattr(overlay, f.name) + for f in fields(overlay) + if getattr(overlay, f.name) is not None + } + else: + data = dict(overlay) + out = copy.copy(self) + for key, val in data.items(): + if key not in PAGE_KEYS: + raise StyleConfigError(f"Unknown page key: {key!r}") + setattr(out, key, float(val)) + return out + + def to_dict(self) -> dict[str, float]: + out: dict[str, float] = {} + for f in fields(self): + val = getattr(self, f.name) + if val is not None: + out[f.name] = val + return out + + def require_complete(self) -> "PageSpec": + """Ensure all margins are set (for apply). Missing → GOST defaults.""" + return PageSpec( + left_mm=30.0 if self.left_mm is None else self.left_mm, + right_mm=10.0 if self.right_mm is None else self.right_mm, + top_mm=20.0 if self.top_mm is None else self.top_mm, + bottom_mm=20.0 if self.bottom_mm is None else self.bottom_mm, + ) + + +@dataclass +class ParagraphStyleSpec: + font_name: str | None = None + size_pt: float | None = None + bold: bool | None = None + italic: bool | None = None + all_caps: bool | None = None + underline: bool | None = None + alignment: str | None = None # left | center | justify + first_line_indent_cm: float | None = None + left_indent_cm: float | None = None + right_indent_cm: float | None = None + space_before_mm: float | None = None + space_after_mm: float | None = None + line_spacing: float | None = None # 1.0 | 1.5 + page_break_before: bool | None = None + keep_with_next: bool | None = None + widow_control: bool | None = None + + def merge(self, overlay: "ParagraphStyleSpec | dict[str, Any] | None") -> "ParagraphStyleSpec": + if overlay is None: + return copy.copy(self) + if isinstance(overlay, ParagraphStyleSpec): + data = { + f.name: getattr(overlay, f.name) + for f in fields(overlay) + if getattr(overlay, f.name) is not None + } + else: + data = dict(overlay) + out = copy.copy(self) + for key, val in data.items(): + if key not in STYLE_FIELD_KEYS: + raise StyleConfigError(f"Unknown style field: {key!r}") + if val is None: + continue + if key == "alignment": + s = str(val).lower() + if s not in ALIGNMENT_VALUES: + raise StyleConfigError( + f"Invalid alignment {val!r}; expected one of {sorted(ALIGNMENT_VALUES)}" + ) + setattr(out, key, s) + elif key == "line_spacing": + fval = float(val) + if fval not in LINE_SPACING_VALUES: + raise StyleConfigError( + f"Invalid line_spacing {val!r}; expected 1.0 or 1.5" + ) + setattr(out, key, fval) + elif key in ("font_name",): + setattr(out, key, str(val)) + elif key in ("size_pt", "first_line_indent_cm", "left_indent_cm", + "right_indent_cm", "space_before_mm", "space_after_mm"): + setattr(out, key, float(val)) + elif key in ("bold", "italic", "all_caps", "underline", + "page_break_before", "keep_with_next", "widow_control"): + setattr(out, key, bool(val)) + else: + setattr(out, key, val) + return out + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {} + for f in fields(self): + val = getattr(self, f.name) + if val is not None: + out[f.name] = val + return out + + +@dataclass +class StyleConfig: + page: PageSpec = field(default_factory=PageSpec) + styles: dict[str, ParagraphStyleSpec] = field(default_factory=dict) + + def merge(self, overlay: "StyleConfig | None") -> "StyleConfig": + if overlay is None: + return copy.deepcopy(self) + page = self.page.merge(overlay.page) + styles = {name: copy.copy(spec) for name, spec in self.styles.items()} + for name, spec in overlay.styles.items(): + if name not in STYLE_NAMES: + raise StyleConfigError(f"Unknown style name: {name!r}") + if name in styles: + styles[name] = styles[name].merge(spec) + else: + styles[name] = copy.copy(spec) + return StyleConfig(page=page, styles=styles) + + def to_dict(self) -> dict[str, Any]: + return { + "page": self.page.to_dict(), + "styles": {name: spec.to_dict() for name, spec in self.styles.items()}, + } + + +def _ps(**kwargs) -> ParagraphStyleSpec: + return ParagraphStyleSpec(**kwargs) + + +def _common_body_and_misc() -> dict[str, ParagraphStyleSpec]: + """Shared Normal + captions/misc (identical for mirea and pis_custom).""" + return { + "Normal": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + italic=False, + alignment="justify", + first_line_indent_cm=1.25, + left_indent_cm=0, + right_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.5, + widow_control=True, + ), + "Caption Figure": _ps( + font_name="Times New Roman", + size_pt=12, + bold=True, + italic=False, + alignment="center", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=0, + space_after_mm=6, + line_spacing=1.0, + widow_control=True, + ), + "Caption Table": _ps( + font_name="Times New Roman", + size_pt=12, + bold=False, + italic=True, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=6, + space_after_mm=0, + line_spacing=1.0, + keep_with_next=True, + widow_control=True, + ), + "Название таблицы": _ps( + font_name="Times New Roman", + size_pt=12, + bold=False, + italic=True, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=6, + space_after_mm=0, + line_spacing=1.0, + keep_with_next=True, + widow_control=True, + ), + "Caption Listing": _ps( + font_name="Times New Roman", + size_pt=12, + bold=False, + italic=True, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=6, + space_after_mm=0, + line_spacing=1.0, + keep_with_next=True, + widow_control=True, + ), + "Caption": _ps( + font_name="Times New Roman", + size_pt=12, + bold=True, + italic=False, + alignment="center", + first_line_indent_cm=0, + space_before_mm=0, + space_after_mm=6, + line_spacing=1.0, + ), + "Code": _ps( + font_name="Courier New", + size_pt=10, + bold=False, + italic=False, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.0, + ), + "Table Text": _ps( + font_name="Times New Roman", + size_pt=12, + bold=False, + italic=False, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.0, + ), + "Bibliography": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + italic=False, + alignment="justify", + first_line_indent_cm=1.25, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.5, + ), + "Bibliography Heading": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + italic=False, + all_caps=True, + alignment="center", + first_line_indent_cm=0, + left_indent_cm=1.25, + space_before_mm=6, + space_after_mm=6, + line_spacing=1.5, + keep_with_next=True, + ), + "toc 1": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + all_caps=True, + alignment="left", + first_line_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.5, + ), + "toc 2": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + all_caps=False, + alignment="left", + first_line_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.5, + ), + "toc 3": _ps( + font_name="Times New Roman", + size_pt=14, + bold=False, + all_caps=False, + alignment="left", + first_line_indent_cm=0, + space_before_mm=0, + space_after_mm=0, + line_spacing=1.5, + ), + "Footer": _ps( + font_name="Times New Roman", + size_pt=12, + alignment="center", + first_line_indent_cm=0, + ), + "Hyperlink": _ps( + font_name="Times New Roman", + size_pt=14, + underline=False, + ), + "FollowedHyperlink": _ps( + font_name="Times New Roman", + size_pt=14, + underline=False, + ), + "Space After Table": _ps( + first_line_indent_cm=1.25, + space_before_mm=6, + space_after_mm=0, + line_spacing=1.5, + ), + } + + +def preset_mirea() -> StyleConfig: + styles = _common_body_and_misc() + styles["Heading 1"] = _ps( + font_name="Times New Roman", + size_pt=18, + bold=True, + all_caps=True, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=1.25, + right_indent_cm=0, + space_before_mm=0, + space_after_mm=10, + line_spacing=1.5, + page_break_before=True, + keep_with_next=True, + widow_control=True, + ) + styles["Heading 2"] = _ps( + font_name="Times New Roman", + size_pt=16, + bold=True, + all_caps=False, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=1.25, + right_indent_cm=0, + space_before_mm=15, + space_after_mm=10, + line_spacing=1.5, + page_break_before=False, + keep_with_next=True, + widow_control=True, + ) + styles["Heading 3"] = _ps( + font_name="Times New Roman", + size_pt=14, + bold=True, + all_caps=False, + alignment="left", + first_line_indent_cm=0, + left_indent_cm=1.25, + right_indent_cm=0, + space_before_mm=15, + space_after_mm=10, + line_spacing=1.5, + page_break_before=False, + keep_with_next=True, + widow_control=True, + ) + return StyleConfig( + page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20), + styles=styles, + ) + + +def preset_pis_custom() -> StyleConfig: + styles = _common_body_and_misc() + styles["Heading 1"] = _ps( + font_name="Times New Roman", + size_pt=14, + bold=True, + all_caps=True, + alignment="center", + first_line_indent_cm=0, + left_indent_cm=0, + right_indent_cm=0, + space_before_mm=0, + space_after_mm=10, + line_spacing=1.5, + page_break_before=True, + keep_with_next=True, + widow_control=True, + ) + styles["Heading 2"] = _ps( + font_name="Times New Roman", + size_pt=14, + bold=True, + all_caps=False, + alignment="justify", + first_line_indent_cm=1.25, + left_indent_cm=0, + right_indent_cm=0, + space_before_mm=15, + space_after_mm=10, + line_spacing=1.5, + page_break_before=False, + keep_with_next=True, + widow_control=True, + ) + styles["Heading 3"] = _ps( + font_name="Times New Roman", + size_pt=14, + bold=True, + all_caps=False, + alignment="justify", + first_line_indent_cm=1.25, + left_indent_cm=0, + right_indent_cm=0, + space_before_mm=10, + space_after_mm=10, + line_spacing=1.5, + page_break_before=False, + keep_with_next=True, + widow_control=True, + ) + return StyleConfig( + page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20), + styles=styles, + ) + + +PRESETS: dict[str, Callable[[], StyleConfig]] = { + "mirea": preset_mirea, + "pis_custom": preset_pis_custom, +} + + +def get_preset(name: str) -> StyleConfig: + factory = PRESETS.get(name, preset_mirea) + return factory() + + +def style_config_from_dict(data: dict[str, Any]) -> StyleConfig: + if not isinstance(data, dict): + raise StyleConfigError("Styles JSON root must be an object") + unknown = set(data.keys()) - TOP_LEVEL_KEYS + if unknown: + raise StyleConfigError(f"Unknown top-level key(s): {sorted(unknown)}") + + page = PageSpec() + if "page" in data: + raw_page = data["page"] + if not isinstance(raw_page, dict): + raise StyleConfigError("'page' must be an object") + page = PageSpec().merge(raw_page) + + styles: dict[str, ParagraphStyleSpec] = {} + if "styles" in data: + raw_styles = data["styles"] + if not isinstance(raw_styles, dict): + raise StyleConfigError("'styles' must be an object") + for name, raw_spec in raw_styles.items(): + if name not in STYLE_NAMES: + raise StyleConfigError(f"Unknown style name: {name!r}") + if not isinstance(raw_spec, dict): + raise StyleConfigError(f"Style {name!r} must be an object") + styles[name] = ParagraphStyleSpec().merge(raw_spec) + + return StyleConfig(page=page, styles=styles) + + +def load_styles_file(path: Path) -> StyleConfig: + if not path.is_file(): + raise StyleConfigError(f"Styles file not found: {path}") + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise StyleConfigError(f"Cannot read styles {path}: {exc}") from exc + if not isinstance(raw, dict): + raise StyleConfigError(f"Styles JSON root must be an object: {path}") + return style_config_from_dict(raw) + + +def resolve_style_config( + style_preset: str, + *, + md_dir: str | Path | None = None, + styles_path: str | Path | None = None, +) -> StyleConfig: + """Merge preset ← near-md file ← explicit --styles path.""" + config = get_preset(style_preset) + + if md_dir: + near = Path(md_dir) / STYLES_FILENAME + if near.is_file(): + try: + config = config.merge(load_styles_file(near)) + _log.info("Applied styles overlay: %s", near) + except StyleConfigError: + raise + + if styles_path: + path = Path(styles_path) + config = config.merge(load_styles_file(path)) + _log.info("Applied styles overlay: %s", path) + + return config diff --git a/md2gost/styles.py b/md2gost/styles.py index 8d42ef3..667499d 100644 --- a/md2gost/styles.py +++ b/md2gost/styles.py @@ -1,5 +1,7 @@ """Apply MIREA TZ (GOST 7.32 / методичка 2022) paragraph styles to a Document.""" +from __future__ import annotations + from docx.document import Document from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER @@ -7,6 +9,32 @@ from docx.oxml.ns import qn from docx.shared import Cm, Mm, Pt from docx.styles.style import _ParagraphStyle as ParagraphStyle +from .style_config import ( + ParagraphStyleSpec, + StyleConfig, + get_preset, + resolve_style_config, +) + +_ALIGN = { + "left": WD_ALIGN_PARAGRAPH.LEFT, + "center": WD_ALIGN_PARAGRAPH.CENTER, + "justify": WD_ALIGN_PARAGRAPH.JUSTIFY, +} + +# Styles that may be missing from Template.docx and need ensure + base +_ENSURE_BASE = { + "Caption Figure": "Caption", + "Caption Table": "Caption", + "Название таблицы": "Caption Table", + "Caption Listing": "Caption", + "Code": "Normal", + "Table Text": "Normal", + "Bibliography": "Normal", + "Bibliography Heading": "Normal", + "Space After Table": "Normal", +} + def _set_run_font(style: ParagraphStyle, name: str, size_pt: float, bold: bool = False, italic: bool = False): """Lock typeface/size/color so Word theme (Calibri + accent blue) cannot leak.""" @@ -55,6 +83,18 @@ def _ensure_style(document: Document, name: str, base: str = "Normal") -> Paragr return style +def _get_or_ensure(document: Document, name: str) -> ParagraphStyle | None: + base = _ENSURE_BASE.get(name) + if base is not None: + return _ensure_style(document, name, base) + try: + return document.styles[name] + except KeyError: + if name.startswith("toc "): + return _ensure_style(document, name, "Normal") + return None + + def _clear_tab_stops(style: ParagraphStyle) -> None: pPr = style.element.get_or_add_pPr() tabs = pPr.find(qn("w:tabs")) @@ -88,249 +128,135 @@ def _fix_toc_tab_stops(document: Document) -> None: ) -def apply_mirea_styles(document: Document) -> None: - """Mutate section margins and key paragraph styles to match the MIREA method guide.""" - _apply_common_page_and_body(document) +def _apply_paragraph_spec(style, spec: ParagraphStyleSpec) -> None: + need_font = ( + spec.font_name is not None + or spec.size_pt is not None + or spec.bold is not None + or spec.italic is not None + ) + if need_font: + name = spec.font_name if spec.font_name is not None else (style.font.name or "Times New Roman") + size = spec.size_pt if spec.size_pt is not None else ( + style.font.size.pt if style.font.size else 14 + ) + bold = bool(spec.bold) if spec.bold is not None else bool(style.font.bold) + italic = bool(spec.italic) if spec.italic is not None else bool(style.font.italic) + _set_run_font(style, name, size, bold=bold, italic=italic) - # --- Headings (табл. 2.1): слева с отступом 1,25 см --- - heading_specs = [ - (1, 18, Mm(0), Mm(10), True), - (2, 16, Mm(15), Mm(10), False), - (3, 14, Mm(15), Mm(10), False), - ] - for level, size, before, after, page_break in heading_specs: - style: ParagraphStyle = document.styles[f"Heading {level}"] - _set_run_font(style, "Times New Roman", size, bold=True) - if level == 1: - style.font.all_caps = True + if spec.all_caps is not None: + style.font.all_caps = spec.all_caps + if spec.underline is not None: + style.font.underline = spec.underline + + # Character styles (Hyperlink, …) have no paragraph_format + if style.type != WD_STYLE_TYPE.PARAGRAPH: + return + + pf = style.paragraph_format + if spec.alignment is not None: + pf.alignment = _ALIGN[spec.alignment] + if spec.first_line_indent_cm is not None: + pf.first_line_indent = Cm(spec.first_line_indent_cm) + if spec.left_indent_cm is not None: + pf.left_indent = Cm(spec.left_indent_cm) + if spec.right_indent_cm is not None: + pf.right_indent = Cm(spec.right_indent_cm) + if spec.space_before_mm is not None: + pf.space_before = Mm(spec.space_before_mm) + if spec.space_after_mm is not None: + pf.space_after = Mm(spec.space_after_mm) + if spec.line_spacing is not None: + if spec.line_spacing == 1.5: + pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE else: - style.font.all_caps = False - hpf = style.paragraph_format - hpf.alignment = WD_ALIGN_PARAGRAPH.LEFT - hpf.first_line_indent = Cm(0) - hpf.left_indent = Cm(1.25) - hpf.right_indent = Cm(0) - hpf.space_before = before - hpf.space_after = after - hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - hpf.page_break_before = page_break - hpf.keep_with_next = True - hpf.widow_control = True - - _apply_common_captions_and_misc(document) + pf.line_spacing_rule = WD_LINE_SPACING.SINGLE + if spec.page_break_before is not None: + pf.page_break_before = spec.page_break_before + if spec.keep_with_next is not None: + pf.keep_with_next = spec.keep_with_next + if spec.widow_control is not None: + pf.widow_control = spec.widow_control -def apply_pis_custom_styles(document: Document) -> None: - """Styles for PIS_custom: итоговый отчёт по практическим работам. +def _apply_page_margins(config: StyleConfig) -> None: + from . import page_geometry as pg - * H1 (разделы / практические работы): по центру, ПРОПИСНЫЕ, без точки. - * H2 (подразделы): с абзацного отступа 1,25 см, с прописной буквы. - * Поля / шрифт / интервал / красная строка — как в чек-листе ПИС (= ГОСТ поля). - """ - _apply_common_page_and_body(document) - - # H1 — раздел: центр, caps, с новой страницы - h1: ParagraphStyle = document.styles["Heading 1"] - _set_run_font(h1, "Times New Roman", 14, bold=True) - h1.font.all_caps = True - h1pf = h1.paragraph_format - h1pf.alignment = WD_ALIGN_PARAGRAPH.CENTER - h1pf.first_line_indent = Cm(0) - h1pf.left_indent = Cm(0) - h1pf.right_indent = Cm(0) - h1pf.space_before = Mm(0) - h1pf.space_after = Mm(10) - h1pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - h1pf.page_break_before = True - h1pf.keep_with_next = True - h1pf.widow_control = True - - # H2 / H3 — подразделы: абзацный отступ, не caps - for level, size, before in ((2, 14, Mm(15)), (3, 14, Mm(10))): - style = document.styles[f"Heading {level}"] - _set_run_font(style, "Times New Roman", size, bold=True) - style.font.all_caps = False - hpf = style.paragraph_format - hpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY - hpf.first_line_indent = Cm(1.25) - hpf.left_indent = Cm(0) - hpf.right_indent = Cm(0) - hpf.space_before = before - hpf.space_after = Mm(10) - hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - hpf.page_break_before = False - hpf.keep_with_next = True - hpf.widow_control = True - - _apply_common_captions_and_misc(document) + page = config.page.require_complete() + pg.MARGIN_LEFT = Mm(page.left_mm) + pg.MARGIN_RIGHT = Mm(page.right_mm) + pg.MARGIN_TOP = Mm(page.top_mm) + pg.MARGIN_BOTTOM = Mm(page.bottom_mm) -def apply_document_styles(document: Document, style_preset: str = "mirea") -> None: - if style_preset == "pis_custom": - apply_pis_custom_styles(document) - else: - apply_mirea_styles(document) - - -def _apply_common_page_and_body(document: Document) -> None: +def apply_style_config(document: Document, config: StyleConfig) -> None: + """Apply full StyleConfig (page margins + paragraph styles) to document.""" from .page_geometry import apply_section_geometry, is_landscape_section + _apply_page_margins(config) + for section in document.sections: - # Do not wipe landscape sections created for +landscape figures/tables. apply_section_geometry(section, landscape=is_landscape_section(section)) _fix_toc_tab_stops(document) - normal: ParagraphStyle = document.styles["Normal"] - _set_run_font(normal, "Times New Roman", 14) - pf = normal.paragraph_format - pf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY - pf.first_line_indent = Cm(1.25) - pf.left_indent = Cm(0) - pf.right_indent = Cm(0) - pf.space_before = Pt(0) - pf.space_after = Pt(0) - pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - pf.widow_control = True - - -def _apply_common_captions_and_misc(document: Document) -> None: - # --- Caption Figure: 12pt bold, center, under figure --- - caption_fig = _ensure_style(document, "Caption Figure", "Caption") - _set_run_font(caption_fig, "Times New Roman", 12, bold=True) - cpf = caption_fig.paragraph_format - cpf.alignment = WD_ALIGN_PARAGRAPH.CENTER - cpf.first_line_indent = Cm(0) - cpf.left_indent = Cm(0) - cpf.space_before = Mm(0) - cpf.space_after = Mm(6) - cpf.line_spacing_rule = WD_LINE_SPACING.SINGLE - cpf.widow_control = True - - # --- Caption Table (legacy EN name) + «Название таблицы» (основной) --- - caption_tbl = _ensure_style(document, "Caption Table", "Caption") - _set_run_font(caption_tbl, "Times New Roman", 12, italic=True) - tpf = caption_tbl.paragraph_format - tpf.alignment = WD_ALIGN_PARAGRAPH.LEFT - tpf.first_line_indent = Cm(0) - tpf.left_indent = Cm(0) - tpf.space_before = Mm(6) - tpf.space_after = Mm(0) - tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE - tpf.keep_with_next = True - tpf.widow_control = True - - # ГОСТ-имя стиля подписи таблицы (те же параметры, что Caption Table) - caption_tbl_ru = _ensure_style(document, "Название таблицы", "Caption Table") - _set_run_font(caption_tbl_ru, "Times New Roman", 12, italic=True) - tpf_ru = caption_tbl_ru.paragraph_format - tpf_ru.alignment = WD_ALIGN_PARAGRAPH.LEFT - tpf_ru.first_line_indent = Cm(0) - tpf_ru.left_indent = Cm(0) - tpf_ru.space_before = Mm(6) - tpf_ru.space_after = Mm(0) - tpf_ru.line_spacing_rule = WD_LINE_SPACING.SINGLE - tpf_ru.keep_with_next = True - tpf_ru.widow_control = True - - # --- Caption Listing (как таблицы) --- - caption_lst = _ensure_style(document, "Caption Listing", "Caption") - _set_run_font(caption_lst, "Times New Roman", 12, italic=True) - lpf = caption_lst.paragraph_format - lpf.alignment = WD_ALIGN_PARAGRAPH.LEFT - lpf.first_line_indent = Cm(0) - lpf.left_indent = Cm(0) - lpf.space_before = Mm(6) - lpf.space_after = Mm(0) - lpf.line_spacing_rule = WD_LINE_SPACING.SINGLE - lpf.keep_with_next = True - lpf.widow_control = True - - caption = document.styles["Caption"] - _set_run_font(caption, "Times New Roman", 12, bold=True) - capf = caption.paragraph_format - capf.alignment = WD_ALIGN_PARAGRAPH.CENTER - capf.first_line_indent = Cm(0) - capf.space_before = Mm(0) - capf.space_after = Mm(6) - capf.line_spacing_rule = WD_LINE_SPACING.SINGLE - - code = _ensure_style(document, "Code", "Normal") - _set_run_font(code, "Courier New", 10) - cdpf = code.paragraph_format - cdpf.alignment = WD_ALIGN_PARAGRAPH.LEFT - cdpf.first_line_indent = Cm(0) - cdpf.left_indent = Cm(0) - cdpf.space_before = Mm(0) - cdpf.space_after = Mm(0) - cdpf.line_spacing_rule = WD_LINE_SPACING.SINGLE - - table_text = _ensure_style(document, "Table Text", "Normal") - _set_run_font(table_text, "Times New Roman", 12) - ttf = table_text.paragraph_format - ttf.alignment = WD_ALIGN_PARAGRAPH.LEFT - ttf.first_line_indent = Cm(0) - ttf.left_indent = Cm(0) - ttf.space_before = Mm(0) - ttf.space_after = Mm(0) - ttf.line_spacing_rule = WD_LINE_SPACING.SINGLE - - biblio = _ensure_style(document, "Bibliography", "Normal") - _set_run_font(biblio, "Times New Roman", 14) - bpf = biblio.paragraph_format - bpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY - bpf.first_line_indent = Cm(1.25) - bpf.space_before = Pt(0) - bpf.space_after = Pt(0) - bpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - - biblio_h = _ensure_style(document, "Bibliography Heading", "Normal") - _set_run_font(biblio_h, "Times New Roman", 14) - biblio_h.font.all_caps = True - bhpf = biblio_h.paragraph_format - bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER - bhpf.first_line_indent = Cm(0) - bhpf.left_indent = Cm(1.25) # табл. 5.1 - bhpf.space_before = Mm(6) - bhpf.space_after = Mm(6) - bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - bhpf.keep_with_next = True - - # TOC styles: TNR 14, 1.5, no bold, no first-line indent; toc 1 = ALL CAPS - for toc_name, all_caps in (("toc 1", True), ("toc 2", False), ("toc 3", False)): - try: - toc_style = document.styles[toc_name] - except KeyError: - toc_style = _ensure_style(document, toc_name, "Normal") - _set_run_font(toc_style, "Times New Roman", 14, bold=False) - toc_style.font.all_caps = all_caps - toc_style.font.bold = False - tpf_toc = toc_style.paragraph_format - tpf_toc.alignment = WD_ALIGN_PARAGRAPH.LEFT - tpf_toc.first_line_indent = Cm(0) - tpf_toc.space_before = Pt(0) - tpf_toc.space_after = Pt(0) - tpf_toc.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE - - try: - footer_style = document.styles["Footer"] - _set_run_font(footer_style, "Times New Roman", 12) - footer_style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER - footer_style.paragraph_format.first_line_indent = Cm(0) - except KeyError: - pass - - for hyper_name in ("Hyperlink", "FollowedHyperlink"): - try: - hyper = document.styles[hyper_name] - except KeyError: + # Apply in a stable order: Normal first, then headings, then the rest + order = [ + "Normal", + "Heading 1", "Heading 2", "Heading 3", + "Caption Figure", "Caption Table", "Название таблицы", "Caption Listing", "Caption", + "Code", "Table Text", + "Bibliography", "Bibliography Heading", + "toc 1", "toc 2", "toc 3", + "Footer", "Hyperlink", "FollowedHyperlink", + "Space After Table", + ] + applied = set() + for name in order: + spec = config.styles.get(name) + if spec is None: continue - _set_run_font(hyper, "Times New Roman", 14) - hyper.font.underline = False + style = _get_or_ensure(document, name) + if style is None: + continue + _apply_paragraph_spec(style, spec) + applied.add(name) - after = _ensure_style(document, "Space After Table", "Normal") - apf = after.paragraph_format - apf.space_before = Mm(6) - apf.space_after = Pt(0) - apf.first_line_indent = Cm(1.25) - apf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE + for name, spec in config.styles.items(): + if name in applied: + continue + style = _get_or_ensure(document, name) + if style is None: + continue + _apply_paragraph_spec(style, spec) + + +def apply_mirea_styles(document: Document) -> None: + """Mutate section margins and key paragraph styles to match the MIREA method guide.""" + apply_style_config(document, get_preset("mirea")) + + +def apply_pis_custom_styles(document: Document) -> None: + """Styles for PIS_custom: итоговый отчёт по практическим работам.""" + apply_style_config(document, get_preset("pis_custom")) + + +def apply_document_styles( + document: Document, + style_preset: str = "mirea", + *, + overlay: StyleConfig | None = None, + md_dir: str | None = None, + styles_path: str | None = None, +) -> StyleConfig: + """Apply preset (+ optional JSON overlays). Returns the resolved StyleConfig.""" + if overlay is not None: + config = get_preset(style_preset).merge(overlay) + else: + config = resolve_style_config( + style_preset, + md_dir=md_dir, + styles_path=styles_path, + ) + apply_style_config(document, config) + return config diff --git a/prompts/README.md b/prompts/README.md index 20622bb..63b2155 100644 --- a/prompts/README.md +++ b/prompts/README.md @@ -1,22 +1,27 @@ # Промпты для генерации учебных работ -## Основной файл +## Файлы -[`generate-mirea-report.md`](generate-mirea-report.md) — полный системный промпт (ГОСТ / МИРЭА). -[`generate-pis-custom-report.md`](generate-pis-custom-report.md) — итоговый отчёт ПИС. +| Файл | Назначение | +|------|------------| +| [`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) | Итоговый отчёт ПИС | + +В GUI (**Справка → Промпт для ИИ**) можно дописать к любому промпту выбранные UML-схемы из `md2gost.schemes.json`. ### Как использовать -1. Открой файл, скопируй всё начиная с роли («Ты — оформитель…») или весь файл. +1. Открой файл или GUI, скопируй промпт (в GUI — после выбора схем). 2. Вставь в: - **ChatGPT / Claude** — Custom instructions / первое сообщение; - **Cursor** — Rules for AI / сообщение агенту; - **GitHub Copilot Chat** — контекст чата. -3. Следующим сообщением задай: тип (`coursework` | `practice` | `vkr`), тему, черновик. +3. Следующим сообщением задай: тип (`coursework` | `practice` | `vkr` | `PIS_custom` | …), тему, черновик. 4. Полученный `.md` проверь и сконвертируй: ```bash python -m md2gost report.md -o report.docx --type coursework --check --strict ``` -Каноническая копия промпта — файл в репозитории [`prompts/generate-mirea-report.md`](generate-mirea-report.md). +Подробнее: [`docs/prompts.md`](../docs/prompts.md). diff --git a/prompts/generate-md.md b/prompts/generate-md.md new file mode 100644 index 0000000..fae33f3 --- /dev/null +++ b/prompts/generate-md.md @@ -0,0 +1,116 @@ +# Промпт: Markdown для md2gost + +Скопируйте **весь** текст ниже в системный промпт / первое сообщение. В GUI можно дописать выбранные UML-схемы кнопками — они добавятся в конец. + +В следующем сообщении укажите тип работы, тему и черновик / факты. + +--- + +Ты оформляешь учебный текст в **диалекте Markdown для конвертера md2gost** (Markdown → DOCX по ГОСТ / ТЗ МИРЭА и родственным профилям). + +## Зачем не обычный Markdown + +Обычный MD описывает структуру текста, но не несёт: + +- спецразделов без номера («ВВЕДЕНИЕ», «ЗАКЛЮЧЕНИЕ»); +- подписей «Рисунок 1.1 — …» / «Таблица …» / «Листинг …»; +- перекрёстных ссылок на объекты; +- библиографии в виде `[n]: …` с правилами ГОСТ; +- диаграмм, которые конвертер сам превратит в Рисунок. + +md2gost читает расширения ниже, нумерует объекты и собирает DOCX. Твоя задача — выдать **один готовый `.md`**, а не Word и не PNG. + +## Выход + +- Только markdown документа. +- Без пояснений до и после, без обёртки в ```markdown```, без HTML/DOCX. + +## Что писать как в обычном MD + +Заголовки `#`…, абзацы, списки, таблицы `|…|`, картинки `![]()`, блоки кода, формулы `$$…$$`. + +## Что писать иначе (обязательно) + +### Спецразделы + +```markdown +# *СОДЕРЖАНИЕ +[TOC] + +# *ВВЕДЕНИЕ + +# 1 Название раздела +## 1.1 Подраздел + +# *ЗАКЛЮЧЕНИЕ + +# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ + +# *ПРИЛОЖЕНИЯ +``` + +Звёздочка `*` = раздел без номера. ПРОПИСНЫЕ. Точку в конце заголовка не ставить. После СОДЕРЖАНИЕ — `[TOC]`. + +Для отчёта ПИС (`PIS_custom`) H1 работ без `*`: `# Практическая работа №N. Название`. + +### Подписи и ссылки + +- Рисунок-файл: `![…](file.png "%id Подпись")` + в тексте `@Рисунок:id` +- Таблица / листинг / диаграмма: строка `%id Подпись` перед объектом + `@Таблица:id` / `@Листинг:id` / `@Рисунок:id` +- Формула: `%eq1` + `$$…$$` + `@Формула:eq1` (номер только если есть ссылка) + +В тексте: **«Рисунок»**, **«Таблица»**, **«Листинг»** — не «рис.» / «табл.» + +Склеивание ячеек таблицы: `^` (rowspan сверху), `>` (colspan слева). Без графы «№ п/п». + +Флаги в строке `%`: `+listing` (ещё Листинг с исходником), `+landscape` (альбомная страница). + +### Источники + +В тексте: `[1]`. В списке: `[1]: Автор. Название. — …`. +Во введении и заключении ссылок `[n]` не должно быть. + +### Запрещено + +Сноски `[^1]`, «рис.», «табл.», `# ВВЕДЕНИЕ` без `*`, пустые подписи. + +## Диаграммы: конвертер рисует сам + +**Не** экспортируй UML/Mermaid в PNG вручную и **не** пиши «вставь картинку сюда». + +В `.md` оставь fenced-блок — md2gost превратит его в **Рисунок** (PlantUML jar / Kroki; Mermaid — через Kroki). + +| Оградка | Содержание тела | +|---------|-----------------| +| `` ```uml `` / `` ```plantuml `` | Полный PlantUML, обычно с `@startuml`…`@enduml` | +| `` ```uml- `` / `` ``` `` | Только макросы схемы (C4, BPMN, usecase…); **без** `@startuml` и **без** `!include` — схема добавит сама | +| `` ```mermaid `` / `` ```mmd `` | Синтаксис Mermaid | + +Перед блоком — `%id Подпись` (и ссылка `@Рисунок:id` в тексте). + +**IDEF0** конвертер не рисует — только готовый PNG как обычный Рисунок. + +Если в конец этого промпта добавлены блоки «Схема: …» — используй их макросы и оградки. Если блоков нет — для C4/BPMN/usecase всё равно можно писать `` ```uml-c4 ``, `` ```bpmn ``, `` ```usecase `` с типичными макросами; детали пользователь может дописать из GUI. + +Пример схемы: + +~~~markdown +См. @Рисунок:arch1. + +%arch1 Контейнеры системы + +```uml-c4 +Person(user, "Студент") +System(app, "Портал") +Rel(user, app, "логин") +``` +~~~ + +## Контроль перед выдачей + +1. Структура соответствует типу работы (МИРЭА / ПИС / иное — из входа пользователя). +2. У каждого `%id` / title картинки есть `@Тип:id` в тексте. +3. Диаграммы — fence в md, не внешние PNG (кроме IDEF0 и готовых иллюстраций). +4. Нет «рис.» и ссылок `[n]` во введении/заключении. + +После проверки выдай только markdown. diff --git a/pyproject.toml b/pyproject.toml index aa674ea..7255879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,17 @@ packages = ["md2gost", "md2latex", "md2fodt"] "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" +"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/types.md" = "md2gost/docs/types.md" +"docs/cli-gui.md" = "md2gost/docs/cli-gui.md" +"docs/styles.md" = "md2gost/docs/styles.md" +"docs/prompts.md" = "md2gost/docs/prompts.md" # Legacy Poetry metadata (optional; prefer uv + [project] above) [tool.poetry] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 304b204..b6c0e32 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -153,11 +153,49 @@ def test_prompt_catalog_loads(): from md2gost.help_content import load_prompt_catalog catalog = load_prompt_catalog() names = {name for name, _title, text in catalog} + assert "generate-md.md" in names assert "generate-mirea-report.md" in names assert "generate-pis-custom-report.md" in names assert all(text.strip() for _n, _t, text in catalog) +def test_compose_prompt_with_schemes(): + from md2gost.diagram_schemes import DiagramScheme + from md2gost.help_content import compose_prompt, scheme_prompt_block + + scheme = DiagramScheme( + id="c4", + title="C4 Container", + docs='Person(alias, "Label")', + ai_prompt="Рисуй C4. Блок ```uml-c4 без @startuml.", + ) + block = scheme_prompt_block(scheme) + assert "uml-c4" in block + assert "```c4" in block or "```uml-c4" in block + assert "Рисуй C4" in block + assert "Person(alias" in block + + base = "Базовый промпт про markdown.\n" + out = compose_prompt(base, [scheme]) + assert out.startswith("Базовый промпт") + assert "Дополнение: выбранные схемы" in out + assert "uml-c4" in out + assert compose_prompt(base, []).strip() == base.strip() + assert compose_prompt(base, None).strip() == base.strip() + + +def test_docs_catalog_loads(): + from md2gost.help_content import load_docs_catalog + catalog = load_docs_catalog() + assert catalog, "docs/ catalog should not be empty" + names = {name for name, _title, _text in catalog} + assert "README.md" in names + assert "markdown.md" in names + assert catalog[0][0].lower() == "readme.md" + assert "Документация" in catalog[0][1] + assert all(text.strip() for _n, _t, text in catalog) + + def test_listing_continuation_mode(): import os import docx diff --git a/tests/test_style_config.py b/tests/test_style_config.py new file mode 100644 index 0000000..ea26077 --- /dev/null +++ b/tests/test_style_config.py @@ -0,0 +1,114 @@ +"""Tests for JSON style overlay (md2gost.styles.json / --styles).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import docx +import pytest +from docx.enum.text import WD_ALIGN_PARAGRAPH + +from md2gost.style_config import ( + StyleConfigError, + get_preset, + resolve_style_config, + style_config_from_dict, +) +from md2gost.styles import apply_document_styles, apply_style_config +from md2gost import page_geometry as pg + + +TEMPLATE = Path("md2gost/Template.docx") + + +def test_preset_unchanged_without_overlay(): + cfg = resolve_style_config("mirea") + assert cfg.page.left_mm == 30 + assert cfg.page.right_mm == 10 + h1 = cfg.styles["Heading 1"] + assert h1.alignment == "left" + assert h1.size_pt == 18 + assert h1.all_caps is True + + +def test_overlay_changes_margins_and_h1(tmp_path: Path): + overlay = { + "page": {"left_mm": 20, "right_mm": 20}, + "styles": { + "Heading 1": { + "alignment": "center", + "size_pt": 14, + "left_indent_cm": 0, + } + }, + } + path = tmp_path / "custom.json" + path.write_text(json.dumps(overlay), encoding="utf-8") + + cfg = resolve_style_config("mirea", styles_path=path) + assert cfg.page.left_mm == 20 + assert cfg.page.right_mm == 20 + # Unspecified margins stay from preset + assert cfg.page.top_mm == 20 + assert cfg.page.bottom_mm == 20 + assert cfg.styles["Heading 1"].alignment == "center" + assert cfg.styles["Heading 1"].size_pt == 14 + # Unspecified heading fields stay from preset + assert cfg.styles["Heading 1"].all_caps is True + assert cfg.styles["Heading 1"].bold is True + + +def test_unknown_style_name_raises(): + with pytest.raises(StyleConfigError, match="Unknown style name"): + style_config_from_dict({"styles": {"Heading 9": {"size_pt": 12}}}) + + +def test_unknown_page_key_raises(): + with pytest.raises(StyleConfigError, match="Unknown page key"): + style_config_from_dict({"page": {"gutter_mm": 5}}) + + +def test_unknown_style_field_raises(): + with pytest.raises(StyleConfigError, match="Unknown style field"): + style_config_from_dict({"styles": {"Normal": {"color": "red"}}}) + + +def test_cli_overlay_wins_over_near_md(tmp_path: Path): + near = tmp_path / "md2gost.styles.json" + near.write_text( + json.dumps({"page": {"left_mm": 25}, "styles": {"Heading 1": {"size_pt": 16}}}), + encoding="utf-8", + ) + cli = tmp_path / "cli.json" + cli.write_text( + json.dumps({"page": {"left_mm": 15}, "styles": {"Heading 1": {"alignment": "center"}}}), + encoding="utf-8", + ) + cfg = resolve_style_config("mirea", md_dir=tmp_path, styles_path=cli) + assert cfg.page.left_mm == 15 + assert cfg.styles["Heading 1"].size_pt == 16 # from near-md, not overridden + assert cfg.styles["Heading 1"].alignment == "center" # from CLI + + +def test_apply_overlay_to_document(tmp_path: Path): + overlay = style_config_from_dict({ + "page": {"left_mm": 20, "right_mm": 20, "top_mm": 20, "bottom_mm": 20}, + "styles": {"Heading 1": {"alignment": "center", "size_pt": 14}}, + }) + doc = docx.Document(str(TEMPLATE)) + apply_document_styles(doc, "mirea", overlay=overlay) + h1 = doc.styles["Heading 1"] + assert h1.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER + assert abs(h1.font.size.pt - 14) < 0.01 + section = doc.sections[0] + assert abs(section.left_margin.mm - 20) < 0.1 + assert abs(pg.MARGIN_LEFT.mm - 20) < 0.1 + + +def test_pis_preset_h1_centered(): + doc = docx.Document(str(TEMPLATE)) + apply_style_config(doc, get_preset("pis_custom")) + h1 = doc.styles["Heading 1"] + assert h1.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER + assert h1.font.all_caps is True