- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
Binary file not shown.
+135
-13
@@ -10,6 +10,32 @@ poetry install
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
## GUI
|
||||
|
||||
Без файла или с `--gui` открывается окно: перетащите `.md`, выберите параметры, нажмите «Конвертировать». CLI при этом тот же (`python -m md2gost файл.md …`).
|
||||
|
||||
```bash
|
||||
python -m md2gost
|
||||
python -m md2gost --gui
|
||||
python -m md2gost --gui report.md --type PIS_custom
|
||||
md2gost-gui
|
||||
```
|
||||
|
||||
На Windows файл можно бросить из Проводника в верхнюю область окна. Клик по области — выбор через диалог. Все флаги CLI есть в форме (тип, нумерация, TOC, тире, `---` → разрыв страницы, титул/задание, диаграммы, проверка ТЗ).
|
||||
|
||||
Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (МИРЭА / ПИС) в буфер.
|
||||
|
||||
### Сборка exe (Windows)
|
||||
|
||||
Двойной клик по `build-exe.bat` в корне репозитория (нужен Python 3.10+ в PATH). Результат: `dist\md2gost.exe`.
|
||||
|
||||
```bat
|
||||
build-exe.bat
|
||||
build-exe.bat nopause
|
||||
```
|
||||
|
||||
Двойной клик по exe — GUI. CLI: `md2gost.exe report.md -o report.docx --type coursework`.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
@@ -31,7 +57,7 @@ python -m md2gost report.md -o report.docx --type PIS_custom --check
|
||||
# Титул: «Отчёт по практическим работам …» — отдельный DOCX:
|
||||
python -m md2gost report.md -o report.docx --type PIS_custom --title title.docx
|
||||
# Курсовая АПИД (источники 7–20, проверка пунктов 2.1–2.4):
|
||||
python -m md2gost report.md -o report.docx --type APID_coursework --check --no-emdash-to-hyphen --title title.docx --assignment assignment.docx
|
||||
python -m md2gost report.md -o report.docx --type APID_coursework --check --title title.docx --assignment assignment.docx
|
||||
```
|
||||
|
||||
Пример: [`examples/pis_custom.md`](../examples/pis_custom.md).
|
||||
@@ -57,8 +83,12 @@ python -m md2gost report.md -o report.docx --toc manual
|
||||
|
||||
### Тире (`--emdash-to-hyphen` / `--no-emdash-to-hyphen`)
|
||||
|
||||
По умолчанию типографское «—» заменяется на «-» (в тексте и подписях).
|
||||
Оставить длинное тире по ГОСТ: `--no-emdash-to-hyphen`.
|
||||
По умолчанию типографское «—» **сохраняется** (как в методичке: тире с пробелами, дефис в диапазонах).
|
||||
Заменить «—» на «-»: `--emdash-to-hyphen`.
|
||||
|
||||
### Разрыв страницы (`---` / `--hr-pagebreak`)
|
||||
|
||||
Строка `---` (также `***`, `___`) на отдельной строке по умолчанию **игнорируется**. Разрыв страницы: `--hr-pagebreak` или галочка в GUI.
|
||||
|
||||
## Синтаксис (кратко)
|
||||
|
||||
@@ -70,9 +100,10 @@ python -m md2gost report.md -o report.docx --toc manual
|
||||
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
|
||||
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
|
||||
| Листинг | `%id Подпись` перед code fence |
|
||||
| Диаграмма UML/BPMN/C4 | `%id Подпись` + ````uml` / ````bpmn` / ````c4` → PNG (Рисунок); `+listing` — ещё и Листинг |
|
||||
| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет |
|
||||
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
|
||||
| Источник | `[1]` в тексте; `[1]: …` в списке |
|
||||
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
|
||||
|
||||
### Таблицы со склеиванием
|
||||
|
||||
@@ -98,22 +129,44 @@ python -m md2gost report.md -o report.docx --toc manual
|
||||
Разные шапки «первый раз / продолжение» есть в LaTeX (`longtable`), не в Office.
|
||||
|
||||
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
|
||||
mid-page «Продолжение…» (как было на 2.4). Поэтому по умолчанию таблицу **не режем**.
|
||||
mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save
|
||||
Word COM режет по реальной пагинации. Без Word — укажите `off` или поставьте Word + pywin32.
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`off`** / **`soft`** (по умолчанию) | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`) |
|
||||
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --table-continuation word
|
||||
python -m md2gost report.md -o report.docx --table-continuation off
|
||||
python -m md2gost report.md -o report.docx --table-continuation caption
|
||||
```
|
||||
|
||||
### Продолжение листинга (`--listing-continuation`)
|
||||
|
||||
Те же режимы, что у таблиц. По умолчанию **`word`**.
|
||||
|
||||
| Режим | Поведение |
|
||||
|--------|-----------|
|
||||
| **`word`** (по умолчанию) | После save Word COM + «Продолжение Листинга N» (Windows + Word + pywin32) |
|
||||
| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно |
|
||||
| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` |
|
||||
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» |
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --listing-continuation word
|
||||
python -m md2gost report.md -o report.docx --listing-continuation off
|
||||
python -m md2gost report.md -o report.docx --listing-continuation caption
|
||||
```
|
||||
|
||||
### Диаграммы
|
||||
|
||||
```markdown
|
||||
В отчёте пишите так (пример в 4 обратных кавычках, чтобы вложенный ` ```uml ` не ломал разметку):
|
||||
|
||||
````markdown
|
||||
%usecase1 Диаграмма прецедентов +listing
|
||||
|
||||
```uml
|
||||
@@ -122,19 +175,86 @@ actor Student
|
||||
Student --> (Login)
|
||||
@enduml
|
||||
```
|
||||
````
|
||||
|
||||
Широкая схема на альбомной странице — флаг `+landscape` в той же строке `%`:
|
||||
|
||||
%arch1 Архитектура +landscape
|
||||
|
||||
```uml-c4
|
||||
Person(user, "Студент")
|
||||
System(app, "Портал")
|
||||
```
|
||||
|
||||
Рендер (по приоритету):
|
||||
````markdown
|
||||
%arch1 Архитектура +landscape
|
||||
|
||||
1. `PLANTUML_JAR` / `--plantuml-jar` + Java → `plantuml.jar`
|
||||
2. `KROKI_URL` / `--kroki-url` (по умолчанию `http://localhost:8000`)
|
||||
3. remote `https://kroki.io` при `--diagram-fallback remote` (по умолчанию; предупреждение в лог)
|
||||
```uml-c4
|
||||
Person(user, "Студент")
|
||||
System(app, "Портал")
|
||||
```
|
||||
````
|
||||
|
||||
Кэш PNG: `{каталог_md}/.md2gost-cache/`.
|
||||
Схемы (`c4`, `usecase`, свои): при первом запуске рядом с приложением создаётся `md2gost.schemes.json`. В markdown — оградка `uml-<id>` или короткое `<id>`:
|
||||
````markdown
|
||||
%arch C4
|
||||
|
||||
```uml-c4
|
||||
Person(user, "Студент")
|
||||
System(app, "Портал")
|
||||
Rel(user, app, "логин")
|
||||
```
|
||||
````
|
||||
|
||||
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **BPMN 2.0** — оградка ````bpmn` / ````uml-bpmn` (макросы `Start`, `UserTask`, `XOR`, `Flow`, `Pool`…; библиотека `diagrams/BPMN.puml`). **Mermaid** — ````mermaid` / ````mmd` через тот же Kroki (свой `--kroki-url` или kroki.io); jar не используется. **IDEF0** конвертер не рисует — вставляйте готовый PNG.
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw, "ок?")
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
````markdown
|
||||
%bpmn1 Процесс заявки
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw, "ок?")
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
````
|
||||
|
||||
Обычному пользователю jar/Kroki указывать не нужно. Порядок для UML:
|
||||
|
||||
1. Вшитый / скачанный `plantuml.jar` + Java (exe кладёт jar внутрь; иначе качаем в `%LOCALAPPDATA%\md2gost\`)
|
||||
2. Локальный Kroki (`KROKI_URL` / `--kroki-url`, по умолчанию `http://localhost:8000`)
|
||||
3. Интернет `https://kroki.io` при `--diagram-fallback remote` (по умолчанию)
|
||||
|
||||
Mermaid всегда идёт через Kroki (шаги 2–3).
|
||||
|
||||
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML рендерится с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
|
||||
|
||||
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
|
||||
|
||||
Свой jar — только если нужен другой файл: `--plantuml-jar` или поле на вкладке «Диаграммы».
|
||||
|
||||
Кэш: `{каталог_md}/.md2gost-cache/` (`*.png`, при svg ещё `*.svg`).
|
||||
|
||||
```bash
|
||||
python -m md2gost report.md -o report.docx --plantuml-jar C:\tools\plantuml.jar
|
||||
python scripts/fetch_plantuml.py
|
||||
python -m md2gost report.md -o report.docx --diagram-fallback local
|
||||
python -m md2gost report.md -o report.docx --diagram-format svg
|
||||
```
|
||||
|
||||
Подробности и ИИ-промпт: [`prompts/`](../prompts/).
|
||||
@@ -144,3 +264,5 @@ PDF через LaTeX (XeLaTeX, шаблон МИРЭА): [`md2latex/README.md`](
|
||||
## Проверки
|
||||
|
||||
`--check` печатает замечания по структуре, «рис.», ссылкам во введении, числу/возрасту источников, приложениям и т.д. `--strict` завершает процесс с кодом 1 при ошибках.
|
||||
|
||||
`--check-pages` — пост-проверка полупустых страниц **через Microsoft Word** (Windows + Word + `pip install pywin32`). Все находки помечены как эвристика и **могут быть ложными**; не влияют на `--strict`. Можно вызвать для готового файла: `python -m md2gost report.docx --check-pages`. Макрос Word: [`scripts/check_page_fill.bas`](../scripts/check_page_fill.bas).
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
"""md2gost — Markdown → DOCX (MIREA TZ / GOST)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def package_dir() -> str:
|
||||
"""Directory of the md2gost package (Template.docx, mml2omml, etc.)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
meipass = getattr(sys, "_MEIPASS", None)
|
||||
if meipass:
|
||||
bundled = os.path.join(meipass, "md2gost")
|
||||
if os.path.isfile(os.path.join(bundled, "Template.docx")):
|
||||
return bundled
|
||||
if os.path.isfile(os.path.join(meipass, "Template.docx")):
|
||||
return meipass
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
+147
-105
@@ -1,41 +1,44 @@
|
||||
#!/usr/bin/env python
|
||||
from argparse import ArgumentParser, BooleanOptionalAction
|
||||
import os
|
||||
import os.path
|
||||
import sys
|
||||
from getpass import getuser
|
||||
|
||||
from docx import Document
|
||||
|
||||
from .converter import Converter
|
||||
from .pipeline import ConvertRequest, convert, should_launch_gui
|
||||
from .profiles import (
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
DEFAULT_LISTING_CONTINUATION,
|
||||
DEFAULT_TOC_MODE,
|
||||
DOC_TYPES,
|
||||
HEADING_NUMBERING_MODES,
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
TOC_MODES,
|
||||
DEFAULT_TOC_MODE,
|
||||
TABLE_CONTINUATION_MODES,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
get_profile,
|
||||
LISTING_CONTINUATION_MODES,
|
||||
TOC_MODES,
|
||||
)
|
||||
from .checker import check_markdown, format_report
|
||||
|
||||
|
||||
def main():
|
||||
def build_parser() -> ArgumentParser:
|
||||
parser = ArgumentParser(
|
||||
prog="md2gost",
|
||||
description=(
|
||||
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
|
||||
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
|
||||
"Без файла или с --gui открывается окно. "
|
||||
"FODT: python -m md2fodt …"
|
||||
),
|
||||
)
|
||||
parser.add_argument("filename", help="Путь до исходного markdown файла")
|
||||
parser.add_argument(
|
||||
"filename", nargs="?",
|
||||
help="Путь до .md (или .docx с --check-pages). Без файла открывается GUI",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gui", action="store_true",
|
||||
help="Открыть графический интерфейс (можно сразу передать .md)",
|
||||
)
|
||||
parser.add_argument("-o", "--output", help="Путь до сгенерированного .docx")
|
||||
parser.add_argument("-t", "--template", help="Путь до шаблона .docx")
|
||||
parser.add_argument(
|
||||
"--type", dest="doc_type", choices=DOC_TYPES, default="coursework",
|
||||
help="Тип: coursework | practice | vkr | PIS_custom | APID_coursework",
|
||||
"--type", dest="doc_type", choices=DOC_TYPES, default="practice",
|
||||
help="Тип: practice | coursework | vkr | PIS_custom | APID_coursework (по умолчанию practice)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--heading-numbering",
|
||||
@@ -64,19 +67,53 @@ def main():
|
||||
default=DEFAULT_TABLE_CONTINUATION,
|
||||
help=(
|
||||
"Таблицы длиннее страницы: "
|
||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение» (по умолчанию); "
|
||||
"word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» "
|
||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
||||
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
|
||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
|
||||
"(оценка ≠ Word, возможны артефакты). "
|
||||
f"По умолчанию: {DEFAULT_TABLE_CONTINUATION}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--listing-continuation",
|
||||
choices=LISTING_CONTINUATION_MODES,
|
||||
default=DEFAULT_LISTING_CONTINUATION,
|
||||
help=(
|
||||
"Листинги длиннее страницы: "
|
||||
"word — после сохранения Word COM + «Продолжение Листинга…» "
|
||||
"(по умолчанию; нужны Windows, Word, pywin32); "
|
||||
"off/soft — один блок, пагинация Word, без авто«Продолжение»; "
|
||||
"legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» "
|
||||
"(оценка ≠ Word, возможны артефакты). "
|
||||
f"По умолчанию: {DEFAULT_LISTING_CONTINUATION}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--table-repeat-header",
|
||||
action=BooleanOptionalAction,
|
||||
default=False,
|
||||
help=(
|
||||
"При --table-continuation word: повторять шапку таблицы на каждом фрагменте "
|
||||
"после разрыва. По умолчанию выкл."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--emdash-to-hyphen",
|
||||
action=BooleanOptionalAction,
|
||||
default=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Автозамена типографского тире «—» на дефис «-» в тексте и подписях. "
|
||||
"Включено по умолчанию; отключить: --no-emdash-to-hyphen."
|
||||
"По умолчанию выключено (тире «—» по методичке); включить: --emdash-to-hyphen."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hr-pagebreak",
|
||||
action=BooleanOptionalAction,
|
||||
default=False,
|
||||
help=(
|
||||
"Строка «---» / «***» / «___» — разрыв страницы Word. "
|
||||
"По умолчанию такие строки игнорируются; включить: --hr-pagebreak."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)")
|
||||
@@ -85,6 +122,15 @@ def main():
|
||||
action="store_true")
|
||||
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
|
||||
action="store_true")
|
||||
parser.add_argument(
|
||||
"--check-pages",
|
||||
help=(
|
||||
"После конвертации (или для готового .docx) проверить полупустые страницы "
|
||||
"через Microsoft Word. Эвристика — возможны ложные срабатывания. "
|
||||
"Нужны Windows, Word и pywin32."
|
||||
),
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument("--strict", help="Код выхода 1 при ошибках проверки",
|
||||
action="store_true")
|
||||
parser.add_argument("--syntax-highlighting", help="Подсветка синтаксиса в листингах",
|
||||
@@ -103,108 +149,104 @@ def main():
|
||||
default="remote",
|
||||
help="Если локальный рендер UML недоступен: remote (kroki.io), local (ошибка), off",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagram-format",
|
||||
choices=["png", "svg"],
|
||||
default="png",
|
||||
help="Формат схем в Word: png (по умолчанию) или svg (вектор + PNG-запасной, Word 2016+)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diagram-scale",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help="Масштаб рендера PlantUML PNG (качество); размер на странице как при 1. По умолчанию 2",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--schemes",
|
||||
dest="schemes_path",
|
||||
help="Путь к md2gost.schemes.json (иначе рядом с приложением / с .md)",
|
||||
)
|
||||
parser.add_argument("--debug", help="Добавляет отладочные данные в документ",
|
||||
action="store_true")
|
||||
return parser
|
||||
|
||||
args = parser.parse_args()
|
||||
filename, output, template, debug = \
|
||||
args.filename, args.output, args.template, args.debug
|
||||
if args.syntax_highlighting:
|
||||
os.environ["SYNTAX_HIGHLIGHTING"] = "1"
|
||||
|
||||
from .diagram_renderer import configure_diagrams
|
||||
configure_diagrams(
|
||||
plantuml_jar=args.plantuml_jar,
|
||||
kroki_url=args.kroki_url,
|
||||
fallback=args.diagram_fallback,
|
||||
)
|
||||
|
||||
if not filename.endswith(".md"):
|
||||
print("Error: filename must have md format")
|
||||
exit(1)
|
||||
|
||||
os.environ["WORKING_DIR"] = os.path.dirname(os.path.abspath(filename)) or "."
|
||||
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
md_text = f.read()
|
||||
|
||||
if args.check or args.check_only:
|
||||
issues = check_markdown(md_text, args.doc_type)
|
||||
print(format_report(issues))
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
if args.strict and errors:
|
||||
sys.exit(1)
|
||||
if args.check_only:
|
||||
sys.exit(0 if not errors else (1 if args.strict else 0))
|
||||
|
||||
if not output:
|
||||
output = os.path.basename(filename).replace(".md", ".docx")
|
||||
elif not output.endswith(".docx"):
|
||||
print("Error: output file must have docx format")
|
||||
exit(1)
|
||||
|
||||
if not template:
|
||||
from . import package_dir
|
||||
template = os.path.join(package_dir(), "Template.docx")
|
||||
|
||||
converter = Converter(
|
||||
filename, output, template, debug,
|
||||
def request_from_args(args) -> ConvertRequest:
|
||||
return ConvertRequest(
|
||||
filename=args.filename or "",
|
||||
output=args.output,
|
||||
template=args.template,
|
||||
doc_type=args.doc_type,
|
||||
heading_numbering=args.heading_numbering,
|
||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||
toc_mode=args.toc,
|
||||
table_continuation=args.table_continuation,
|
||||
listing_continuation=args.listing_continuation,
|
||||
table_repeat_header=bool(getattr(args, "table_repeat_header", False)),
|
||||
emdash_to_hyphen=args.emdash_to_hyphen,
|
||||
hr_pagebreak=args.hr_pagebreak,
|
||||
title=args.title,
|
||||
assignment=args.assignment,
|
||||
check=args.check,
|
||||
check_only=args.check_only,
|
||||
check_pages=bool(getattr(args, "check_pages", False)),
|
||||
strict=args.strict,
|
||||
syntax_highlighting=bool(args.syntax_highlighting),
|
||||
plantuml_jar=args.plantuml_jar,
|
||||
kroki_url=args.kroki_url,
|
||||
diagram_fallback=args.diagram_fallback,
|
||||
diagram_format=args.diagram_format,
|
||||
diagram_scale=float(args.diagram_scale),
|
||||
schemes_path=args.schemes_path,
|
||||
debug=args.debug,
|
||||
open_when_done=bool(args.debug),
|
||||
)
|
||||
converter.convert()
|
||||
|
||||
document = converter.document
|
||||
|
||||
# Front matter is appended *into* a shell that already has coursework styles.
|
||||
# Never use title.docx as compose base: python-docx default template has
|
||||
# Calibri + accent-blue headings and would override ГОСТ стили.
|
||||
if args.title or args.assignment:
|
||||
def _argv_needs_console(argv: list[str]) -> bool:
|
||||
"""Frozen windowed exe: attach a console for CLI / --help, not for GUI."""
|
||||
args = argv[1:]
|
||||
if not args:
|
||||
return False
|
||||
if "-h" in args or "--help" in args:
|
||||
return True
|
||||
if "--gui" in args:
|
||||
return False
|
||||
return any(not a.startswith("-") for a in args)
|
||||
|
||||
|
||||
def _enable_windows_console() -> None:
|
||||
if sys.platform != "win32" or not getattr(sys, "frozen", False):
|
||||
return
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
if not kernel32.AttachConsole(0xFFFFFFFF):
|
||||
if not kernel32.GetConsoleWindow():
|
||||
kernel32.AllocConsole()
|
||||
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace")
|
||||
sys.stderr = open("CONOUT$", "w", encoding="utf-8", errors="replace")
|
||||
try:
|
||||
from docxcompose.composer import Composer
|
||||
except ImportError:
|
||||
print("Error: docxcompose required for --title/--assignment")
|
||||
sys.exit(3)
|
||||
from .styles import apply_document_styles
|
||||
sys.stdin = open("CONIN$", "r", encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
shell = Document(template)
|
||||
apply_document_styles(shell, get_profile(args.doc_type).style_preset)
|
||||
body = shell.element.body
|
||||
for child in list(body):
|
||||
if not child.tag.endswith("}sectPr"):
|
||||
body.remove(child)
|
||||
|
||||
composer = Composer(shell)
|
||||
if args.title:
|
||||
composer.append(Document(args.title))
|
||||
shell.add_page_break()
|
||||
if args.assignment:
|
||||
composer.append(Document(args.assignment))
|
||||
shell.add_page_break()
|
||||
composer.append(document)
|
||||
document = composer.doc
|
||||
apply_document_styles(document, get_profile(args.doc_type).style_preset)
|
||||
def main():
|
||||
if _argv_needs_console(sys.argv):
|
||||
_enable_windows_console()
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
req = request_from_args(args)
|
||||
|
||||
document.core_properties.author = getuser()
|
||||
document.core_properties.comments = \
|
||||
"Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||
if should_launch_gui(args.filename, args.gui):
|
||||
from .gui import run_gui
|
||||
run_gui(req)
|
||||
return
|
||||
|
||||
document.save(output)
|
||||
print(f"Generated document: {os.path.abspath(output)}")
|
||||
|
||||
if debug:
|
||||
import platform
|
||||
if platform.system() == 'Darwin':
|
||||
import subprocess
|
||||
subprocess.call(('open', output))
|
||||
elif platform.system() == 'Windows':
|
||||
os.startfile(output)
|
||||
else:
|
||||
import subprocess
|
||||
subprocess.call(('xdg-open', output))
|
||||
result = convert(req)
|
||||
sys.exit(result.exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+105
-14
@@ -258,12 +258,36 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
||||
|
||||
n = len(entries)
|
||||
if profile.sectional_biblio:
|
||||
# Count per ## section roughly
|
||||
if n < profile.min_sources:
|
||||
# Count per ## section inside bibliography
|
||||
sections: list[tuple[str, int]] = []
|
||||
current_title: str | None = None
|
||||
current_count = 0
|
||||
for line in block.splitlines():
|
||||
hm = re.match(r"^#{2,6}\s+(\*?)(.+)$", line)
|
||||
if hm:
|
||||
if current_title is not None:
|
||||
sections.append((current_title, current_count))
|
||||
current_title = hm.group(2).strip()
|
||||
current_count = 0
|
||||
continue
|
||||
if BIBLIO_LINE_RE.match(line.strip()):
|
||||
current_count += 1
|
||||
if current_title is not None:
|
||||
sections.append((current_title, current_count))
|
||||
|
||||
if sections:
|
||||
for title, cnt in sections:
|
||||
if cnt < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "warning",
|
||||
f"ВКР: в разделе списка «{title}» желательно ≥{profile.min_sources} "
|
||||
f"источников (сейчас {cnt})",
|
||||
))
|
||||
elif n < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "warning",
|
||||
f"ВКР: в каждом разделе списка желательно ≥{profile.min_sources} источников "
|
||||
f"(сейчас всего {n})",
|
||||
f"ВКР: список должен делиться на разделы; в каждом ≥{profile.min_sources} "
|
||||
f"источников (сейчас всего {n}, разделов нет)",
|
||||
))
|
||||
else:
|
||||
if n < profile.min_sources:
|
||||
@@ -288,13 +312,10 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
||||
))
|
||||
|
||||
# Citation order vs first appearance
|
||||
# Exclude intro/conclusion from citation scan for order
|
||||
body_for_cites = text
|
||||
cites = find_citations(body_for_cites)
|
||||
# Filter cites that appear only in intro/conclusion — still listed
|
||||
keys = [e[0] for e in entries]
|
||||
if cites and keys:
|
||||
# First N unique cites should match order of keys for simple lists
|
||||
if not profile.sectional_biblio:
|
||||
expected = cites[:len(keys)]
|
||||
if keys != expected and set(keys) == set(expected):
|
||||
@@ -313,16 +334,15 @@ def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
||||
|
||||
def check_object_refs(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
# Captions / images
|
||||
labels = set(re.findall(r"^%(\w+)", text, re.M))
|
||||
# image titles with %id
|
||||
labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text))
|
||||
refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text))
|
||||
for lab in labels:
|
||||
# At least one @?:lab or word reference — soft check
|
||||
if lab not in refs and f"@{lab}" not in text:
|
||||
# only warn if label looks intentional
|
||||
pass
|
||||
if lab not in refs:
|
||||
issues.append(Issue(
|
||||
"ref.unused", "warning",
|
||||
f"Метка «{lab}» объявлена, но в тексте нет ссылки @…:{lab}",
|
||||
))
|
||||
for ref in refs:
|
||||
if ref not in labels:
|
||||
issues.append(Issue(
|
||||
@@ -342,6 +362,66 @@ def check_appendices(text: str) -> list[Issue]:
|
||||
f"Буква «{letter}» не используется для обозначения приложений",
|
||||
_line_of(text, m.start()),
|
||||
))
|
||||
|
||||
# Multiple lettered appendices should have a list after # *ПРИЛОЖЕНИЯ
|
||||
apps = list(re.finditer(
|
||||
r"^#{1,6}\s+\*?Приложение\s+[А-ЯA-ZЁ]\b",
|
||||
text, re.M | re.I,
|
||||
))
|
||||
# Filter out the section header «ПРИЛОЖЕНИЯ» / bare «ПРИЛОЖЕНИЕ»
|
||||
lettered = []
|
||||
for m in apps:
|
||||
line = m.group(0)
|
||||
if re.search(r"(?i)приложение\s+[А-ЯA-ZЁ]", line):
|
||||
lettered.append(m)
|
||||
if len(lettered) >= 2:
|
||||
sec = re.search(
|
||||
r"^#\s*\*?\s*ПРИЛОЖЕНИЯ?\s*$",
|
||||
text, re.M | re.I,
|
||||
)
|
||||
if sec:
|
||||
between = text[sec.end(): lettered[0].start()]
|
||||
# Expect a plain-text list (not only blank / headings)
|
||||
has_list = bool(re.search(r"(?im)^\s*[-–—*]|\bприложение\s+[А-ЯA-ZЁ]\b", between))
|
||||
has_prose = bool(re.search(r"[А-Яа-яA-Za-z]{3,}", between))
|
||||
if not (has_list or has_prose):
|
||||
issues.append(Issue(
|
||||
"appendix.toc", "warning",
|
||||
"При нескольких приложениях после «ПРИЛОЖЕНИЯ» нужен перечень "
|
||||
"(основной текст) с номерами и названиями",
|
||||
_line_of(text, sec.start()),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_continuation_hints(
|
||||
text: str,
|
||||
*,
|
||||
table_continuation: str = "off",
|
||||
listing_continuation: str = "off",
|
||||
) -> list[Issue]:
|
||||
"""Warn that Word cannot auto-insert «Продолжение…» when mode is off/soft."""
|
||||
issues: list[Issue] = []
|
||||
soft = {"off", "soft"}
|
||||
if table_continuation in soft and _TABLE_BLOCK_RE.search(text):
|
||||
issues.append(Issue(
|
||||
"table.continuation", "warning",
|
||||
"Word сам не вставит «Продолжение Таблицы N» при переносе. "
|
||||
"Варианты: --table-continuation word (точный разрыв через Word COM) "
|
||||
"или caption (оценка высоты ≠ вёрстка Word, возможны артефакты).",
|
||||
))
|
||||
# Long fenced code blocks (likely listings)
|
||||
long_listing = False
|
||||
for m in re.finditer(r"^```[^\n]*\n([\s\S]*?)^```", text, re.M):
|
||||
if m.group(1).count("\n") >= 40:
|
||||
long_listing = True
|
||||
break
|
||||
if listing_continuation in soft and long_listing:
|
||||
issues.append(Issue(
|
||||
"listing.continuation", "warning",
|
||||
"Длинный листинг: Word сам не вставит «Продолжение Листинга N». "
|
||||
"Варианты: --listing-continuation word или caption.",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
@@ -404,7 +484,13 @@ def check_table_merge(text: str) -> list[Issue]:
|
||||
return issues
|
||||
|
||||
|
||||
def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]:
|
||||
def check_markdown(
|
||||
text: str,
|
||||
doc_type: str = "coursework",
|
||||
*,
|
||||
table_continuation: str = "off",
|
||||
listing_continuation: str = "off",
|
||||
) -> list[Issue]:
|
||||
profile = get_profile(doc_type)
|
||||
issues: list[Issue] = []
|
||||
issues.extend(check_structure(text, profile))
|
||||
@@ -417,6 +503,11 @@ def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]:
|
||||
issues.extend(check_object_refs(text))
|
||||
issues.extend(check_appendices(text))
|
||||
issues.extend(check_table_merge(text))
|
||||
issues.extend(check_continuation_hints(
|
||||
text,
|
||||
table_continuation=table_continuation,
|
||||
listing_continuation=listing_continuation,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
|
||||
+20
-3
@@ -15,7 +15,9 @@ from .profiles import (
|
||||
DEFAULT_TOC_MODE,
|
||||
TOC_MODES,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
DEFAULT_LISTING_CONTINUATION,
|
||||
TABLE_CONTINUATION_MODES,
|
||||
LISTING_CONTINUATION_MODES,
|
||||
)
|
||||
from .label_pass import (
|
||||
assign_numbers,
|
||||
@@ -26,6 +28,8 @@ from .label_pass import (
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.table import Table
|
||||
from .renderable.listing import Listing
|
||||
from .renderable.diagram import DiagramFigure
|
||||
|
||||
|
||||
class Converter:
|
||||
@@ -33,11 +37,13 @@ class Converter:
|
||||
|
||||
def __init__(self, input_path: str, output_path: str,
|
||||
template_path: str = None, debug: bool = False,
|
||||
doc_type: str = "coursework",
|
||||
doc_type: str = "practice",
|
||||
heading_numbering: str = DEFAULT_HEADING_NUMBERING,
|
||||
emdash_to_hyphen: bool = False,
|
||||
toc_mode: str = DEFAULT_TOC_MODE,
|
||||
table_continuation: str = DEFAULT_TABLE_CONTINUATION):
|
||||
table_continuation: str = DEFAULT_TABLE_CONTINUATION,
|
||||
listing_continuation: str = DEFAULT_LISTING_CONTINUATION,
|
||||
hr_pagebreak: bool = False):
|
||||
if heading_numbering not in HEADING_NUMBERING_MODES:
|
||||
raise ValueError(
|
||||
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
|
||||
@@ -52,12 +58,19 @@ class Converter:
|
||||
f"table_continuation must be one of {TABLE_CONTINUATION_MODES}, "
|
||||
f"got {table_continuation!r}"
|
||||
)
|
||||
if listing_continuation not in LISTING_CONTINUATION_MODES:
|
||||
raise ValueError(
|
||||
f"listing_continuation must be one of {LISTING_CONTINUATION_MODES}, "
|
||||
f"got {listing_continuation!r}"
|
||||
)
|
||||
self._output_path = output_path
|
||||
self._doc_type = doc_type
|
||||
self._heading_numbering = heading_numbering
|
||||
self._emdash_to_hyphen = emdash_to_hyphen
|
||||
self._toc_mode = toc_mode
|
||||
self._table_continuation = table_continuation
|
||||
self._listing_continuation = listing_continuation
|
||||
self._hr_pagebreak = hr_pagebreak
|
||||
self._profile = get_profile(doc_type)
|
||||
self._document: Document = docx.Document(template_path)
|
||||
self._document._body.clear_content()
|
||||
@@ -67,7 +80,7 @@ class Converter:
|
||||
raw = f.read()
|
||||
self._raw_markdown = raw
|
||||
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
|
||||
self.parser = Parser(self._document, self._preprocessed)
|
||||
self.parser = Parser(self._document, self._preprocessed, hr_pagebreak=hr_pagebreak)
|
||||
|
||||
def convert(self):
|
||||
renderables = list(self.parser.parse())
|
||||
@@ -85,6 +98,10 @@ class Converter:
|
||||
r.set_toc_mode(self._toc_mode)
|
||||
elif isinstance(r, Table):
|
||||
r.set_continuation_mode(self._table_continuation)
|
||||
elif isinstance(r, Listing):
|
||||
r.set_continuation_mode(self._listing_continuation)
|
||||
elif isinstance(r, DiagramFigure) and r.listing:
|
||||
r.listing.set_continuation_mode(self._listing_continuation)
|
||||
|
||||
formula_refs = find_formula_refs(self._raw_markdown)
|
||||
registry = assign_numbers(
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
"""HTTP(S) include cache: index URL → local file, never overwrite existing cache files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, urljoin
|
||||
|
||||
import requests
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
INCLUDE_CACHE_INDEX = "md2gost.include-cache.json"
|
||||
INCLUDE_CACHE_DIR = "include-cache"
|
||||
MAX_INCLUDE_DEPTH = 8
|
||||
|
||||
_HTTP_RE = re.compile(r"^https?://", re.I)
|
||||
_INCLUDE_LINE_RE = re.compile(
|
||||
r"^[ \t]*!(?:includeurl|include)[ \t]+(?P<q>[\"']?)(?P<ref>[^\s\"']+)(?P=q)",
|
||||
re.I | re.M,
|
||||
)
|
||||
|
||||
|
||||
def app_dir() -> Path:
|
||||
"""Directory next to the running application (exe or cwd for python -m)."""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def include_cache_index_path(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / INCLUDE_CACHE_INDEX
|
||||
|
||||
|
||||
def include_cache_files_dir(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / INCLUDE_CACHE_DIR
|
||||
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
return bool(_HTTP_RE.match((value or "").strip()))
|
||||
|
||||
|
||||
def _load_index(path: Path) -> dict[str, str]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_log.warning("Не удалось прочитать индекс кэша includes: %s", exc)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for key, val in data.items():
|
||||
if isinstance(key, str) and isinstance(val, str) and is_http_url(key):
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
def _save_index(path: Path, index: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(index, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _unique_cache_path(url: str, files_dir: Path) -> Path:
|
||||
"""New file path for URL; never reuse/overwrite an existing cache file."""
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
parsed = urlparse(url)
|
||||
base = Path(parsed.path).name or "include.puml"
|
||||
if not base.lower().endswith((".puml", ".iuml", ".pu", ".txt")):
|
||||
base = base + ".puml" if "." not in base else base
|
||||
digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:10]
|
||||
stem = Path(base).stem
|
||||
suffix = Path(base).suffix or ".puml"
|
||||
# sanitize stem
|
||||
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", stem)[:40] or "include"
|
||||
candidate = files_dir / f"{safe}_{digest}{suffix}"
|
||||
if not candidate.exists():
|
||||
return candidate
|
||||
n = 1
|
||||
while True:
|
||||
alt = files_dir / f"{safe}_{digest}_{n}{suffix}"
|
||||
if not alt.exists():
|
||||
return alt
|
||||
n += 1
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
try:
|
||||
resp = requests.get(url, timeout=60)
|
||||
resp.raise_for_status()
|
||||
tmp.write_bytes(resp.content)
|
||||
tmp.replace(dest)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def resolve_url(
|
||||
url: str,
|
||||
*,
|
||||
base: Path | None = None,
|
||||
depth: int = 0,
|
||||
) -> Path:
|
||||
"""
|
||||
Return local path for URL. Uses index if present and file exists;
|
||||
otherwise downloads to a *new* file and records the mapping.
|
||||
Never overwrites an existing cache file for a different URL.
|
||||
"""
|
||||
url = url.strip()
|
||||
if not is_http_url(url):
|
||||
raise ValueError(f"Не URL: {url}")
|
||||
if depth > MAX_INCLUDE_DEPTH:
|
||||
raise RuntimeError(f"Слишком глубокая цепочка includes ({MAX_INCLUDE_DEPTH}): {url}")
|
||||
|
||||
root = base or app_dir()
|
||||
index_path = include_cache_index_path(root)
|
||||
files_dir = include_cache_files_dir(root)
|
||||
index = _load_index(index_path)
|
||||
|
||||
cached = index.get(url)
|
||||
if cached:
|
||||
path = Path(cached)
|
||||
if path.is_file():
|
||||
_rewrite_nested_includes(path, parent_url=url, base=root, depth=depth)
|
||||
return path
|
||||
_log.warning("Файл кэша пропал (%s), скачаю заново: %s", path, url)
|
||||
|
||||
dest = _unique_cache_path(url, files_dir)
|
||||
try:
|
||||
_download(url, dest)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"Не удалось скачать include {url}: {exc}. "
|
||||
"Нет сети и нет кэша — положите файл вручную или проверьте URL."
|
||||
) from exc
|
||||
|
||||
index[url] = str(dest.resolve())
|
||||
_save_index(index_path, index)
|
||||
_log.info("Include cached: %s → %s", url, dest)
|
||||
_rewrite_nested_includes(dest, parent_url=url, base=root, depth=depth)
|
||||
return dest
|
||||
|
||||
|
||||
def _absolute_include_url(ref: str, parent_url: str | None) -> str | None:
|
||||
"""Turn include ref into absolute http(s) URL, or None if local/stdlib."""
|
||||
ref = ref.strip()
|
||||
if is_http_url(ref):
|
||||
return ref
|
||||
if ref.startswith("<") and ref.endswith(">"):
|
||||
return None # PlantUML stdlib
|
||||
if parent_url and not Path(ref).is_absolute():
|
||||
# relative to parent URL directory
|
||||
return urljoin(parent_url, ref)
|
||||
return None
|
||||
|
||||
|
||||
def _rewrite_nested_includes(
|
||||
path: Path, *, parent_url: str, base: Path, depth: int,
|
||||
) -> None:
|
||||
"""Rewrite http(s) / relative !include inside a cached .puml to local paths."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return
|
||||
|
||||
changed = False
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
nonlocal changed
|
||||
ref = match.group("ref")
|
||||
abs_url = _absolute_include_url(ref, parent_url)
|
||||
if not abs_url:
|
||||
return match.group(0)
|
||||
local = resolve_url(abs_url, base=base, depth=depth + 1)
|
||||
changed = True
|
||||
local_s = str(local.resolve()).replace("\\", "/")
|
||||
return f"!include {local_s}"
|
||||
|
||||
new_text = _INCLUDE_LINE_RE.sub(repl, text)
|
||||
if changed and new_text != text:
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
|
||||
|
||||
def resolve_include_ref(
|
||||
ref: str,
|
||||
*,
|
||||
base: Path | None = None,
|
||||
schemes_dir: Path | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Resolve include entry to a local path string suitable for !include.
|
||||
Local paths are resolved relative to schemes_dir / diagrams / cwd.
|
||||
"""
|
||||
ref = (ref or "").strip()
|
||||
if not ref:
|
||||
raise ValueError("Пустой include")
|
||||
if is_http_url(ref):
|
||||
return str(resolve_url(ref, base=base).resolve()).replace("\\", "/")
|
||||
|
||||
path = Path(ref)
|
||||
if path.is_file():
|
||||
return str(path.resolve()).replace("\\", "/")
|
||||
candidates: list[Path] = []
|
||||
if schemes_dir:
|
||||
candidates.append(schemes_dir / ref)
|
||||
from . import package_dir
|
||||
candidates.append(Path(package_dir()) / "diagrams" / ref)
|
||||
candidates.append(Path.cwd() / ref)
|
||||
for cand in candidates:
|
||||
if cand.is_file():
|
||||
return str(cand.resolve()).replace("\\", "/")
|
||||
raise FileNotFoundError(f"Include не найден: {ref}")
|
||||
|
||||
|
||||
def rewrite_http_includes_in_source(source: str, *, base: Path | None = None) -> str:
|
||||
"""Replace http(s) !include / !includeurl in prepared source with cached local paths."""
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
ref = match.group("ref")
|
||||
if not is_http_url(ref):
|
||||
return match.group(0)
|
||||
local = str(resolve_url(ref, base=base).resolve()).replace("\\", "/")
|
||||
return f"!include {local}"
|
||||
|
||||
return _INCLUDE_LINE_RE.sub(repl, source)
|
||||
|
||||
|
||||
def reset_include_cache(base: Path | None = None) -> int:
|
||||
"""
|
||||
Delete index and only files listed in it. Returns number of files removed.
|
||||
Other files in include-cache/ are left untouched.
|
||||
"""
|
||||
root = base or app_dir()
|
||||
index_path = include_cache_index_path(root)
|
||||
index = _load_index(index_path)
|
||||
removed = 0
|
||||
for _url, path_s in list(index.items()):
|
||||
path = Path(path_s)
|
||||
try:
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed += 1
|
||||
except OSError as exc:
|
||||
_log.warning("Не удалось удалить %s: %s", path, exc)
|
||||
if index_path.is_file():
|
||||
index_path.unlink(missing_ok=True)
|
||||
return removed
|
||||
|
||||
|
||||
def cache_entry_count(base: Path | None = None) -> int:
|
||||
return len(_load_index(include_cache_index_path(base or app_dir())))
|
||||
+360
-69
@@ -1,12 +1,14 @@
|
||||
"""Render UML/BPMN/C4 fenced blocks to PNG (PlantUML jar / Kroki local / remote)."""
|
||||
"""Render UML / Mermaid / scheme fenced blocks to PNG (+ optional SVG)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -15,13 +17,45 @@ from typing import Literal
|
||||
import requests
|
||||
|
||||
from . import package_dir
|
||||
from .diagram_schemes import (
|
||||
configure_schemes,
|
||||
ensure_user_schemes,
|
||||
is_diagram_lang,
|
||||
prepare_with_schemes,
|
||||
)
|
||||
|
||||
DIAGRAM_LANGS = frozenset({"uml", "plantuml", "bpmn", "c4"})
|
||||
# Back-compat: static set used by older tests; runtime uses is_diagram_lang().
|
||||
DIAGRAM_LANGS = frozenset(
|
||||
{
|
||||
"uml",
|
||||
"plantuml",
|
||||
"c4",
|
||||
"c4context",
|
||||
"c4component",
|
||||
"usecase",
|
||||
"bpmn",
|
||||
"mermaid",
|
||||
"mmd",
|
||||
}
|
||||
)
|
||||
FallbackMode = Literal["local", "remote", "off"]
|
||||
DiagramFormat = Literal["png", "svg"]
|
||||
|
||||
DEFAULT_KROKI_URL = "http://localhost:8000"
|
||||
REMOTE_KROKI_URL = "https://kroki.io"
|
||||
|
||||
# Sharper raster when Word shrinks the figure to page width (display size stays ×1).
|
||||
DEFAULT_DIAGRAM_SCALE = 2.0
|
||||
# Avoid PlantUML clipping large scaled diagrams (default limit is 4096).
|
||||
PLANTUML_LIMIT_SIZE = "8192"
|
||||
|
||||
# Pinned official jar (GPL). Bundled at exe build; otherwise downloaded once to user cache.
|
||||
PLANTUML_VERSION = "1.2025.4"
|
||||
PLANTUML_JAR_URL = (
|
||||
f"https://github.com/plantuml/plantuml/releases/download/"
|
||||
f"v{PLANTUML_VERSION}/plantuml-{PLANTUML_VERSION}.jar"
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -31,6 +65,17 @@ class DiagramConfig:
|
||||
kroki_url: str | None = None
|
||||
fallback: FallbackMode = "remote"
|
||||
cache_dir: str | None = None
|
||||
schemes_path: str | None = None
|
||||
diagram_format: DiagramFormat = "png"
|
||||
diagram_scale: float = DEFAULT_DIAGRAM_SCALE
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagramRenderResult:
|
||||
png_path: str
|
||||
svg_path: str | None = None
|
||||
# PlantUML render scale; Image divides display size by this to keep ×1 layout.
|
||||
pixel_scale: float = 1.0
|
||||
|
||||
|
||||
_CONFIG = DiagramConfig()
|
||||
@@ -41,13 +86,26 @@ def configure_diagrams(
|
||||
kroki_url: str | None = None,
|
||||
fallback: FallbackMode = "remote",
|
||||
cache_dir: str | None = None,
|
||||
schemes_path: str | None = None,
|
||||
md_dir: str | None = None,
|
||||
diagram_format: DiagramFormat = "png",
|
||||
diagram_scale: float | None = None,
|
||||
) -> None:
|
||||
global _CONFIG
|
||||
ensure_user_schemes()
|
||||
configure_schemes(schemes_path=schemes_path, md_dir=md_dir)
|
||||
fmt: DiagramFormat = "svg" if diagram_format == "svg" else "png"
|
||||
scale = DEFAULT_DIAGRAM_SCALE if diagram_scale is None else float(diagram_scale)
|
||||
if scale < 1:
|
||||
scale = 1.0
|
||||
_CONFIG = DiagramConfig(
|
||||
plantuml_jar=plantuml_jar or os.environ.get("PLANTUML_JAR"),
|
||||
plantuml_jar=resolve_plantuml_jar(plantuml_jar or None, download=False),
|
||||
kroki_url=kroki_url or os.environ.get("KROKI_URL"),
|
||||
fallback=fallback,
|
||||
cache_dir=cache_dir,
|
||||
schemes_path=schemes_path,
|
||||
diagram_format=fmt,
|
||||
diagram_scale=scale,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,65 +115,175 @@ def diagrams_dir() -> Path:
|
||||
|
||||
def prepare_source(lang: str, source: str) -> tuple[str, str]:
|
||||
"""Return (prepared_source, kroki_diagram_type)."""
|
||||
lang = (lang or "uml").lower().strip()
|
||||
text = source.strip()
|
||||
|
||||
if lang == "bpmn":
|
||||
if "@startbpmn" not in text.lower() and "<definitions" not in text.lower():
|
||||
# PlantUML BPMN dialect
|
||||
if not text.startswith("@start"):
|
||||
text = "@startbpmn\n" + text + "\n@endbpmn"
|
||||
return text, "bpmn"
|
||||
|
||||
if lang == "c4":
|
||||
if "!include" not in text and "!includeurl" not in text.lower():
|
||||
# Prefer PlantUML stdlib; also ship local stubs for jar -I path
|
||||
includes = (
|
||||
f"!include {diagrams_dir() / 'C4_Container.puml'}\n"
|
||||
if (diagrams_dir() / "C4_Container.puml").exists()
|
||||
else "!include <C4/C4_Container>\n"
|
||||
)
|
||||
body = text
|
||||
if body.lower().startswith("@startuml"):
|
||||
lines = body.splitlines()
|
||||
text = lines[0] + "\n" + includes + "\n".join(lines[1:])
|
||||
else:
|
||||
text = f"@startuml\n{includes}{body}\n@enduml"
|
||||
elif not text.lower().startswith("@start"):
|
||||
text = f"@startuml\n{text}\n@enduml"
|
||||
return text, "plantuml"
|
||||
|
||||
# uml / plantuml
|
||||
if not text.lower().startswith("@start"):
|
||||
text = f"@startuml\n{text}\n@enduml"
|
||||
return text, "plantuml"
|
||||
return prepare_with_schemes(lang, source)
|
||||
|
||||
|
||||
def _cache_path(source: str, cache_dir: str | None = None) -> Path:
|
||||
def _cache_root(cache_dir: str | None = None) -> Path:
|
||||
root = cache_dir or _CONFIG.cache_dir
|
||||
if not root:
|
||||
wd = os.environ.get("WORKING_DIR", ".")
|
||||
root = os.path.join(wd, ".md2gost-cache")
|
||||
Path(root).mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
|
||||
return Path(root) / f"{digest}.png"
|
||||
path = Path(root)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _cache_digest(source: str) -> str:
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
def _cache_paths(source: str, cache_dir: str | None = None) -> tuple[Path, Path]:
|
||||
root = _cache_root(cache_dir)
|
||||
digest = _cache_digest(source)
|
||||
return root / f"{digest}.png", root / f"{digest}.svg"
|
||||
|
||||
|
||||
def _java_available() -> bool:
|
||||
return shutil.which("java") is not None
|
||||
|
||||
|
||||
def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool:
|
||||
def vendor_plantuml_path() -> Path:
|
||||
return Path(package_dir()) / "vendor" / "plantuml.jar"
|
||||
|
||||
|
||||
def cached_plantuml_path() -> Path:
|
||||
if os.name == "nt":
|
||||
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
|
||||
else:
|
||||
root = Path.home() / ".md2gost"
|
||||
return root / "plantuml.jar"
|
||||
|
||||
|
||||
def iter_plantuml_candidates(explicit: str | None = None) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
if explicit:
|
||||
paths.append(Path(explicit))
|
||||
env = os.environ.get("PLANTUML_JAR")
|
||||
if env:
|
||||
paths.append(Path(env))
|
||||
paths.append(vendor_plantuml_path())
|
||||
if getattr(sys, "frozen", False):
|
||||
mei = getattr(sys, "_MEIPASS", None)
|
||||
if mei:
|
||||
paths.append(Path(mei) / "md2gost" / "vendor" / "plantuml.jar")
|
||||
paths.append(Path(mei) / "vendor" / "plantuml.jar")
|
||||
paths.append(Path(sys.executable).resolve().parent / "plantuml.jar")
|
||||
paths.append(cached_plantuml_path())
|
||||
seen: set[str] = set()
|
||||
out: list[Path] = []
|
||||
for path in paths:
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_plantuml_jar(explicit: str | None = None, *, download: bool = False) -> str | None:
|
||||
"""Find a plantuml.jar: explicit path, env, bundled vendor, user cache; optionally download."""
|
||||
for path in iter_plantuml_candidates(explicit):
|
||||
if path.is_file() and path.stat().st_size > 1000:
|
||||
return str(path)
|
||||
if download:
|
||||
dest = cached_plantuml_path()
|
||||
if fetch_plantuml_jar(dest):
|
||||
return str(dest)
|
||||
return None
|
||||
|
||||
|
||||
def fetch_plantuml_jar(dest: Path, url: str = PLANTUML_JAR_URL) -> bool:
|
||||
"""Download official plantuml.jar to dest. Returns True on success."""
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_suffix(".jar.part")
|
||||
try:
|
||||
resp = requests.get(url, timeout=120, stream=True)
|
||||
resp.raise_for_status()
|
||||
size = 0
|
||||
with open(tmp, "wb") as fh:
|
||||
for chunk in resp.iter_content(chunk_size=65536):
|
||||
if chunk:
|
||||
fh.write(chunk)
|
||||
size += len(chunk)
|
||||
if size < 1000:
|
||||
tmp.unlink(missing_ok=True)
|
||||
return False
|
||||
tmp.replace(dest)
|
||||
_log.info("PlantUML jar: %s", dest)
|
||||
return True
|
||||
except Exception as exc:
|
||||
_log.warning("Не удалось скачать plantuml.jar: %s", exc)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
|
||||
def diagram_engine_status(explicit_jar: str | None = None) -> str:
|
||||
"""Human-readable status for the GUI."""
|
||||
java = shutil.which("java")
|
||||
jar = resolve_plantuml_jar(explicit_jar, download=False)
|
||||
if java:
|
||||
java_line = f"Java: есть ({java})"
|
||||
else:
|
||||
java_line = (
|
||||
"Java: не найдена. Локальный PlantUML не запустится — "
|
||||
"картинки пойдут через интернет (kroki.io)."
|
||||
)
|
||||
if jar:
|
||||
jar_line = f"PlantUML: {jar}"
|
||||
else:
|
||||
jar_line = (
|
||||
"PlantUML: файла нет. Нажмите «Скачать PlantUML» (нужна Java) "
|
||||
"или оставьте как есть — картинки нарисует интернет (kroki.io)."
|
||||
)
|
||||
return java_line + "\n" + jar_line
|
||||
|
||||
|
||||
def _ensure_plantuml_png_scale(source: str, scale: float = DEFAULT_DIAGRAM_SCALE) -> str:
|
||||
"""Inject `scale N` into PlantUML source unless the author already set scale/dpi."""
|
||||
if scale <= 1:
|
||||
return source
|
||||
if re.search(r"(?im)^\s*scale\b", source):
|
||||
return source
|
||||
if re.search(r"(?im)^\s*skinparam\s+dpi\b", source):
|
||||
return source
|
||||
lines = source.splitlines()
|
||||
if not lines:
|
||||
return source
|
||||
insert_at = 0
|
||||
if lines[0].strip().lower().startswith("@start"):
|
||||
insert_at = 1
|
||||
scale_line = f"scale {scale:g}"
|
||||
lines.insert(insert_at, scale_line)
|
||||
return "\n".join(lines) + ("\n" if source.endswith("\n") else "")
|
||||
|
||||
|
||||
def _is_png(data: bytes) -> bool:
|
||||
return data.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
def _is_svg(data: bytes) -> bool:
|
||||
head = data.lstrip()[:200].lower()
|
||||
return head.startswith(b"<svg") or head.startswith(b"<?xml") or b"<svg" in head
|
||||
|
||||
|
||||
def _render_plantuml_jar_one(
|
||||
source: str,
|
||||
out_path: Path,
|
||||
jar: str,
|
||||
fmt: Literal["png", "svg"],
|
||||
) -> bool:
|
||||
if not jar or not os.path.isfile(jar) or not _java_available():
|
||||
return False
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = Path(tmp) / "diagram.puml"
|
||||
src.write_text(source, encoding="utf-8")
|
||||
cmd = [
|
||||
"java", "-jar", jar,
|
||||
"-tpng",
|
||||
"java",
|
||||
f"-Dplantuml.include.path={diagrams_dir().resolve()}",
|
||||
f"-DPLANTUML_LIMIT_SIZE={PLANTUML_LIMIT_SIZE}",
|
||||
"-jar", jar,
|
||||
f"-t{fmt}",
|
||||
"-charset", "UTF-8",
|
||||
f"-I{diagrams_dir()}",
|
||||
"-o", tmp,
|
||||
str(src),
|
||||
]
|
||||
@@ -126,16 +294,43 @@ def _render_plantuml_jar(source: str, out_png: Path, jar: str) -> bool:
|
||||
except (OSError, subprocess.TimeoutExpired) as e:
|
||||
_log.warning("PlantUML jar failed: %s", e)
|
||||
return False
|
||||
produced = Path(tmp) / "diagram.png"
|
||||
produced = Path(tmp) / f"diagram.{fmt}"
|
||||
if proc.returncode != 0 or not produced.is_file():
|
||||
_log.warning("PlantUML jar error: %s", proc.stderr or proc.stdout)
|
||||
return False
|
||||
shutil.copyfile(produced, out_png)
|
||||
data = produced.read_bytes()
|
||||
if fmt == "png" and not _is_png(data):
|
||||
return False
|
||||
if fmt == "svg" and not _is_svg(data):
|
||||
return False
|
||||
shutil.copyfile(produced, out_path)
|
||||
return True
|
||||
|
||||
|
||||
def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path) -> bool:
|
||||
url = base_url.rstrip("/") + f"/{diagram_type}/png"
|
||||
def _render_plantuml_jar(
|
||||
source: str,
|
||||
out_png: Path,
|
||||
jar: str,
|
||||
*,
|
||||
out_svg: Path | None = None,
|
||||
) -> bool:
|
||||
"""Render PNG; optionally also SVG from the same jar."""
|
||||
if not _render_plantuml_jar_one(source, out_png, jar, "png"):
|
||||
return False
|
||||
if out_svg is not None:
|
||||
if not _render_plantuml_jar_one(source, out_svg, jar, "svg"):
|
||||
_log.warning("PlantUML jar: PNG ok, SVG failed")
|
||||
return True
|
||||
|
||||
|
||||
def _render_kroki(
|
||||
source: str,
|
||||
diagram_type: str,
|
||||
base_url: str,
|
||||
out_path: Path,
|
||||
fmt: Literal["png", "svg"] = "png",
|
||||
) -> bool:
|
||||
url = base_url.rstrip("/") + f"/{diagram_type}/{fmt}"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
@@ -143,16 +338,38 @@ def _render_kroki(source: str, diagram_type: str, base_url: str, out_png: Path)
|
||||
headers={"Content-Type": "text/plain"},
|
||||
timeout=60,
|
||||
)
|
||||
if resp.status_code != 200 or not resp.content.startswith(b"\x89PNG"):
|
||||
if resp.status_code != 200:
|
||||
_log.warning("Kroki %s → HTTP %s", url, resp.status_code)
|
||||
return False
|
||||
out_png.write_bytes(resp.content)
|
||||
data = resp.content
|
||||
if fmt == "png" and not _is_png(data):
|
||||
_log.warning("Kroki %s → not PNG", url)
|
||||
return False
|
||||
if fmt == "svg" and not _is_svg(data):
|
||||
_log.warning("Kroki %s → not SVG", url)
|
||||
return False
|
||||
out_path.write_bytes(data)
|
||||
return True
|
||||
except requests.RequestException as e:
|
||||
_log.warning("Kroki request failed (%s): %s", url, e)
|
||||
return False
|
||||
|
||||
|
||||
def _render_kroki_pair(
|
||||
source: str,
|
||||
diagram_type: str,
|
||||
base_url: str,
|
||||
out_png: Path,
|
||||
out_svg: Path | None,
|
||||
) -> bool:
|
||||
if not _render_kroki(source, diagram_type, base_url, out_png, "png"):
|
||||
return False
|
||||
if out_svg is not None:
|
||||
if not _render_kroki(source, diagram_type, base_url, out_svg, "svg"):
|
||||
_log.warning("Kroki: PNG ok, SVG failed (%s)", base_url)
|
||||
return True
|
||||
|
||||
|
||||
def render_diagram(
|
||||
lang: str,
|
||||
source: str,
|
||||
@@ -161,36 +378,91 @@ def render_diagram(
|
||||
kroki_url: str | None = None,
|
||||
fallback: FallbackMode | None = None,
|
||||
cache_dir: str | None = None,
|
||||
) -> str:
|
||||
"""Render diagram to PNG path. Raises RuntimeError if all backends fail."""
|
||||
diagram_format: DiagramFormat | None = None,
|
||||
diagram_scale: float | None = None,
|
||||
) -> DiagramRenderResult:
|
||||
"""Render diagram to PNG (+ optional SVG). Raises RuntimeError if all backends fail."""
|
||||
ensure_user_schemes()
|
||||
prepared, kroki_type = prepare_source(lang, source)
|
||||
out = _cache_path(prepared, cache_dir)
|
||||
if out.is_file() and out.stat().st_size > 0:
|
||||
return str(out)
|
||||
scale = _CONFIG.diagram_scale if diagram_scale is None else float(diagram_scale)
|
||||
if scale < 1:
|
||||
scale = 1.0
|
||||
# pixel_scale: only when we injected scale (author override → treat as 1 for display).
|
||||
pixel_scale = 1.0
|
||||
if kroki_type == "plantuml" and scale > 1:
|
||||
before = prepared
|
||||
prepared = _ensure_plantuml_png_scale(prepared, scale)
|
||||
if prepared != before:
|
||||
pixel_scale = scale
|
||||
out_png, out_svg_path = _cache_paths(prepared, cache_dir)
|
||||
fmt: DiagramFormat = (
|
||||
diagram_format
|
||||
if diagram_format is not None
|
||||
else _CONFIG.diagram_format
|
||||
)
|
||||
want_svg = fmt == "svg"
|
||||
svg_target = out_svg_path if want_svg else None
|
||||
|
||||
if out_png.is_file() and out_png.stat().st_size > 0:
|
||||
has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0
|
||||
if not want_svg or has_svg:
|
||||
return DiagramRenderResult(
|
||||
png_path=str(out_png),
|
||||
svg_path=str(out_svg_path) if (want_svg and has_svg) else None,
|
||||
pixel_scale=pixel_scale,
|
||||
)
|
||||
# PNG cached but SVG missing in svg mode — fall through to fill SVG.
|
||||
|
||||
jar = plantuml_jar if plantuml_jar is not None else _CONFIG.plantuml_jar
|
||||
jar = jar or os.environ.get("PLANTUML_JAR")
|
||||
jar = resolve_plantuml_jar(jar or None, download=False)
|
||||
local_kroki = kroki_url if kroki_url is not None else _CONFIG.kroki_url
|
||||
local_kroki = local_kroki or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL
|
||||
mode: FallbackMode = fallback if fallback is not None else _CONFIG.fallback
|
||||
|
||||
# 1) PlantUML jar
|
||||
if _render_plantuml_jar(prepared, out, jar or ""):
|
||||
return str(out)
|
||||
def _result() -> DiagramRenderResult:
|
||||
svg = None
|
||||
if want_svg and out_svg_path.is_file() and out_svg_path.stat().st_size > 0:
|
||||
svg = str(out_svg_path)
|
||||
return DiagramRenderResult(
|
||||
png_path=str(out_png), svg_path=svg, pixel_scale=pixel_scale,
|
||||
)
|
||||
|
||||
# 1) PlantUML jar — only for plantuml type
|
||||
if kroki_type == "plantuml":
|
||||
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
|
||||
return _result()
|
||||
|
||||
# 2) Local Kroki
|
||||
if _render_kroki(prepared, kroki_type, local_kroki, out):
|
||||
return str(out)
|
||||
if _render_kroki_pair(prepared, kroki_type, local_kroki, out_png, svg_target):
|
||||
return _result()
|
||||
|
||||
def _png_fallback_or_raise(message: str) -> DiagramRenderResult:
|
||||
if out_png.is_file() and out_png.stat().st_size > 0:
|
||||
_log.warning("Диаграмма: SVG недоступен, вставляю только PNG")
|
||||
return DiagramRenderResult(
|
||||
png_path=str(out_png), svg_path=None, pixel_scale=pixel_scale,
|
||||
)
|
||||
raise RuntimeError(message)
|
||||
|
||||
if mode == "off":
|
||||
raise RuntimeError(
|
||||
"Не удалось отрендерить диаграмму локально "
|
||||
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
||||
if kroki_type == "plantuml":
|
||||
return _png_fallback_or_raise(
|
||||
"Не удалось отрендерить диаграмму локально "
|
||||
"(задайте --plantuml-jar или KROKI_URL; remote fallback отключён)"
|
||||
)
|
||||
return _png_fallback_or_raise(
|
||||
"Не удалось отрендерить диаграмму через локальный Kroki "
|
||||
"(задайте --kroki-url / KROKI_URL; remote fallback отключён)"
|
||||
)
|
||||
if mode == "local":
|
||||
raise RuntimeError(
|
||||
"Локальный рендер диаграммы недоступен "
|
||||
"(Java+plantuml.jar или локальный Kroki)"
|
||||
if kroki_type == "plantuml":
|
||||
return _png_fallback_or_raise(
|
||||
"Локальный рендер диаграммы недоступен "
|
||||
"(Java+plantuml.jar или локальный Kroki)"
|
||||
)
|
||||
return _png_fallback_or_raise(
|
||||
"Локальный Kroki недоступен для диаграммы "
|
||||
f"(тип {kroki_type}; задайте работающий --kroki-url)"
|
||||
)
|
||||
|
||||
# 3) Remote fallback
|
||||
@@ -198,7 +470,26 @@ def render_diagram(
|
||||
"Диаграмма: локальный рендер недоступен, использую remote %s",
|
||||
REMOTE_KROKI_URL,
|
||||
)
|
||||
if _render_kroki(prepared, kroki_type, REMOTE_KROKI_URL, out):
|
||||
return str(out)
|
||||
if _render_kroki_pair(prepared, kroki_type, REMOTE_KROKI_URL, out_png, svg_target):
|
||||
return _result()
|
||||
|
||||
raise RuntimeError("Не удалось отрендерить диаграмму (PlantUML/Kroki)")
|
||||
return _png_fallback_or_raise(
|
||||
"Не удалось отрендерить диаграмму (PlantUML/Kroki)"
|
||||
)
|
||||
|
||||
|
||||
# Re-export for callers / tests
|
||||
__all__ = [
|
||||
"DIAGRAM_LANGS",
|
||||
"DiagramRenderResult",
|
||||
"configure_diagrams",
|
||||
"diagram_engine_status",
|
||||
"diagrams_dir",
|
||||
"fetch_plantuml_jar",
|
||||
"is_diagram_lang",
|
||||
"prepare_source",
|
||||
"render_diagram",
|
||||
"resolve_plantuml_jar",
|
||||
"PLANTUML_JAR_URL",
|
||||
"PLANTUML_VERSION",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Configurable PlantUML diagram schemes (uml-c4, …) from md2gost.schemes.json."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from . import package_dir
|
||||
from .diagram_includes import (
|
||||
app_dir,
|
||||
resolve_include_ref,
|
||||
rewrite_http_includes_in_source,
|
||||
)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
SCHEMES_FILENAME = "md2gost.schemes.json"
|
||||
SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"})
|
||||
# Rendered via Kroki only (not PlantUML jar / schemes).
|
||||
KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"})
|
||||
KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiagramScheme:
|
||||
id: str
|
||||
title: str = ""
|
||||
version: str = ""
|
||||
author: str = ""
|
||||
docs: str = ""
|
||||
ai_prompt: str = ""
|
||||
includes: list[str] = field(default_factory=list)
|
||||
prefix: str = ""
|
||||
postfix: str = ""
|
||||
theme: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data: dict[str, Any] = {
|
||||
"title": self.title,
|
||||
"version": self.version,
|
||||
"author": self.author,
|
||||
"docs": self.docs,
|
||||
"ai-prompt": self.ai_prompt,
|
||||
"includes": list(self.includes),
|
||||
"prefix": self.prefix,
|
||||
"postfix": self.postfix,
|
||||
}
|
||||
if self.theme:
|
||||
data["theme"] = self.theme
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, scheme_id: str, data: dict[str, Any]) -> DiagramScheme:
|
||||
includes = data.get("includes") or []
|
||||
if isinstance(includes, str):
|
||||
includes = [includes]
|
||||
return cls(
|
||||
id=scheme_id,
|
||||
title=str(data.get("title") or scheme_id),
|
||||
version=str(data.get("version") or ""),
|
||||
author=str(data.get("author") or ""),
|
||||
docs=str(data.get("docs") or ""),
|
||||
ai_prompt=str(data.get("ai-prompt") or data.get("ai_prompt") or ""),
|
||||
includes=[str(x) for x in includes],
|
||||
prefix=str(data.get("prefix") or ""),
|
||||
postfix=str(data.get("postfix") or ""),
|
||||
theme=str(data.get("theme") or ""),
|
||||
)
|
||||
|
||||
|
||||
def bundled_schemes_path() -> Path:
|
||||
return Path(package_dir()) / "diagrams" / "schemes.json"
|
||||
|
||||
|
||||
def user_schemes_path(base: Path | None = None) -> Path:
|
||||
return (base or app_dir()) / SCHEMES_FILENAME
|
||||
|
||||
|
||||
def ensure_user_schemes(base: Path | None = None) -> Path:
|
||||
"""
|
||||
On first run copy bundled template next to the app.
|
||||
Never overwrite an existing user file.
|
||||
"""
|
||||
dest = user_schemes_path(base)
|
||||
if dest.is_file():
|
||||
return dest
|
||||
src = bundled_schemes_path()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if src.is_file():
|
||||
shutil.copyfile(src, dest)
|
||||
_log.info("Создан файл схем: %s", dest)
|
||||
else:
|
||||
dest.write_text("{}\n", encoding="utf-8")
|
||||
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _load_schemes_file(path: Path) -> dict[str, DiagramScheme]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_log.warning("Не удалось прочитать схемы %s: %s", path, exc)
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, DiagramScheme] = {}
|
||||
for key, val in raw.items():
|
||||
sid = str(key).lower().strip()
|
||||
if not SCHEME_ID_RE.match(sid):
|
||||
_log.warning("Пропуск схемы с недопустимым id: %s", key)
|
||||
continue
|
||||
if not isinstance(val, dict):
|
||||
continue
|
||||
out[sid] = DiagramScheme.from_dict(sid, val)
|
||||
return out
|
||||
|
||||
|
||||
def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
|
||||
return _load_schemes_file(path)
|
||||
|
||||
|
||||
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
|
||||
dest = path or user_schemes_path()
|
||||
payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return dest
|
||||
|
||||
|
||||
def scheme_id_from_lang(lang: str) -> str | None:
|
||||
"""Normalize fence lang to scheme id, or None for bare uml/plantuml."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if not lang:
|
||||
return None
|
||||
if lang in BUILTIN_DIAGRAM_LANGS:
|
||||
return None
|
||||
if lang.startswith("uml-"):
|
||||
sid = lang[4:]
|
||||
return sid if SCHEME_ID_RE.match(sid) else None
|
||||
if SCHEME_ID_RE.match(lang):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def load_merged_schemes(
|
||||
*,
|
||||
base: Path | None = None,
|
||||
extra_path: Path | None = None,
|
||||
md_dir: Path | None = None,
|
||||
) -> dict[str, DiagramScheme]:
|
||||
"""
|
||||
Merge layers (later wins entirely per id):
|
||||
1) bundled template
|
||||
2) user file next to app (created on first run)
|
||||
3) md2gost.schemes.json next to .md
|
||||
4) explicit --schemes path
|
||||
"""
|
||||
ensure_user_schemes(base)
|
||||
merged: dict[str, DiagramScheme] = {}
|
||||
merged.update(_load_schemes_file(bundled_schemes_path()))
|
||||
merged.update(_load_schemes_file(user_schemes_path(base)))
|
||||
if md_dir:
|
||||
near_md = Path(md_dir) / SCHEMES_FILENAME
|
||||
if near_md.is_file():
|
||||
user = user_schemes_path(base).resolve()
|
||||
try:
|
||||
if near_md.resolve() != user:
|
||||
merged.update(_load_schemes_file(near_md))
|
||||
except OSError:
|
||||
merged.update(_load_schemes_file(near_md))
|
||||
if extra_path:
|
||||
merged.update(_load_schemes_file(Path(extra_path)))
|
||||
return merged
|
||||
|
||||
|
||||
_SCHEMES_CACHE: dict[str, DiagramScheme] | None = None
|
||||
_SCHEMES_EXTRA: Path | None = None
|
||||
_SCHEMES_MD_DIR: Path | None = None
|
||||
|
||||
|
||||
def configure_schemes(
|
||||
*,
|
||||
schemes_path: str | None = None,
|
||||
md_dir: str | None = None,
|
||||
base: Path | None = None,
|
||||
) -> dict[str, DiagramScheme]:
|
||||
global _SCHEMES_CACHE, _SCHEMES_EXTRA, _SCHEMES_MD_DIR
|
||||
_SCHEMES_EXTRA = Path(schemes_path) if schemes_path else None
|
||||
_SCHEMES_MD_DIR = Path(md_dir) if md_dir else None
|
||||
_SCHEMES_CACHE = load_merged_schemes(
|
||||
base=base,
|
||||
extra_path=_SCHEMES_EXTRA,
|
||||
md_dir=_SCHEMES_MD_DIR,
|
||||
)
|
||||
return _SCHEMES_CACHE
|
||||
|
||||
|
||||
def get_schemes() -> dict[str, DiagramScheme]:
|
||||
global _SCHEMES_CACHE
|
||||
if _SCHEMES_CACHE is None:
|
||||
_SCHEMES_CACHE = load_merged_schemes()
|
||||
return _SCHEMES_CACHE
|
||||
|
||||
|
||||
def reload_schemes(**kwargs) -> dict[str, DiagramScheme]:
|
||||
return configure_schemes(**kwargs)
|
||||
|
||||
|
||||
def get_scheme(scheme_id: str) -> DiagramScheme | None:
|
||||
return get_schemes().get(scheme_id)
|
||||
|
||||
|
||||
def is_diagram_lang(lang: str) -> bool:
|
||||
"""True if fence should render as a diagram (not a plain listing)."""
|
||||
lang = (lang or "").lower().strip()
|
||||
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS:
|
||||
return True
|
||||
if lang.startswith("uml-"):
|
||||
# Force diagram path so missing scheme becomes an error, not a listing.
|
||||
return bool(SCHEME_ID_RE.match(lang[4:]))
|
||||
return lang in get_schemes()
|
||||
|
||||
|
||||
def apply_scheme(
|
||||
scheme: DiagramScheme,
|
||||
body: str,
|
||||
*,
|
||||
base: Path | None = None,
|
||||
) -> str:
|
||||
"""Wrap body with includes / prefix / postfix / theme; resolve URL includes."""
|
||||
text = body.strip()
|
||||
has_start = text.lower().startswith("@start")
|
||||
|
||||
include_lines: list[str] = []
|
||||
schemes_dir = user_schemes_path(base).parent
|
||||
for ref in scheme.includes:
|
||||
local = resolve_include_ref(ref, base=base, schemes_dir=schemes_dir)
|
||||
include_lines.append(f"!include {local}")
|
||||
|
||||
theme_line = ""
|
||||
if scheme.theme and "!theme" not in text.lower():
|
||||
t = scheme.theme.strip()
|
||||
if not t.lower().startswith("!theme"):
|
||||
t = f"!theme {t}"
|
||||
theme_line = t
|
||||
|
||||
prefix = scheme.prefix or ""
|
||||
postfix = scheme.postfix or ""
|
||||
|
||||
# Avoid double @startuml / @enduml when body already has @start…
|
||||
if has_start:
|
||||
prefix_use = _strip_start_end_wrappers(prefix)
|
||||
postfix_use = _strip_start_end_wrappers(postfix)
|
||||
head_bits = [ln for ln in include_lines if ln]
|
||||
if theme_line:
|
||||
head_bits.append(theme_line)
|
||||
if prefix_use.strip():
|
||||
head_bits.append(prefix_use.strip())
|
||||
if head_bits:
|
||||
lines = text.splitlines()
|
||||
text = lines[0] + "\n" + "\n".join(head_bits) + "\n" + "\n".join(lines[1:])
|
||||
if postfix_use.strip():
|
||||
text = text.rstrip() + "\n" + postfix_use.strip()
|
||||
else:
|
||||
parts: list[str] = []
|
||||
p = prefix.rstrip("\n") if prefix else ""
|
||||
pf = postfix.lstrip("\n") if postfix else ""
|
||||
if not p.lstrip().lower().startswith("@start"):
|
||||
parts.append("@startuml")
|
||||
if p:
|
||||
parts.append(p)
|
||||
parts.extend(include_lines)
|
||||
if theme_line:
|
||||
parts.append(theme_line)
|
||||
parts.append(text)
|
||||
if pf:
|
||||
parts.append(pf)
|
||||
elif "@enduml" not in text.lower():
|
||||
parts.append("@enduml")
|
||||
text = "\n".join(parts)
|
||||
|
||||
text = rewrite_http_includes_in_source(text, base=base)
|
||||
return text
|
||||
|
||||
|
||||
def _strip_start_end_wrappers(chunk: str) -> str:
|
||||
"""Remove leading @start… and trailing @end… lines from prefix/postfix."""
|
||||
lines = chunk.splitlines()
|
||||
while lines and lines[0].strip().lower().startswith("@start"):
|
||||
lines = lines[1:]
|
||||
while lines and lines[-1].strip().lower().startswith("@end"):
|
||||
lines = lines[:-1]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) -> tuple[str, str]:
|
||||
"""
|
||||
Prepare diagram source using schemes.
|
||||
Returns (prepared_source, kroki_diagram_type).
|
||||
Raises ValueError if uml-<id> / named scheme is missing.
|
||||
"""
|
||||
lang = (lang or "uml").lower().strip()
|
||||
text = source.strip()
|
||||
|
||||
if lang in KROKI_DIAGRAM_LANGS:
|
||||
return text, KROKI_TYPE_BY_LANG[lang]
|
||||
|
||||
sid = scheme_id_from_lang(lang)
|
||||
|
||||
if sid is None:
|
||||
# bare uml / plantuml
|
||||
if not text.lower().startswith("@start"):
|
||||
text = f"@startuml\n{text}\n@enduml"
|
||||
text = rewrite_http_includes_in_source(text, base=base)
|
||||
return text, "plantuml"
|
||||
|
||||
scheme = get_scheme(sid)
|
||||
if scheme is None:
|
||||
raise ValueError(
|
||||
f"Схема диаграммы «{sid}» не найдена "
|
||||
f"(оградка ```{lang}). Добавьте её в {SCHEMES_FILENAME}."
|
||||
)
|
||||
prepared = apply_scheme(scheme, text, base=base)
|
||||
return prepared, "plantuml"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"c4": {
|
||||
"title": "C4 Container",
|
||||
"version": "1.0.0",
|
||||
"author": "md2gost",
|
||||
"docs": "Person(alias, \"Label\", \"descr?\")\nSystem(alias, \"Label\", \"descr?\")\nSystem_Ext(alias, \"Label\")\nContainer(alias, \"Label\", \"techn\", \"descr?\")\nContainerDb(alias, \"Label\", \"techn\")\nRel(from, to, \"label\", \"techn?\")\nRel_R / Rel_L / Rel_U / Rel_D — направление\nLAYOUT_WITH_LEGEND()",
|
||||
"ai-prompt": "Рисуй C4 Container diagram. В блоке ```uml-c4 (или ```c4) пиши только макросы C4: Person, System, Container, Rel. Без @startuml и без !include — схема добавит сама.",
|
||||
"includes": [
|
||||
"https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml"
|
||||
],
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml"
|
||||
},
|
||||
"c4context": {
|
||||
"title": "C4 Context",
|
||||
"version": "1.0.0",
|
||||
"author": "md2gost",
|
||||
"docs": "Person(alias, \"Label\")\nSystem(alias, \"Label\", \"descr?\")\nSystem_Ext(alias, \"Label\")\nSystem_Boundary(alias, \"Label\") { … }\nRel(from, to, \"label\")\nLAYOUT_WITH_LEGEND()",
|
||||
"ai-prompt": "Рисуй C4 System Context. В блоке ```uml-c4context только макросы C4 Context. Без @startuml и !include.",
|
||||
"includes": [
|
||||
"https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Context.puml"
|
||||
],
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml"
|
||||
},
|
||||
"c4component": {
|
||||
"title": "C4 Component",
|
||||
"version": "1.0.0",
|
||||
"author": "md2gost",
|
||||
"docs": "Container_Boundary(alias, \"Label\") { … }\nComponent(alias, \"Label\", \"techn\", \"descr?\")\nComponentDb(alias, \"Label\", \"techn\")\nRel(from, to, \"label\")\nLAYOUT_WITH_LEGEND()",
|
||||
"ai-prompt": "Рисуй C4 Component diagram. В блоке ```uml-c4component только макросы C4 Component. Без @startuml и !include.",
|
||||
"includes": [
|
||||
"https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml"
|
||||
],
|
||||
"prefix": "@startuml\n",
|
||||
"postfix": "\nLAYOUT_WITH_LEGEND()\n@enduml"
|
||||
},
|
||||
"usecase": {
|
||||
"title": "Прецеденты (Use Case)",
|
||||
"version": "1.0.0",
|
||||
"author": "md2gost",
|
||||
"docs": "left to right direction\nactor \"Имя\" as A\nrectangle Система {\n usecase \"Сценарий\" as UC1\n}\nA --> UC1\nUC1 .> UC2 : <<include>>\nUC3 .> UC1 : <<extend>>",
|
||||
"ai-prompt": "Рисуй UML Use Case. В блоке ```uml-usecase (или ```usecase) — actor, usecase, связи. Без @startuml — схема обернёт сама.",
|
||||
"includes": [],
|
||||
"prefix": "@startuml\nleft to right direction\nskinparam actorStyle awesome\n",
|
||||
"postfix": "\n@enduml"
|
||||
},
|
||||
"bpmn": {
|
||||
"title": "BPMN 2.0",
|
||||
"version": "1.0.0",
|
||||
"author": "md2gost",
|
||||
"docs": "Pool(alias, \"Участник\") { Lane(alias, \"Роль\") { … } }\nStart / StartMessage / StartTimer / StartSignal\nCatchMessage / ThrowMessage / CatchTimer / CatchError\nEnd / EndMessage / EndError / EndTerminate\nTask(alias, \"Имя\") / UserTask / ServiceTask / ScriptTask / ManualTask / SendTask / ReceiveTask / BusinessRuleTask\nSubProcess / SubProcessExpanded { … } / CallActivity\nXOR / AND / OR / EventBased / ComplexGW\nFlow / Flow_R/L/U/D / CondFlow(from, to, \"условие\") / DefaultFlow / MessageFlow\nDataObject / DataStore / Annotation\nBoundaryError + Attach(task, event)\nBPMN_VERTICAL() / BPMN_LEGEND()",
|
||||
"ai-prompt": "Рисуй BPMN 2.0 (OMG). В блоке ```uml-bpmn (или ```bpmn) только макросы библиотеки md2gost. Без @startuml и без !include.\n\nПравила: Sequence Flow (Flow) только внутри одного пула. Между пулами — MessageFlow. У процесса есть Start и хотя бы один End. Развилка и слияние — шлюзы (XOR/AND/OR), не рисуй ветвление со стрелок задачи без шлюза, если это решение. Подпись условия — на CondFlow/Flow от шлюза. DefaultFlow — ветка «иначе». Не используй activity-syntax (start/:task/;), mxgraph и чистый UML.\n\nМакросы:\nПул: Pool(alias, \"Имя\") { Lane(alias, \"Роль\") { ... } }\nСобытия: Start, StartMessage, StartTimer, StartSignal, StartCondition, Intermediate, CatchMessage, ThrowMessage, CatchTimer, CatchSignal, ThrowSignal, CatchError, End, EndMessage, EndError, EndTerminate, EndSignal\nЗадачи: Task, UserTask, ServiceTask, ScriptTask, ManualTask, SendTask, ReceiveTask, BusinessRuleTask, SubProcess, SubProcessExpanded { ... }, CallActivity\nШлюзы: Exclusive/XOR, Parallel/AND, Inclusive/OR, EventBased, ComplexGW\nПотоки: Flow, Flow_R, Flow_L, Flow_U, Flow_D, CondFlow(from,to,\"условие\"), DefaultFlow, MessageFlow, Assoc, DataAssoc\nДанные: DataObject, DataStore, Annotation\nГраница: BoundaryError/Timer/Message + Attach(task, event)\nМакет: Lay_R/L/U/D, BPMN_VERTICAL(), BPMN_LEGEND()\nПсевдонимы элементов — латиница (s, t1, gw, e_ok). Не называй alias зарезервированными словами end/start/group.",
|
||||
"includes": [
|
||||
"BPMN.puml"
|
||||
],
|
||||
"prefix": "@startuml\nleft to right direction\n",
|
||||
"postfix": "\n@enduml"
|
||||
}
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
"""File-drop helpers for the md2gost GUI (tkinterdnd2 or Windows WM_DROPFILES)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
DropCallback = Callable[[list[str]], None]
|
||||
|
||||
|
||||
def parse_tkdnd_files(data: str) -> list[str]:
|
||||
"""Parse a TkDND file list (`{C:\\a b.md} C:\\c.md`)."""
|
||||
files: list[str] = []
|
||||
text = (data or "").strip()
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
if text[i].isspace():
|
||||
i += 1
|
||||
continue
|
||||
if text[i] == "{":
|
||||
j = text.find("}", i + 1)
|
||||
if j < 0:
|
||||
files.append(text[i + 1 :])
|
||||
break
|
||||
files.append(text[i + 1 : j])
|
||||
i = j + 1
|
||||
continue
|
||||
j = i
|
||||
while j < n and not text[j].isspace():
|
||||
j += 1
|
||||
files.append(text[i:j])
|
||||
i = j
|
||||
return files
|
||||
|
||||
|
||||
def normalize_drop_paths(items: list[str] | str) -> list[str]:
|
||||
if isinstance(items, str):
|
||||
items = parse_tkdnd_files(items)
|
||||
out: list[str] = []
|
||||
for raw in items:
|
||||
if isinstance(raw, bytes):
|
||||
try:
|
||||
raw = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raw = raw.decode(sys.getfilesystemencoding() or "utf-8", errors="replace")
|
||||
path = raw.strip().strip('"')
|
||||
if not path:
|
||||
continue
|
||||
if path.lower().startswith("file:"):
|
||||
parsed = urlparse(path)
|
||||
path = unquote(parsed.path)
|
||||
if sys.platform == "win32" and path.startswith("/") and len(path) > 3 and path[2] == ":":
|
||||
path = path[1:]
|
||||
out.append(os.path.normpath(path))
|
||||
return out
|
||||
|
||||
|
||||
def first_markdown(paths: list[str]) -> str | None:
|
||||
for path in paths:
|
||||
if path.lower().endswith(".md") and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def enable_file_drop(widget, callback: DropCallback) -> str:
|
||||
"""Enable dropping files onto widget. Returns backend name: tkdnd | win32 | none."""
|
||||
def _deliver(items: list[str] | str) -> None:
|
||||
callback(normalize_drop_paths(items))
|
||||
|
||||
if _try_tkdnd(widget, _deliver):
|
||||
return "tkdnd"
|
||||
if sys.platform == "win32" and _WinDropHook.attach(widget, _deliver):
|
||||
return "win32"
|
||||
return "none"
|
||||
|
||||
|
||||
def _try_tkdnd(widget, deliver: DropCallback) -> bool:
|
||||
try:
|
||||
from tkinterdnd2 import DND_FILES
|
||||
except ImportError:
|
||||
return False
|
||||
register = getattr(widget, "drop_target_register", None)
|
||||
bind = getattr(widget, "dnd_bind", None)
|
||||
if register is None or bind is None:
|
||||
root = widget.winfo_toplevel()
|
||||
register = getattr(root, "drop_target_register", None)
|
||||
bind = getattr(root, "dnd_bind", None)
|
||||
target = root
|
||||
else:
|
||||
target = widget
|
||||
if register is None or bind is None:
|
||||
return False
|
||||
try:
|
||||
register(DND_FILES)
|
||||
bind("<<Drop>>", lambda event: deliver(getattr(event, "data", "") or ""))
|
||||
bind("<<DragEnter>>", lambda event: widget.event_generate("<<Md2GostDragEnter>>"))
|
||||
bind("<<DragLeave>>", lambda event: widget.event_generate("<<Md2GostDragLeave>>"))
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _WinDropHook:
|
||||
"""Subclass a Win32 HWND and accept WM_DROPFILES. Keep a strong ref on the widget."""
|
||||
|
||||
_hooks: list[_WinDropHook] = []
|
||||
|
||||
def __init__(self, widget, callback: DropCallback):
|
||||
self.widget = widget
|
||||
self.callback = callback
|
||||
self._pending: list[str] = []
|
||||
self._old_proc = None
|
||||
self._wndproc = None
|
||||
self._hwnd = 0
|
||||
self._alive = True
|
||||
|
||||
@classmethod
|
||||
def attach(cls, widget, callback: DropCallback) -> bool:
|
||||
try:
|
||||
toplevel = widget.winfo_toplevel()
|
||||
except Exception:
|
||||
toplevel = widget
|
||||
if getattr(toplevel, "_md2gost_dnd_hooked", False):
|
||||
return True
|
||||
|
||||
hook = cls(widget, callback)
|
||||
|
||||
def start(_event=None):
|
||||
if getattr(toplevel, "_md2gost_dnd_hooked", False):
|
||||
return
|
||||
if hook._install():
|
||||
toplevel._md2gost_dnd_hooked = True
|
||||
toplevel._md2gost_dnd_hook = hook
|
||||
cls._hooks.append(hook)
|
||||
|
||||
widget.bind("<Map>", start, add="+")
|
||||
widget.bind("<Destroy>", lambda e: hook._detach(), add="+")
|
||||
try:
|
||||
if widget.winfo_ismapped():
|
||||
start()
|
||||
else:
|
||||
widget.after_idle(start)
|
||||
except Exception:
|
||||
widget.after(200, start)
|
||||
return True
|
||||
|
||||
def _install(self) -> bool:
|
||||
try:
|
||||
api = _win32_drop_api()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
hwnd = _toplevel_hwnd(self.widget)
|
||||
if not hwnd:
|
||||
return False
|
||||
|
||||
WM_DROPFILES = 0x0233
|
||||
GWLP_WNDPROC = -4
|
||||
WS_EX_ACCEPTFILES = 0x00000010
|
||||
GWL_EXSTYLE = -20
|
||||
|
||||
def wndproc(hw, msg, wp, lp):
|
||||
if msg == WM_DROPFILES:
|
||||
try:
|
||||
hdrop = int(wp) if wp is not None else 0
|
||||
if hdrop:
|
||||
self._pending.extend(_query_drop_files(hdrop, api=api))
|
||||
api.DragFinish(hdrop)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
if self._old_proc:
|
||||
return api.CallWindowProc(self._old_proc, hw, msg, wp, lp)
|
||||
return api.DefWindowProc(hw, msg, wp, lp)
|
||||
|
||||
self._wndproc = api.WNDPROC(wndproc)
|
||||
try:
|
||||
ex = api.GetWindowLong(hwnd, GWL_EXSTYLE) or 0
|
||||
api.SetWindowLong(hwnd, GWL_EXSTYLE, int(ex) | WS_EX_ACCEPTFILES)
|
||||
api.DragAcceptFiles(hwnd, True)
|
||||
self._old_proc = api.GetWindowLongPtr(hwnd, GWLP_WNDPROC)
|
||||
api.SetWindowLongPtr(hwnd, GWLP_WNDPROC, api.as_ptr(self._wndproc))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
self._hwnd = hwnd
|
||||
self._poll()
|
||||
return True
|
||||
|
||||
def _poll(self) -> None:
|
||||
if not self._alive:
|
||||
return
|
||||
if self._pending:
|
||||
files = self._pending[:]
|
||||
self._pending.clear()
|
||||
try:
|
||||
self.callback(files)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.widget.after(120, self._poll)
|
||||
except Exception:
|
||||
self._alive = False
|
||||
|
||||
def _detach(self) -> None:
|
||||
self._alive = False
|
||||
if not self._hwnd or self._old_proc is None:
|
||||
return
|
||||
try:
|
||||
api = _win32_drop_api()
|
||||
api.SetWindowLongPtr(self._hwnd, -4, self._old_proc)
|
||||
except Exception:
|
||||
pass
|
||||
self._old_proc = None
|
||||
|
||||
|
||||
class _Win32DropApi:
|
||||
def __init__(self):
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
self._ctypes = ctypes
|
||||
is64 = ctypes.sizeof(ctypes.c_void_p) == 8
|
||||
# wintypes.WPARAM/LPARAM historically were 32-bit; force pointer width.
|
||||
WPARAM = ctypes.c_uint64 if is64 else ctypes.c_uint
|
||||
LPARAM = ctypes.c_int64 if is64 else ctypes.c_long
|
||||
HWND = ctypes.c_void_p
|
||||
LRESULT = ctypes.c_int64 if is64 else ctypes.c_long
|
||||
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||
|
||||
get_ptr = user32.GetWindowLongPtrW if is64 else user32.GetWindowLongW
|
||||
set_ptr = user32.SetWindowLongPtrW if is64 else user32.SetWindowLongW
|
||||
get_ptr.argtypes = [HWND, ctypes.c_int]
|
||||
get_ptr.restype = ctypes.c_void_p
|
||||
set_ptr.argtypes = [HWND, ctypes.c_int, ctypes.c_void_p]
|
||||
set_ptr.restype = ctypes.c_void_p
|
||||
|
||||
# GWL_EXSTYLE is a 32-bit style mask; Get/SetWindowLongW is enough.
|
||||
get_long = user32.GetWindowLongW
|
||||
set_long = user32.SetWindowLongW
|
||||
get_long.argtypes = [HWND, ctypes.c_int]
|
||||
get_long.restype = ctypes.c_long
|
||||
set_long.argtypes = [HWND, ctypes.c_int, ctypes.c_long]
|
||||
set_long.restype = ctypes.c_long
|
||||
|
||||
call_proc = user32.CallWindowProcW
|
||||
call_proc.argtypes = [ctypes.c_void_p, HWND, wintypes.UINT, WPARAM, LPARAM]
|
||||
call_proc.restype = LRESULT
|
||||
|
||||
def_proc = user32.DefWindowProcW
|
||||
def_proc.argtypes = [HWND, wintypes.UINT, WPARAM, LPARAM]
|
||||
def_proc.restype = LRESULT
|
||||
|
||||
accept = shell32.DragAcceptFiles
|
||||
accept.argtypes = [HWND, wintypes.BOOL]
|
||||
accept.restype = None
|
||||
|
||||
finish = shell32.DragFinish
|
||||
finish.argtypes = [ctypes.c_void_p]
|
||||
finish.restype = None
|
||||
|
||||
query = shell32.DragQueryFileW
|
||||
query.argtypes = [ctypes.c_void_p, wintypes.UINT, ctypes.c_wchar_p, wintypes.UINT]
|
||||
query.restype = wintypes.UINT
|
||||
|
||||
self.shell32 = shell32
|
||||
self.WNDPROC = ctypes.WINFUNCTYPE(LRESULT, HWND, wintypes.UINT, WPARAM, LPARAM)
|
||||
self.GetWindowLongPtr = get_ptr
|
||||
self.SetWindowLongPtr = set_ptr
|
||||
self.GetWindowLong = get_long
|
||||
self.SetWindowLong = set_long
|
||||
self.CallWindowProc = call_proc
|
||||
self.DefWindowProc = def_proc
|
||||
self.DragAcceptFiles = accept
|
||||
self.DragFinish = finish
|
||||
self.DragQueryFileW = query
|
||||
|
||||
def as_ptr(self, wndproc):
|
||||
return self._ctypes.cast(wndproc, self._ctypes.c_void_p).value
|
||||
|
||||
|
||||
_WIN32_DROP_API = None
|
||||
|
||||
|
||||
def _win32_drop_api() -> _Win32DropApi:
|
||||
global _WIN32_DROP_API
|
||||
if _WIN32_DROP_API is None:
|
||||
_WIN32_DROP_API = _Win32DropApi()
|
||||
return _WIN32_DROP_API
|
||||
|
||||
|
||||
def _toplevel_hwnd(widget) -> int:
|
||||
try:
|
||||
import ctypes
|
||||
hwnd = int(widget.winfo_id())
|
||||
GA_ROOT = 2
|
||||
root = ctypes.windll.user32.GetAncestor(hwnd, GA_ROOT)
|
||||
if root:
|
||||
return int(root)
|
||||
parent = ctypes.windll.user32.GetParent(hwnd)
|
||||
return int(parent or hwnd)
|
||||
except Exception:
|
||||
try:
|
||||
return int(widget.winfo_id())
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _query_drop_files(hdrop: int, api: _Win32DropApi | None = None) -> list[str]:
|
||||
import ctypes
|
||||
|
||||
query = (api or _win32_drop_api()).DragQueryFileW
|
||||
count = query(hdrop, 0xFFFFFFFF, None, 0)
|
||||
files: list[str] = []
|
||||
for i in range(count):
|
||||
length = query(hdrop, i, None, 0)
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
query(hdrop, i, buf, length + 1)
|
||||
if buf.value:
|
||||
files.append(buf.value)
|
||||
return files
|
||||
@@ -24,6 +24,7 @@ __all__ = [
|
||||
"create_table_row",
|
||||
"create_table_cell",
|
||||
"apply_cell_merge",
|
||||
"set_table_box_borders",
|
||||
]
|
||||
|
||||
|
||||
@@ -117,3 +118,27 @@ def apply_cell_merge(cell: _Cell, v_merge: str | None = None, grid_span: int | N
|
||||
tcPr.append(create_element("w:vMerge"))
|
||||
if grid_span and grid_span > 1:
|
||||
tcPr.append(create_element("w:gridSpan", {"w:val": str(grid_span)}))
|
||||
|
||||
|
||||
def set_table_box_borders(table: Table, *, sz: str = "4", color: str = "000000") -> None:
|
||||
"""Outer frame only — no inside H/V lines (listings look like text in a box)."""
|
||||
tbl = table._tbl
|
||||
tblPr = tbl.tblPr
|
||||
if tblPr is None:
|
||||
tblPr = create_element("w:tblPr")
|
||||
tbl.insert(0, tblPr)
|
||||
for el in list(tblPr.findall(qn("w:tblBorders"))):
|
||||
tblPr.remove(el)
|
||||
solid = {"w:val": "single", "w:sz": sz, "w:space": "0", "w:color": color}
|
||||
none = {"w:val": "nil"}
|
||||
borders = create_element("w:tblBorders")
|
||||
for edge, attrs in (
|
||||
("w:top", solid),
|
||||
("w:left", solid),
|
||||
("w:bottom", solid),
|
||||
("w:right", solid),
|
||||
("w:insideH", none),
|
||||
("w:insideV", none),
|
||||
):
|
||||
borders.append(create_element(edge, attrs))
|
||||
tblPr.append(borders)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Attach SVG as Word svgBlip alongside a PNG InlineShape (Office 2016+)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
||||
from docx.opc.part import Part
|
||||
from docx.oxml import OxmlElement
|
||||
from docx.oxml.ns import qn
|
||||
from lxml import etree
|
||||
|
||||
SVG_CONTENT_TYPE = "image/svg+xml"
|
||||
SVG_BLIP_URI = "{96DAC541-7B7A-43C6-8E14-B03AE682B59D}"
|
||||
ASVG_NS = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"
|
||||
ASVG_SVG_BLIP = f"{{{ASVG_NS}}}svgBlip"
|
||||
|
||||
|
||||
def attach_svg_blip(run, inline_shape, svg_path: str | Path) -> bool:
|
||||
"""
|
||||
After run.add_picture(png), add an SVG part and asvg:svgBlip on the PNG blip.
|
||||
Word 2016+ uses the vector; older apps keep the PNG.
|
||||
Returns True if the SVG was attached.
|
||||
"""
|
||||
path = Path(svg_path)
|
||||
if not path.is_file() or path.stat().st_size <= 0:
|
||||
return False
|
||||
data = path.read_bytes()
|
||||
head = data.lstrip()[:200].lower()
|
||||
if not (head.startswith(b"<svg") or head.startswith(b"<?xml") or b"<svg" in head):
|
||||
return False
|
||||
|
||||
part = run.part
|
||||
package = part.package
|
||||
partname = package.next_partname("/word/media/image%d.svg")
|
||||
svg_part = Part(partname, SVG_CONTENT_TYPE, data, package)
|
||||
r_id = part.relate_to(svg_part, RT.IMAGE)
|
||||
|
||||
blip = inline_shape._inline.graphic.graphicData.pic.blipFill.blip
|
||||
ext_lst = blip.find(qn("a:extLst"))
|
||||
if ext_lst is None:
|
||||
ext_lst = OxmlElement("a:extLst")
|
||||
blip.append(ext_lst)
|
||||
|
||||
# Drop any previous svgBlip extension with the same URI.
|
||||
for ext in list(ext_lst.findall(qn("a:ext"))):
|
||||
if ext.get("uri") == SVG_BLIP_URI:
|
||||
ext_lst.remove(ext)
|
||||
|
||||
ext = OxmlElement("a:ext")
|
||||
ext.set("uri", SVG_BLIP_URI)
|
||||
svg_blip = etree.SubElement(ext, ASVG_SVG_BLIP)
|
||||
svg_blip.set(qn("r:embed"), r_id)
|
||||
ext_lst.append(ext)
|
||||
return True
|
||||
@@ -4,23 +4,38 @@ from re import Match, compile as re_compile
|
||||
|
||||
|
||||
_LISTING_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?listing\b")
|
||||
_LANDSCAPE_FLAG_RE = re_compile(r"(?i)(?:^|\s)\+?landscape\b")
|
||||
|
||||
|
||||
def strip_caption_flags(raw: str) -> tuple[str | None, bool, bool]:
|
||||
"""Return (clean_text, with_listing, landscape) from caption / image-title tail."""
|
||||
text = (raw or "").strip()
|
||||
with_listing = bool(_LISTING_FLAG_RE.search(text))
|
||||
landscape = bool(_LANDSCAPE_FLAG_RE.search(text))
|
||||
if with_listing:
|
||||
text = _LISTING_FLAG_RE.sub(" ", text)
|
||||
if landscape:
|
||||
text = _LANDSCAPE_FLAG_RE.sub(" ", text)
|
||||
text = " ".join(text.split()).strip()
|
||||
return (text or None), with_listing, landscape
|
||||
|
||||
|
||||
class Caption(BlockElement):
|
||||
"""Represents caption element
|
||||
|
||||
Syntax: %label Caption text [+listing]
|
||||
Syntax: %label Caption text [+listing] [+landscape]
|
||||
"""
|
||||
|
||||
# Interrupt an open paragraph so «текст:\\n%id …» still becomes a Caption
|
||||
# (otherwise marko treats the % line as paragraph continuation / plain text).
|
||||
breaks_paragraph = True
|
||||
priority = 6
|
||||
pattern = r"\%(\w+)( (.+))?"
|
||||
|
||||
def __init__(self, match: Match[str]):
|
||||
self.unique_name = match.group(1)
|
||||
raw = (match.group(3) or "").strip()
|
||||
self.with_listing = bool(_LISTING_FLAG_RE.search(raw))
|
||||
if self.with_listing:
|
||||
raw = _LISTING_FLAG_RE.sub(" ", raw).strip()
|
||||
self.text = raw or None
|
||||
self.text, self.with_listing, self.landscape = strip_caption_flags(raw)
|
||||
|
||||
@classmethod
|
||||
def match(cls, source: Source) -> Match[str] | None:
|
||||
|
||||
@@ -2,6 +2,8 @@ import re
|
||||
|
||||
from marko.inline import Image as Image_
|
||||
|
||||
from .caption import strip_caption_flags
|
||||
|
||||
|
||||
class Image(Image_):
|
||||
override = True
|
||||
@@ -9,7 +11,11 @@ class Image(Image_):
|
||||
super().__init__(match)
|
||||
|
||||
self.unique_name = None
|
||||
self.with_listing = False
|
||||
self.landscape = False
|
||||
|
||||
if self.title and (m := re.match(r"\%(\w+)( (.+))?", self.title)):
|
||||
self.unique_name = m.group(1)
|
||||
self.title = (m.group(3) or "").strip() or None
|
||||
self.title, self.with_listing, self.landscape = strip_caption_flags(
|
||||
(m.group(3) or "").strip()
|
||||
)
|
||||
|
||||
+1260
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
"""Usage text and AI prompt files for the GUI pages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import package_dir
|
||||
|
||||
USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
|
||||
|
||||
КАК ПОЛЬЗОВАТЬСЯ ОКНОМ
|
||||
1. Перетащите .md в верхнюю область (или кликните по ней).
|
||||
2. Выберите тип документа и параметры в блоке «Основные».
|
||||
Шаблон / титул / задание — Настройки → Файлы.
|
||||
PlantUML / Kroki — Настройки → Диаграммы.
|
||||
Свои UML-схемы — меню «Шаблоны UML».
|
||||
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
|
||||
Дебаг (меню сверху) — следующая сборка с отладочными данными в документе.
|
||||
|
||||
Типы: practice (по умолчанию) / coursework / vkr — ГОСТ МИРЭА; PIS_custom — отчёт по практикам; APID_coursework — курсовая АПИД.
|
||||
|
||||
Полезные галочки
|
||||
• «—» → «-» — заменить типографское тире на дефис (по умолчанию **выкл.**; методичка требует «—»).
|
||||
• --- → разрыв страницы — по умолчанию выкл. (строка --- игнорируется). Вкл. — page break в Word.
|
||||
• Проверить по ТЗ — замечания по структуре, «рис.», источникам.
|
||||
• Проверить вёрстку в Word — полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32).
|
||||
• Титул / задание — отдельные DOCX, вставляются перед телом отчёта.
|
||||
|
||||
CLI (тот же движок)
|
||||
python -m md2gost report.md -o report.docx --type coursework --check
|
||||
python -m md2gost --gui
|
||||
md2gost.exe report.md --type PIS_custom --title title.docx
|
||||
md2gost.exe report.md --schemes path/to/md2gost.schemes.json
|
||||
|
||||
СИНТАКСИС MARKDOWN
|
||||
|
||||
Спецразделы (без номера, ПРОПИСНЫЕ, звёздочка):
|
||||
# *СОДЕРЖАНИЕ
|
||||
[TOC]
|
||||
# *ВВЕДЕНИЕ
|
||||
# *ЗАКЛЮЧЕНИЕ
|
||||
# *СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ
|
||||
# *ПРИЛОЖЕНИЯ
|
||||
(после ПРИЛОЖЕНИЯ — перечень основным текстом, затем ## Приложение А Название)
|
||||
|
||||
Нумерованные разделы: # 1 Название ## 1.1 Подраздел
|
||||
Точку в конце заголовка не ставить.
|
||||
СОДЕРЖАНИЕ и СПИСОК — по центру; Введение / Заключение / ПРИЛОЖЕНИЯ — слева.
|
||||
|
||||
Рисунок
|
||||
Текст со ссылкой на @Рисунок:arch.
|
||||

|
||||
В тексте пишите «Рисунок», не «рис.»
|
||||
|
||||
Таблица
|
||||
%tbl1 Название
|
||||
| A | B |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
Ссылка: @Таблица:tbl1
|
||||
Склеивание: ^ — ячейка сверху (rowspan), > — ячейка слева (colspan).
|
||||
Не ставить ^/> в шапке; > — не в первом столбце.
|
||||
|
||||
Листинг
|
||||
%code1 Название
|
||||
```python
|
||||
print("ok")
|
||||
```
|
||||
|
||||
Диаграмма (PlantUML / Mermaid / схемы) → рисунок
|
||||
%usecase1 Диаграмма прецедентов +listing
|
||||
```uml
|
||||
@startuml
|
||||
actor User
|
||||
User --> (Login)
|
||||
@enduml
|
||||
```
|
||||
|
||||
Широкая схема/таблица на альбомной странице:
|
||||
%arch1 Архитектура +landscape
|
||||
```uml-c4
|
||||
…
|
||||
```
|
||||
|
||||
Mermaid (через Kroki, свой URL или kroki.io):
|
||||
%seq Последовательность +listing
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
Alice->>Bob: hello
|
||||
```
|
||||
|
||||
+listing — ещё и листинг с исходником.
|
||||
+landscape — отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная.
|
||||
Языки: uml, plantuml, mermaid / mmd, или uml-<id> / <id> из файла схем (c4, usecase, bpmn, …).
|
||||
BPMN 2.0: оградка ```bpmn / ```uml-bpmn, макросы Start, UserTask, XOR, Flow, Pool.
|
||||
Формат в Word: PNG по умолчанию (PlantUML рисуется ~2× для чёткости); --diagram-format svg — вектор + PNG-запасной (Word 2016+).
|
||||
IDEF0 конвертер не рисует — вставляйте готовый PNG как обычный Рисунок.
|
||||
Подробнее — Справка → Схемы и раздел ниже в инструкции.
|
||||
|
||||
Формула (номер только если есть ссылка)
|
||||
%eq1
|
||||
$$ E = mc^2 $$
|
||||
См. @Формула:eq1
|
||||
|
||||
Источники
|
||||
В тексте: [1]
|
||||
В списке: [1]: Иванов И. И. Название. — М.: Наука, 2023. — 120 с.
|
||||
|
||||
Разрыв страницы
|
||||
По умолчанию --- игнорируется.
|
||||
Галочка «--- → разрыв страницы» или --hr-pagebreak: пустая строка, ---, пустая строка.
|
||||
|
||||
Нумерация заголовков
|
||||
manual — цифры уже в md (# 1 …); auto — нумерует Word.
|
||||
|
||||
Содержание
|
||||
native — поле Word (обновить при открытии); manual — собирает md2gost.
|
||||
|
||||
Продолжение таблиц / листингов
|
||||
word — после сборки Word COM режет по реальной пагинации и вставляет «Продолжение…»
|
||||
(по умолчанию; нужны Windows + Word + pywin32).
|
||||
off — не резать, Word сам переносит.
|
||||
legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word).
|
||||
|
||||
Промпт для ИИ — Справка → Промпт для ИИ: скопируйте и вставьте в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
|
||||
"""
|
||||
|
||||
SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
|
||||
|
||||
Зачем
|
||||
В markdown пишете только «тело» диаграммы. Обёртка (@startuml, !include, тема)
|
||||
берётся из схемы в файле md2gost.schemes.json.
|
||||
|
||||
Первый запуск
|
||||
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
|
||||
создаётся md2gost.schemes.json из встроенного шаблона.
|
||||
Если файл уже есть — программа его не перезаписывает (ваши схемы сохраняются).
|
||||
|
||||
Оградка в markdown
|
||||
```uml — обычный PlantUML (или ```plantuml)
|
||||
```uml-c4 — схема с id «c4» (то же, что ```c4)
|
||||
```uml-usecase — схема «usecase»
|
||||
```bpmn / ```uml-bpmn — BPMN 2.0 (макросы в diagrams/BPMN.puml)
|
||||
```mermaid / ```mmd — Mermaid через Kroki (не PlantUML)
|
||||
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn.
|
||||
|
||||
Пример
|
||||
|
||||
%arch Архитектура +listing
|
||||
|
||||
```uml-c4
|
||||
Person(user, "Студент")
|
||||
System(app, "Портал")
|
||||
Rel(user, app, "логин")
|
||||
```
|
||||
|
||||
```bpmn
|
||||
StartMessage(s, "заявка")
|
||||
UserTask(t, "Проверить")
|
||||
XOR(gw)
|
||||
End(e_ok)
|
||||
End(e_no)
|
||||
Flow(s, t)
|
||||
Flow(t, gw)
|
||||
CondFlow(gw, e_ok, "да")
|
||||
DefaultFlow(gw, e_no)
|
||||
```
|
||||
|
||||
Поля схемы в JSON
|
||||
title — подпись в GUI
|
||||
version — версия схемы
|
||||
author — автор
|
||||
docs — шпаргалка синтаксиса (чтобы вспомнить макросы)
|
||||
ai-prompt — заготовка промпта для ИИ / будущего MCP
|
||||
includes — список файлов или http(s):// URL на .puml
|
||||
prefix — текст перед телом (часто @startuml)
|
||||
postfix — текст после тела (часто @enduml)
|
||||
theme — опционально !theme …
|
||||
|
||||
Свои схемы
|
||||
1. Меню «Шаблоны UML» в GUI — добавьте / отредактируйте и сохраните.
|
||||
2. Или откройте md2gost.schemes.json в редакторе («Открыть JSON» / «Открыть файл схем»).
|
||||
3. CLI: --schemes путь.json; также подхватывается md2gost.schemes.json рядом с .md.
|
||||
|
||||
Кэш includes из интернета
|
||||
URL из includes (и !include https://… внутри .puml) при первом рендере
|
||||
скачиваются. Индекс — md2gost.include-cache.json (только пары URL → файл).
|
||||
Файлы лежат в папке include-cache/. Уже скачанные файлы не перезаписываются.
|
||||
Повторный рендер без сети берёт путь из индекса.
|
||||
«Сбросить кэш includes» в Настройки → Диаграммы или в «Шаблоны UML» удаляет индекс
|
||||
и только файлы, перечисленные в нём.
|
||||
|
||||
BPMN 2.0 (```bpmn)
|
||||
Пул: Pool(alias, "Участник") { Lane(alias, "Роль") { … } }
|
||||
События: Start, StartMessage, StartTimer, CatchMessage, ThrowMessage,
|
||||
CatchTimer, CatchError, End, EndMessage, EndError, EndTerminate
|
||||
Задачи: Task / UserTask / ServiceTask / ScriptTask / ManualTask /
|
||||
SendTask / ReceiveTask / BusinessRuleTask / SubProcess
|
||||
Шлюзы: XOR (Exclusive), AND (Parallel), OR (Inclusive), EventBased
|
||||
Потоки: Flow, CondFlow(from, to, "условие"), DefaultFlow, MessageFlow
|
||||
Данные: DataObject, DataStore, Annotation
|
||||
Граница: BoundaryError/Timer/Message + Attach(task, event)
|
||||
Sequence Flow только внутри пула; между пулами — MessageFlow.
|
||||
|
||||
Чего нет
|
||||
IDEF0 / DFD — PlantUML не умеет; вставляйте готовый PNG как Рисунок.
|
||||
У BPMN нет «прилипания» boundary-события к кромке задачи (ставьте Attach)
|
||||
и нет настоящей двойной окружности у intermediate (толщина линии).
|
||||
|
||||
Рендер
|
||||
UML: Java + plantuml.jar (локально) → иначе локальный Kroki → иначе kroki.io.
|
||||
Mermaid: только Kroki (свой --kroki-url / localhost / kroki.io).
|
||||
Формат: PNG по умолчанию (PlantUML ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg — PNG + SVG (svgBlip в Word 2016+).
|
||||
+landscape у %подписи — альбомная страница под широкий рисунок/таблицу.
|
||||
"""
|
||||
|
||||
PROMPT_FILES = (
|
||||
("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"),
|
||||
("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"),
|
||||
)
|
||||
|
||||
|
||||
def prompt_search_dirs() -> list[Path]:
|
||||
dirs: list[Path] = []
|
||||
here = Path(package_dir())
|
||||
dirs.append(here / "prompts")
|
||||
dirs.append(here.parent / "prompts")
|
||||
if getattr(sys, "frozen", False):
|
||||
mei = getattr(sys, "_MEIPASS", None)
|
||||
if mei:
|
||||
dirs.append(Path(mei) / "prompts")
|
||||
dirs.append(Path(sys.executable).resolve().parent / "prompts")
|
||||
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 load_prompt_catalog() -> list[tuple[str, str, str]]:
|
||||
"""Return list of (filename, title, text). Missing files are skipped."""
|
||||
catalog: list[tuple[str, str, str]] = []
|
||||
dirs = prompt_search_dirs()
|
||||
for name, title in PROMPT_FILES:
|
||||
text = None
|
||||
for folder in dirs:
|
||||
candidate = folder / name
|
||||
if candidate.is_file():
|
||||
text = candidate.read_text(encoding="utf-8")
|
||||
break
|
||||
if text:
|
||||
catalog.append((name, title, text))
|
||||
return catalog
|
||||
@@ -51,3 +51,13 @@ class LayoutTracker:
|
||||
|
||||
def new_page(self):
|
||||
self._state.new_page()
|
||||
|
||||
def set_page_size(self, max_height: Length, max_width: Length) -> None:
|
||||
"""Switch usable area (e.g. portrait ↔ landscape) and start a fresh page."""
|
||||
self._state.max_height = max_height
|
||||
self._state.max_width = max_width
|
||||
# Align to a page boundary under the *new* geometry.
|
||||
rem = self._state.remaining_page_height
|
||||
if rem != max_height:
|
||||
self._state.add_height(rem)
|
||||
self._is_new_page = True
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Post-conversion heuristic: half-empty pages inside an unfinished section.
|
||||
|
||||
Requires Windows + Microsoft Word + pywin32. Findings are ALWAYS heuristic —
|
||||
false positives are expected (large figures, end of subsection, odd breaks).
|
||||
Never treat as --strict errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Unused fraction of the text area below the last content on the page.
|
||||
EMPTY_FRACTION_THRESHOLD = 0.40
|
||||
|
||||
FRONT_MATTER_HINTS = (
|
||||
"СОДЕРЖАНИЕ",
|
||||
"ТИТУЛ",
|
||||
"ЗАДАНИЕ",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageMetric:
|
||||
"""Synthetic / measured metrics for one page (CI-friendly)."""
|
||||
|
||||
page_index: int # 1-based
|
||||
content_bottom_frac: float # 0..1, how far down the text area content reaches
|
||||
section_title: str
|
||||
next_section_title: str | None
|
||||
is_landscape: bool = False
|
||||
is_last_doc_page: bool = False
|
||||
mostly_figure_or_table: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageFillIssue:
|
||||
page_index: int
|
||||
message: str
|
||||
severity: str = "heuristic"
|
||||
|
||||
|
||||
def evaluate_page_fill(
|
||||
pages: list[PageMetric],
|
||||
*,
|
||||
empty_threshold: float = EMPTY_FRACTION_THRESHOLD,
|
||||
) -> list[PageFillIssue]:
|
||||
"""Pure heuristic over page metrics (no Word). Safe for unit tests."""
|
||||
issues: list[PageFillIssue] = []
|
||||
for i, page in enumerate(pages):
|
||||
if page.is_landscape:
|
||||
continue
|
||||
if page.mostly_figure_or_table:
|
||||
continue
|
||||
if page.is_last_doc_page:
|
||||
continue
|
||||
title_u = (page.section_title or "").upper()
|
||||
if any(h in title_u for h in FRONT_MATTER_HINTS):
|
||||
continue
|
||||
# Last page of this section (next page starts a different H1)
|
||||
if page.next_section_title and page.next_section_title != page.section_title:
|
||||
continue
|
||||
empty_frac = 1.0 - page.content_bottom_frac
|
||||
if empty_frac <= empty_threshold:
|
||||
continue
|
||||
# Same section continues on the next page
|
||||
if i + 1 >= len(pages):
|
||||
continue
|
||||
nxt = pages[i + 1]
|
||||
if nxt.section_title != page.section_title:
|
||||
continue
|
||||
issues.append(PageFillIssue(
|
||||
page_index=page.page_index,
|
||||
message=(
|
||||
f"[heuristic] page.fill: на стр. {page.page_index} пустой низ "
|
||||
f"~{empty_frac:.0%} полосы, а раздел «{page.section_title}» "
|
||||
f"продолжается на следующей странице. "
|
||||
f"Проверка эвристическая — возможны ложные срабатывания "
|
||||
f"(крупный рисунок, конец пункта, нестандартный разрыв)."
|
||||
),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_docx_page_fill(path: str) -> tuple[list[PageFillIssue], str]:
|
||||
"""
|
||||
Open DOCX in Word, repaginate, collect metrics, evaluate.
|
||||
|
||||
Returns (issues, status_message). On missing Word/pywin32 returns ([], reason).
|
||||
"""
|
||||
try:
|
||||
import win32com.client # type: ignore
|
||||
except ImportError:
|
||||
return [], (
|
||||
"Проверка вёрстки пропущена: нужен пакет pywin32 "
|
||||
"(pip install pywin32) и Microsoft Word."
|
||||
)
|
||||
|
||||
word = None
|
||||
doc = None
|
||||
try:
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
abs_path = str(path)
|
||||
doc = word.Documents.Open(abs_path, ReadOnly=True)
|
||||
doc.Repaginate()
|
||||
|
||||
pages_metrics: list[PageMetric] = []
|
||||
page_count = int(doc.ComputeStatistics(2)) # wdStatisticPages
|
||||
for page_no in range(1, page_count + 1):
|
||||
try:
|
||||
metric = _measure_page(doc, page_no, page_count)
|
||||
except Exception:
|
||||
continue
|
||||
if metric is not None:
|
||||
pages_metrics.append(metric)
|
||||
|
||||
issues = evaluate_page_fill(pages_metrics)
|
||||
if not issues:
|
||||
return [], (
|
||||
"Проверка вёрстки (Word): замечаний по полупустым страницам нет "
|
||||
"(эвристика; возможны пропуски)."
|
||||
)
|
||||
return issues, (
|
||||
f"Проверка вёрстки (Word): найдено замечаний — {len(issues)} "
|
||||
f"(все эвристические, могут быть ложными)."
|
||||
)
|
||||
except Exception as exc:
|
||||
return [], f"Проверка вёрстки пропущена: не удалось открыть Word ({exc})."
|
||||
finally:
|
||||
try:
|
||||
if doc is not None:
|
||||
doc.Close(False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if word is not None:
|
||||
word.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _measure_page(doc, page_no: int, page_count: int) -> PageMetric | None:
|
||||
"""Best-effort measurement via Word COM selection / page setup."""
|
||||
selection = doc.Application.Selection
|
||||
selection.GoTo(What=1, Which=1, Count=page_no) # wdGoToPage, wdGoToAbsolute
|
||||
|
||||
section = selection.Sections(1)
|
||||
ps = section.PageSetup
|
||||
is_landscape = bool(int(ps.Orientation) == 1) # wdOrientLandscape
|
||||
|
||||
page_h = float(ps.PageHeight)
|
||||
top = float(ps.TopMargin)
|
||||
bottom = float(ps.BottomMargin)
|
||||
text_h = max(page_h - top - bottom, 1.0)
|
||||
|
||||
start = int(selection.Start)
|
||||
if page_no < page_count:
|
||||
selection.GoTo(What=1, Which=1, Count=page_no + 1)
|
||||
end = int(selection.Start) - 1
|
||||
else:
|
||||
end = int(doc.Content.End)
|
||||
if end < start:
|
||||
end = start
|
||||
rng = doc.Range(start, end)
|
||||
|
||||
try:
|
||||
# wdVerticalPositionRelativeToPage = 6
|
||||
vpos = float(rng.Information(6))
|
||||
content_bottom = max(0.0, min(1.0, (vpos - top) / text_h))
|
||||
except Exception:
|
||||
content_bottom = 1.0
|
||||
|
||||
section_title = _heading_near(doc, start)
|
||||
next_title = None
|
||||
if page_no < page_count:
|
||||
try:
|
||||
selection.GoTo(What=1, Which=1, Count=page_no + 1)
|
||||
next_title = _heading_near(doc, int(selection.Start))
|
||||
except Exception:
|
||||
next_title = None
|
||||
|
||||
mostly_object = False
|
||||
try:
|
||||
text_len = len((rng.Text or "").strip())
|
||||
if rng.Tables.Count >= 1 and text_len < 80:
|
||||
mostly_object = True
|
||||
if rng.InlineShapes.Count >= 1 and text_len < 80:
|
||||
mostly_object = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return PageMetric(
|
||||
page_index=page_no,
|
||||
content_bottom_frac=content_bottom,
|
||||
section_title=section_title or "",
|
||||
next_section_title=next_title,
|
||||
is_landscape=is_landscape,
|
||||
is_last_doc_page=(page_no == page_count),
|
||||
mostly_figure_or_table=mostly_object,
|
||||
)
|
||||
|
||||
|
||||
def _heading_near(doc, pos: int) -> str:
|
||||
"""Walk backwards for nearest Heading 1 style paragraph."""
|
||||
try:
|
||||
p = doc.Range(pos, pos).Paragraphs(1)
|
||||
for _ in range(80):
|
||||
style = str(p.Style)
|
||||
if "Heading 1" in style or "Заголовок 1" in style:
|
||||
return (p.Range.Text or "").strip().replace("\r", "")
|
||||
if p.Range.Start <= 1:
|
||||
break
|
||||
p = p.Previous()
|
||||
if p is None:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def format_page_fill_report(issues: list[PageFillIssue], status: str) -> str:
|
||||
lines = [status]
|
||||
for i in issues:
|
||||
lines.append(f" [{i.severity}] page:{i.page_index}: {i.message}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""A4 page geometry helpers for portrait / landscape sections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from docx.enum.section import WD_ORIENT
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.oxml.ns import qn
|
||||
from docx.shared import Mm, Pt
|
||||
|
||||
from .util import create_element
|
||||
|
||||
# GOST-like A4 margins (same as styles._apply_common_page_and_body)
|
||||
MARGIN_LEFT = Mm(30)
|
||||
MARGIN_RIGHT = Mm(10)
|
||||
MARGIN_TOP = Mm(20)
|
||||
MARGIN_BOTTOM = Mm(20)
|
||||
|
||||
A4_SHORT = Mm(210)
|
||||
A4_LONG = Mm(297)
|
||||
|
||||
|
||||
def is_landscape_section(section) -> bool:
|
||||
"""True if section is (or should be treated as) landscape A4."""
|
||||
try:
|
||||
if section.orientation == WD_ORIENT.LANDSCAPE:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return int(section.page_width) > int(section.page_height)
|
||||
|
||||
|
||||
def apply_section_geometry(section, *, landscape: bool) -> None:
|
||||
"""
|
||||
Set orientation and page size.
|
||||
|
||||
Set orientation first (python-docx may swap w/h on change), then force A4 dims.
|
||||
"""
|
||||
target = WD_ORIENT.LANDSCAPE if landscape else WD_ORIENT.PORTRAIT
|
||||
try:
|
||||
section.orientation = target
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if landscape:
|
||||
section.page_width = A4_LONG
|
||||
section.page_height = A4_SHORT
|
||||
else:
|
||||
section.page_width = A4_SHORT
|
||||
section.page_height = A4_LONG
|
||||
|
||||
section.left_margin = MARGIN_LEFT
|
||||
section.right_margin = MARGIN_RIGHT
|
||||
section.top_margin = MARGIN_TOP
|
||||
section.bottom_margin = MARGIN_BOTTOM
|
||||
|
||||
# Explicit orient on pgSz for Word.
|
||||
pg_sz = section._sectPr.find(qn("w:pgSz"))
|
||||
if pg_sz is None:
|
||||
pg_sz = section._sectPr._add_pgSz()
|
||||
if landscape:
|
||||
pg_sz.set(qn("w:orient"), "landscape")
|
||||
# Re-assert after XML tweak (some builds reshuffle).
|
||||
section.page_width = A4_LONG
|
||||
section.page_height = A4_SHORT
|
||||
else:
|
||||
if pg_sz.get(qn("w:orient")) is not None:
|
||||
del pg_sz.attrib[qn("w:orient")]
|
||||
section.page_width = A4_SHORT
|
||||
section.page_height = A4_LONG
|
||||
|
||||
# Vertical align: center on landscape (figures/tables only); top on portrait
|
||||
sect_pr = section._sectPr
|
||||
for el in list(sect_pr.findall(qn("w:vAlign"))):
|
||||
sect_pr.remove(el)
|
||||
if landscape:
|
||||
sect_pr.append(create_element("w:vAlign", {"w:val": "center"}))
|
||||
|
||||
|
||||
def content_size(*, landscape: bool) -> tuple:
|
||||
"""Return (max_height, max_width) usable content area for LayoutTracker."""
|
||||
if landscape:
|
||||
page_w, page_h = A4_LONG, A4_SHORT
|
||||
else:
|
||||
page_w, page_h = A4_SHORT, A4_LONG
|
||||
max_height = page_h - MARGIN_TOP - MARGIN_BOTTOM
|
||||
max_width = page_w - MARGIN_LEFT - MARGIN_RIGHT
|
||||
return max_height, max_width
|
||||
|
||||
|
||||
def apply_centered_page_footer(section) -> None:
|
||||
"""Centered PAGE field, Times New Roman 12 (body section style)."""
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
if not footer.paragraphs:
|
||||
footer.add_paragraph()
|
||||
paragraph = footer.paragraphs[0]
|
||||
paragraph.clear()
|
||||
paragraph.paragraph_format.first_line_indent = 0
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
run = paragraph.add_run()
|
||||
run.font.name = "Times New Roman"
|
||||
run.font.size = Pt(12)
|
||||
paragraph._p.append(create_element("w:fldSimple", {
|
||||
"w:instr": "PAGE \\* MERGEFORMAT",
|
||||
}))
|
||||
|
||||
|
||||
def clear_section_footer(section) -> None:
|
||||
"""Empty footer (no PAGE) — for title / assignment / TOC sections."""
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
for p in footer.paragraphs:
|
||||
p.clear()
|
||||
if not footer.paragraphs:
|
||||
footer.add_paragraph()
|
||||
|
||||
|
||||
def ensure_continuous_page_numbers(document) -> None:
|
||||
"""
|
||||
After docxcompose of title/assignment: no PAGE on early sections,
|
||||
continuous numbering (do not restart at 1 on body section).
|
||||
"""
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
sections = list(document.sections)
|
||||
if not sections:
|
||||
return
|
||||
|
||||
# Heuristic: sections before the first that already has a PAGE field /
|
||||
# or first N-1 if title was prepended — clear footer on all but keep
|
||||
# continuous pgNumType. Body renderer already puts PAGE on body section;
|
||||
# after compose, early sections may inherit footers — clear empty ones
|
||||
# that belong to front matter (no Heading-like body content is hard to
|
||||
# detect), so: clear footer on every section that has no PAGE field, and
|
||||
# strip w:pgNumType start=1 everywhere.
|
||||
for section in sections:
|
||||
sect_pr = section._sectPr
|
||||
for child in list(sect_pr):
|
||||
if child.tag == qn("w:pgNumType"):
|
||||
# Keep continuous: remove start attribute if present
|
||||
if child.get(qn("w:start")) is not None:
|
||||
del child.attrib[qn("w:start")]
|
||||
+8
-3
@@ -1,7 +1,7 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from docx import Document
|
||||
from marko.block import BlankLine
|
||||
from marko.block import BlankLine, ThematicBreak
|
||||
|
||||
from .extended_markdown import markdown, Caption
|
||||
from .renderable.caption import CaptionInfo
|
||||
@@ -12,13 +12,14 @@ from .renderable_factory import RenderableFactory
|
||||
class Parser:
|
||||
"""Parses given markdown string and returns Renderable elements"""
|
||||
|
||||
def __init__(self, document: Document, text: str):
|
||||
def __init__(self, document: Document, text: str, hr_pagebreak: bool = False):
|
||||
self._document = document
|
||||
self._parsed = markdown.parse(text)
|
||||
self._caption_info: CaptionInfo | None = None
|
||||
self._hr_pagebreak = hr_pagebreak
|
||||
|
||||
def parse(self) -> Generator[Renderable, None, None]:
|
||||
factory = RenderableFactory(self._document._body)
|
||||
factory = RenderableFactory(self._document._body, hr_pagebreak=self._hr_pagebreak)
|
||||
|
||||
for marko_element in self._parsed.children:
|
||||
if isinstance(marko_element, BlankLine):
|
||||
@@ -29,8 +30,12 @@ class Parser:
|
||||
marko_element.unique_name,
|
||||
marko_element.text,
|
||||
getattr(marko_element, "with_listing", False),
|
||||
getattr(marko_element, "landscape", False),
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(marko_element, ThematicBreak) and not self._hr_pagebreak:
|
||||
continue
|
||||
|
||||
yield factory.create(marko_element, self._caption_info)
|
||||
self._caption_info = None
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Shared Markdown → DOCX pipeline for CLI and GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from getpass import getuser
|
||||
from typing import Callable
|
||||
|
||||
from docx import Document
|
||||
|
||||
from . import package_dir
|
||||
from .checker import check_markdown, format_report
|
||||
from .converter import Converter
|
||||
from .profiles import (
|
||||
DEFAULT_HEADING_NUMBERING,
|
||||
DEFAULT_TABLE_CONTINUATION,
|
||||
DEFAULT_LISTING_CONTINUATION,
|
||||
DEFAULT_TOC_MODE,
|
||||
get_profile,
|
||||
)
|
||||
|
||||
LogFn = Callable[[str], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertRequest:
|
||||
filename: str = ""
|
||||
output: str | None = None
|
||||
template: str | None = None
|
||||
doc_type: str = "practice"
|
||||
heading_numbering: str = DEFAULT_HEADING_NUMBERING
|
||||
toc_mode: str = DEFAULT_TOC_MODE
|
||||
table_continuation: str = DEFAULT_TABLE_CONTINUATION
|
||||
listing_continuation: str = DEFAULT_LISTING_CONTINUATION
|
||||
emdash_to_hyphen: bool = False
|
||||
hr_pagebreak: bool = False
|
||||
title: str | None = None
|
||||
assignment: str | None = None
|
||||
check: bool = False
|
||||
check_only: bool = False
|
||||
strict: bool = False
|
||||
syntax_highlighting: bool = False
|
||||
plantuml_jar: str | None = None
|
||||
kroki_url: str | None = None
|
||||
diagram_fallback: str = "remote"
|
||||
diagram_format: str = "png"
|
||||
diagram_scale: float = 2.0
|
||||
schemes_path: str | None = None
|
||||
debug: bool = False
|
||||
open_when_done: bool = False
|
||||
check_pages: bool = False
|
||||
table_repeat_header: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertResult:
|
||||
ok: bool
|
||||
exit_code: int = 0
|
||||
output_path: str | None = None
|
||||
check_report: str = ""
|
||||
message: str = ""
|
||||
|
||||
|
||||
def default_output_path(filename: str) -> str:
|
||||
base = os.path.basename(filename)
|
||||
if base.lower().endswith(".md"):
|
||||
base = base[:-3]
|
||||
return os.path.join(os.path.dirname(os.path.abspath(filename)), base + ".docx")
|
||||
|
||||
|
||||
def default_template_path() -> str:
|
||||
return os.path.join(package_dir(), "Template.docx")
|
||||
|
||||
|
||||
def open_document(path: str) -> None:
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.call(("open", path))
|
||||
elif system == "Windows":
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
else:
|
||||
subprocess.call(("xdg-open", path))
|
||||
|
||||
|
||||
def _fix_front_matter_after_compose(document, *, had_title: bool, had_assignment: bool) -> None:
|
||||
"""Clear PAGE on title/assignment sections; keep continuous page numbers."""
|
||||
from .page_geometry import clear_section_footer, ensure_continuous_page_numbers
|
||||
|
||||
ensure_continuous_page_numbers(document)
|
||||
n_front = int(bool(had_title)) + int(bool(had_assignment))
|
||||
for i, section in enumerate(document.sections):
|
||||
if i < n_front:
|
||||
clear_section_footer(section)
|
||||
else:
|
||||
break
|
||||
ensure_continuous_page_numbers(document)
|
||||
|
||||
|
||||
def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
||||
"""Run check and/or conversion. Does not re-raise; errors go into ConvertResult."""
|
||||
emit: LogFn = log if log is not None else print
|
||||
|
||||
def fail(code: int, message: str, check_report: str = "") -> ConvertResult:
|
||||
emit(message)
|
||||
return ConvertResult(False, code, check_report=check_report, message=message)
|
||||
|
||||
filename = (req.filename or "").strip()
|
||||
# Standalone page-fill check on an existing DOCX
|
||||
if req.check_pages and filename.lower().endswith(".docx") and not filename.lower().endswith(".md"):
|
||||
from .page_fill_check import check_docx_page_fill, format_page_fill_report
|
||||
issues, status = check_docx_page_fill(filename)
|
||||
report = format_page_fill_report(issues, status)
|
||||
emit(report)
|
||||
return ConvertResult(True, 0, check_report=report, message=report)
|
||||
|
||||
if not filename:
|
||||
return fail(2, "Укажите исходный markdown-файл")
|
||||
if not filename.lower().endswith(".md"):
|
||||
return fail(1, "Исходный файл должен быть в формате .md")
|
||||
if not os.path.isfile(filename):
|
||||
return fail(2, f"Файл не найден: {filename}")
|
||||
|
||||
output = (req.output or "").strip() or None
|
||||
if output and not output.lower().endswith(".docx"):
|
||||
return fail(1, "Выходной файл должен быть в формате .docx")
|
||||
|
||||
for label, path in (("титул", req.title), ("задание", req.assignment), ("шаблон", req.template)):
|
||||
if path and not os.path.isfile(path):
|
||||
return fail(2, f"Файл ({label}) не найден: {path}")
|
||||
|
||||
if req.syntax_highlighting:
|
||||
os.environ["SYNTAX_HIGHLIGHTING"] = "1"
|
||||
else:
|
||||
os.environ.pop("SYNTAX_HIGHLIGHTING", None)
|
||||
|
||||
from .diagram_renderer import configure_diagrams
|
||||
|
||||
md_dir = os.path.dirname(os.path.abspath(filename)) or "."
|
||||
configure_diagrams(
|
||||
plantuml_jar=req.plantuml_jar or None,
|
||||
kroki_url=req.kroki_url or None,
|
||||
fallback=req.diagram_fallback,
|
||||
schemes_path=req.schemes_path or None,
|
||||
md_dir=md_dir,
|
||||
diagram_format=req.diagram_format if req.diagram_format in ("png", "svg") else "png",
|
||||
diagram_scale=req.diagram_scale,
|
||||
)
|
||||
|
||||
os.environ["WORKING_DIR"] = md_dir
|
||||
|
||||
with open(filename, encoding="utf-8") as f:
|
||||
md_text = f.read()
|
||||
|
||||
check_report = ""
|
||||
if req.check or req.check_only:
|
||||
issues = check_markdown(
|
||||
md_text,
|
||||
req.doc_type,
|
||||
table_continuation=req.table_continuation,
|
||||
listing_continuation=req.listing_continuation,
|
||||
)
|
||||
check_report = format_report(issues)
|
||||
emit(check_report)
|
||||
errors = [i for i in issues if i.severity == "error"]
|
||||
if req.strict and errors:
|
||||
return fail(1, "Проверка ТЗ: есть ошибки (--strict)", check_report)
|
||||
if req.check_only:
|
||||
return ConvertResult(
|
||||
True, 0, check_report=check_report,
|
||||
message=check_report,
|
||||
)
|
||||
|
||||
if not output:
|
||||
output = default_output_path(filename)
|
||||
|
||||
template = (req.template or "").strip() or default_template_path()
|
||||
|
||||
handler = _CallbackLogHandler(emit)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
||||
root_log = logging.getLogger("md2gost")
|
||||
root_log.addHandler(handler)
|
||||
attached_diag = logging.getLogger("md2gost.diagram_renderer")
|
||||
prev_diag_level = attached_diag.level
|
||||
attached_diag.setLevel(logging.INFO)
|
||||
attached_diag.addHandler(handler)
|
||||
|
||||
try:
|
||||
converter = Converter(
|
||||
filename, output, template, req.debug,
|
||||
doc_type=req.doc_type,
|
||||
heading_numbering=req.heading_numbering,
|
||||
emdash_to_hyphen=req.emdash_to_hyphen,
|
||||
toc_mode=req.toc_mode,
|
||||
table_continuation=req.table_continuation,
|
||||
listing_continuation=req.listing_continuation,
|
||||
hr_pagebreak=req.hr_pagebreak,
|
||||
)
|
||||
converter.convert()
|
||||
document = converter.document
|
||||
|
||||
if req.title or req.assignment:
|
||||
try:
|
||||
from docxcompose.composer import Composer
|
||||
except ImportError:
|
||||
return fail(3, "Для титула/задания нужен пакет docxcompose", check_report)
|
||||
from .styles import apply_document_styles
|
||||
|
||||
shell = Document(template)
|
||||
apply_document_styles(shell, get_profile(req.doc_type).style_preset)
|
||||
body = shell.element.body
|
||||
for child in list(body):
|
||||
if not child.tag.endswith("}sectPr"):
|
||||
body.remove(child)
|
||||
|
||||
composer = Composer(shell)
|
||||
if req.title:
|
||||
composer.append(Document(req.title))
|
||||
shell.add_page_break()
|
||||
if req.assignment:
|
||||
composer.append(Document(req.assignment))
|
||||
shell.add_page_break()
|
||||
composer.append(document)
|
||||
document = composer.doc
|
||||
apply_document_styles(document, get_profile(req.doc_type).style_preset)
|
||||
_fix_front_matter_after_compose(
|
||||
document,
|
||||
had_title=bool(req.title),
|
||||
had_assignment=bool(req.assignment),
|
||||
)
|
||||
|
||||
document.core_properties.author = getuser()
|
||||
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
||||
document.save(output)
|
||||
except Exception as exc:
|
||||
emit(traceback.format_exc())
|
||||
return ConvertResult(
|
||||
False, 1, check_report=check_report,
|
||||
message=f"Ошибка конвертации: {exc}",
|
||||
)
|
||||
finally:
|
||||
root_log.removeHandler(handler)
|
||||
attached_diag.removeHandler(handler)
|
||||
attached_diag.setLevel(prev_diag_level)
|
||||
|
||||
abs_out = os.path.abspath(output)
|
||||
emit(f"Generated document: {abs_out}")
|
||||
|
||||
if req.table_continuation == "word" or req.listing_continuation == "word":
|
||||
from .word_fix import fix_continuations
|
||||
fix = fix_continuations(
|
||||
abs_out,
|
||||
tables=(req.table_continuation == "word"),
|
||||
listings=(req.listing_continuation == "word"),
|
||||
repeat_header=bool(req.table_repeat_header),
|
||||
)
|
||||
emit(fix.message)
|
||||
if fix.details:
|
||||
for line in fix.details:
|
||||
emit(line)
|
||||
if not fix.ok:
|
||||
check_report = (
|
||||
(check_report + "\n" + fix.message).strip() if check_report else fix.message
|
||||
)
|
||||
|
||||
if req.check_pages:
|
||||
from .page_fill_check import check_docx_page_fill, format_page_fill_report
|
||||
issues, status = check_docx_page_fill(abs_out)
|
||||
page_report = format_page_fill_report(issues, status)
|
||||
emit(page_report)
|
||||
check_report = (check_report + "\n" + page_report).strip() if check_report else page_report
|
||||
|
||||
if req.debug or req.open_when_done:
|
||||
try:
|
||||
open_document(abs_out)
|
||||
except Exception as exc:
|
||||
emit(f"Не удалось открыть файл: {exc}")
|
||||
|
||||
return ConvertResult(
|
||||
True, 0, output_path=abs_out, check_report=check_report,
|
||||
message=f"Generated document: {abs_out}",
|
||||
)
|
||||
|
||||
|
||||
class _CallbackLogHandler(logging.Handler):
|
||||
def __init__(self, emit: LogFn):
|
||||
super().__init__()
|
||||
self._emit = emit
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
self._emit(self.format(record))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def should_launch_gui(filename: str | None, gui_flag: bool) -> bool:
|
||||
"""GUI if --gui, or if no input file was given (interactive default)."""
|
||||
return bool(gui_flag or not filename)
|
||||
+8
-3
@@ -19,12 +19,17 @@ DEFAULT_HEADING_NUMBERING = "manual"
|
||||
TOC_MODES = ("manual", "native")
|
||||
DEFAULT_TOC_MODE = "native"
|
||||
|
||||
# off — одна таблица Word, пагинацию делает Word (по умолчанию; без автоподписи)
|
||||
# off — одна таблица Word, пагинацию делает Word (без автоподписи)
|
||||
# legacy — режем по оценке высоты + «Продолжение…» с page_break_before
|
||||
# caption — режем по оценке + явный PageBreak + «Продолжение…»
|
||||
# soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX)
|
||||
TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft")
|
||||
DEFAULT_TABLE_CONTINUATION = "off"
|
||||
# word — как off при рендере; после save Word COM режет по реальной пагинации + «Продолжение…»
|
||||
TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft", "word")
|
||||
DEFAULT_TABLE_CONTINUATION = "word"
|
||||
|
||||
# Same modes as tables (word = post-process via Word COM after save).
|
||||
LISTING_CONTINUATION_MODES = TABLE_CONTINUATION_MODES
|
||||
DEFAULT_LISTING_CONTINUATION = DEFAULT_TABLE_CONTINUATION
|
||||
|
||||
# section — Рисунок 1.1 / 2.1 (ТЗ МИРЭА); continuous — Рисунок 1, 2, 3 (ПИС)
|
||||
NUMBERING_SCOPES = ("section", "continuous")
|
||||
|
||||
@@ -16,7 +16,7 @@ from ..util import create_element
|
||||
# Map category → Word style name
|
||||
CAPTION_STYLES = {
|
||||
"Рисунок": "Caption Figure",
|
||||
"Таблица": "Caption Table",
|
||||
"Таблица": "Название таблицы",
|
||||
"Листинг": "Caption Listing",
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class CaptionInfo:
|
||||
unique_name: str | None
|
||||
text: str | None
|
||||
with_listing: bool = False
|
||||
landscape: bool = False
|
||||
|
||||
|
||||
class Caption(Renderable):
|
||||
@@ -40,7 +41,10 @@ class Caption(Renderable):
|
||||
try:
|
||||
self._docx_paragraph.style = style_name
|
||||
except KeyError:
|
||||
self._docx_paragraph.style = "Caption"
|
||||
try:
|
||||
self._docx_paragraph.style = "Caption Table" if category == "Таблица" else "Caption"
|
||||
except KeyError:
|
||||
self._docx_paragraph.style = "Caption"
|
||||
|
||||
# Format: «Рисунок 1.1 — Название» (or « - » if --emdash-to-hyphen)
|
||||
from ..profiles import dash_separator
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..rendered_info import RenderedInfo
|
||||
|
||||
|
||||
class DiagramFigure(Renderable, RequiresNumbering):
|
||||
"""UML/BPMN/C4 fence → PNG figure (+ optional source listing)."""
|
||||
"""UML / Mermaid / scheme fence → figure (+ optional source listing)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -34,6 +34,7 @@ class DiagramFigure(Renderable, RequiresNumbering):
|
||||
)
|
||||
self._number = None
|
||||
self._listing_number = None
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
@@ -56,8 +57,14 @@ class DiagramFigure(Renderable, RequiresNumbering):
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
|
||||
-> Generator[RenderedInfo, None, None]:
|
||||
png_path = render_diagram(self._lang, self._source)
|
||||
image = Image(self._parent, png_path, self._caption_info)
|
||||
result = render_diagram(self._lang, self._source)
|
||||
image = Image(
|
||||
self._parent,
|
||||
result.png_path,
|
||||
self._caption_info,
|
||||
svg_path=result.svg_path,
|
||||
pixel_scale=result.pixel_scale,
|
||||
)
|
||||
if self._number is not None:
|
||||
image.set_number(self._number)
|
||||
yield from image.render(previous_rendered, layout_state)
|
||||
|
||||
@@ -2,7 +2,6 @@ from copy import copy
|
||||
from typing import Generator
|
||||
import re
|
||||
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.text.paragraph import Paragraph as DocxParagraph
|
||||
from docx.shared import Parented, Length
|
||||
|
||||
@@ -35,7 +34,8 @@ class Heading(Paragraph):
|
||||
|
||||
if not numbered:
|
||||
self._remove_numbering()
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
# Alignment decided later in Renderer: only СОДЕРЖАНИЕ / СПИСОК centered.
|
||||
# ВВЕДЕНИЕ / ЗАКЛЮЧЕНИЕ / ПРИЛОЖЕНИЯ stay left like Heading 1 (1.25 cm indent).
|
||||
elif numbering_mode == "manual":
|
||||
# Digits already in markdown text — kill Word list numbering to avoid "1 1 …"
|
||||
self._remove_numbering()
|
||||
|
||||
+70
-10
@@ -7,20 +7,32 @@ import os.path
|
||||
|
||||
import requests
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.shared import Parented, Length
|
||||
from docx.shared import Parented, Length, Mm
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
from .caption import Caption, CaptionInfo
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..docx_svg import attach_svg_blip
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
from ..util import create_element
|
||||
|
||||
# Leave room under the figure so «Рисунок N — …» stays on the same page.
|
||||
_CAPTION_RESERVE = Mm(12)
|
||||
|
||||
|
||||
class Image(Renderable, RequiresNumbering):
|
||||
def __init__(self, parent: Parented, path: str, caption_info: CaptionInfo | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
parent: Parented,
|
||||
path: str,
|
||||
caption_info: CaptionInfo | None = None,
|
||||
svg_path: str | None = None,
|
||||
*,
|
||||
pixel_scale: float = 1.0,
|
||||
):
|
||||
super().__init__("Рисунок")
|
||||
self._parent = parent
|
||||
self._caption_info = caption_info
|
||||
@@ -31,6 +43,8 @@ class Image(Renderable, RequiresNumbering):
|
||||
self._docx_paragraph.paragraph_format.line_spacing = 1
|
||||
self._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
self._invalid = False
|
||||
self._native_width: Length | None = None
|
||||
self._native_height: Length | None = None
|
||||
|
||||
run = self._docx_paragraph.add_run()
|
||||
|
||||
@@ -47,36 +61,82 @@ class Image(Renderable, RequiresNumbering):
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"Invalid image path: {path}, skipping...")
|
||||
self._invalid = True
|
||||
self._image = None
|
||||
|
||||
# High-res PlantUML/Kroki PNG: keep on-page size as if scale were 1.
|
||||
if not self._invalid and pixel_scale and pixel_scale > 1:
|
||||
self._image.width = Length(int(self._image.width / pixel_scale))
|
||||
self._image.height = Length(int(self._image.height / pixel_scale))
|
||||
|
||||
if not self._invalid:
|
||||
self._native_width = Length(int(self._image.width))
|
||||
self._native_height = Length(int(self._image.height))
|
||||
|
||||
if not self._invalid and svg_path:
|
||||
try:
|
||||
attach_svg_blip(run, self._image, svg_path)
|
||||
except Exception as exc:
|
||||
logging.warning("SVG blip attach failed (%s): %s", svg_path, exc)
|
||||
|
||||
self._number = None
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
# Keep figure + caption together across Word pagination.
|
||||
if not self._invalid and caption_info is not None:
|
||||
self._docx_paragraph.paragraph_format.keep_with_next = True
|
||||
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def _reset_native_size(self) -> None:
|
||||
if self._image is None or self._native_width is None or self._native_height is None:
|
||||
return
|
||||
self._image.width = Length(int(self._native_width))
|
||||
self._image.height = Length(int(self._native_height))
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
if self._invalid:
|
||||
yield from []
|
||||
return
|
||||
|
||||
# Re-fit from native size each time (Paragraph may have measured us earlier).
|
||||
self._reset_native_size()
|
||||
|
||||
has_caption = self._caption_info is not None
|
||||
caption_reserve = _CAPTION_RESERVE if has_caption else Length(0)
|
||||
max_w = layout_state.max_width
|
||||
max_h = Length(max(0, int(layout_state.max_height) - int(caption_reserve)))
|
||||
|
||||
# limit width
|
||||
if self._image.width > layout_state.max_width:
|
||||
if self._image.width > max_w:
|
||||
height_by_width = self._image.height / self._image.width
|
||||
self._image.width = layout_state.max_width
|
||||
self._image.width = max_w
|
||||
self._image.height = Length(self._image.width * height_by_width)
|
||||
|
||||
# limit height
|
||||
if self._image.height > layout_state.max_height:
|
||||
# limit height (leave room for caption on the same page)
|
||||
if self._image.height > max_h:
|
||||
width_by_height = self._image.width / self._image.height
|
||||
self._image.height = layout_state.max_height
|
||||
self._image.height = max_h
|
||||
self._image.width = Length(self._image.height * width_by_height)
|
||||
|
||||
height = self._image.height
|
||||
|
||||
if layout_state.remaining_page_height < height:
|
||||
height += layout_state.remaining_page_height
|
||||
need = Length(int(height) + int(caption_reserve))
|
||||
remaining = layout_state.remaining_page_height
|
||||
if remaining < need:
|
||||
if self.landscape:
|
||||
# Already on a fresh landscape section — shrink instead of soft page-break
|
||||
# (soft break + section break = empty landscape page before the figure).
|
||||
avail = Length(max(0, int(remaining) - int(caption_reserve)))
|
||||
if avail > 0 and self._image.height > avail:
|
||||
ratio = int(avail) / int(self._image.height)
|
||||
self._image.height = avail
|
||||
self._image.width = Length(int(self._image.width * ratio))
|
||||
height = self._image.height
|
||||
else:
|
||||
height = Length(int(height) + int(remaining))
|
||||
|
||||
yield (rendered_image := RenderedInfo(self._docx_paragraph, Length(height)))
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ class List(Renderable):
|
||||
if self._paragraphs:
|
||||
self._paragraphs[-1]._docx_paragraph.paragraph_format.space_after = self._last_paragraph_space_after
|
||||
|
||||
if getattr(self, "_space_before_mm6", False) and self._paragraphs:
|
||||
from docx.shared import Mm
|
||||
self._paragraphs[0]._docx_paragraph.paragraph_format.space_before = Mm(6)
|
||||
|
||||
for paragraph in self._paragraphs:
|
||||
for x in paragraph.render(previous_rendered, copy(layout_state)):
|
||||
layout_state.add_height(x.height)
|
||||
|
||||
@@ -2,23 +2,25 @@ from copy import copy
|
||||
import os
|
||||
from typing import Generator, Callable
|
||||
|
||||
from docx.oxml import CT_Tbl
|
||||
from docx.shared import Length, Pt, RGBColor, Twips
|
||||
from docx.table import Table
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
|
||||
from .caption import Caption, CaptionInfo
|
||||
from .page_break import PageBreak
|
||||
from .paragraph import Paragraph
|
||||
from .renderable import Renderable
|
||||
from .requires_numbering import RequiresNumbering
|
||||
from ..docx_elements import create_table, _twips
|
||||
from ..docx_elements import create_table, create_table_row, create_table_cell, set_table_box_borders, _twips
|
||||
from ..layout_tracker import LayoutState
|
||||
from ..profiles import DEFAULT_LISTING_CONTINUATION, LISTING_CONTINUATION_MODES
|
||||
from ..rendered_info import RenderedInfo
|
||||
from ..sub_renderable import SubRenderable
|
||||
|
||||
_WORD_PAGED_MODES = frozenset({"off", "soft", "word"})
|
||||
|
||||
|
||||
class DocxParagraphPygmentsFormatter(Formatter):
|
||||
def __init__(self, paragraphs: list[Paragraph], creator: Callable[[], Paragraph], **options):
|
||||
@@ -55,13 +57,14 @@ class Listing(Renderable, RequiresNumbering):
|
||||
self._caption_info = caption_info
|
||||
self._language = language
|
||||
self._parent = parent
|
||||
self._continuation_mode = DEFAULT_LISTING_CONTINUATION
|
||||
self.paragraphs: list[Paragraph] = []
|
||||
self._number = None
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
def _create_table(self, parent, width: Length):
|
||||
# todo: style inheritance
|
||||
# Kept for tests / callers that still expect the helper; render uses multi-row.
|
||||
left_margin = Twips(int(
|
||||
parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
@@ -94,6 +97,51 @@ class Listing(Renderable, RequiresNumbering):
|
||||
def set_number(self, number: str):
|
||||
self._number = number
|
||||
|
||||
def set_continuation_mode(self, mode: str) -> None:
|
||||
if mode not in LISTING_CONTINUATION_MODES:
|
||||
raise ValueError(
|
||||
f"listing continuation must be one of {LISTING_CONTINUATION_MODES}, got {mode!r}"
|
||||
)
|
||||
self._continuation_mode = mode
|
||||
|
||||
def _should_split_fragment(
|
||||
self, line_height, layout_state: LayoutState, lines_in_fragment: int
|
||||
) -> bool:
|
||||
if self._continuation_mode in _WORD_PAGED_MODES:
|
||||
return False
|
||||
if lines_in_fragment == 0:
|
||||
return False
|
||||
return line_height > layout_state.remaining_page_height
|
||||
|
||||
def _make_continuation_paragraph(self) -> Paragraph:
|
||||
continuation_paragraph = Paragraph(self._parent)
|
||||
continuation_paragraph.add_run(f"Продолжение Листинга {self._number}")
|
||||
continuation_paragraph.style = "Caption Listing"
|
||||
continuation_paragraph.first_line_indent = 0
|
||||
return continuation_paragraph
|
||||
|
||||
def _emit_page_break_and_optional_caption(
|
||||
self, layout_state: LayoutState
|
||||
) -> Generator[RenderedInfo, None, None]:
|
||||
mode = self._continuation_mode
|
||||
if mode == "legacy":
|
||||
continuation_paragraph = self._make_continuation_paragraph()
|
||||
continuation_paragraph.page_break_before = True
|
||||
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||
layout_state.add_height(info.height)
|
||||
yield info
|
||||
return
|
||||
|
||||
page_break_info = next(PageBreak(self._parent).render(None, layout_state))
|
||||
layout_state.add_height(page_break_info.height)
|
||||
yield page_break_info
|
||||
|
||||
continuation_paragraph = self._make_continuation_paragraph()
|
||||
continuation_paragraph._docx_paragraph.paragraph_format.keep_with_next = True
|
||||
info = next(continuation_paragraph.render(None, copy(layout_state)))
|
||||
layout_state.add_height(info.height)
|
||||
yield info
|
||||
|
||||
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState)\
|
||||
-> Generator[RenderedInfo | SubRenderable, None, None]:
|
||||
caption_rendered_infos = list(
|
||||
@@ -103,54 +151,65 @@ class Listing(Renderable, RequiresNumbering):
|
||||
layout_state.add_height(sum([info.height for info in caption_rendered_infos]))
|
||||
yield from caption_rendered_infos
|
||||
|
||||
table = self._create_table(self._parent, layout_state.max_width)
|
||||
# One code line = one table row so Word can Split at real page breaks (mode word).
|
||||
# Outer box only — no grid lines between lines (looks like text in a frame).
|
||||
table = create_table(self._parent, 0, 1, self._listing_width(layout_state))
|
||||
set_table_box_borders(table)
|
||||
previous = None
|
||||
|
||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
||||
lines_in_fragment = 0
|
||||
col_w = self._listing_width(layout_state)
|
||||
|
||||
# if first line doesn't fit move listing to the next page
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(self.paragraphs[0].render(previous, paragraph_layout_state))
|
||||
if paragraph_rendered_info.height + table_height > layout_state.remaining_page_height:
|
||||
table_height += layout_state.remaining_page_height
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
# legacy/caption: if first line doesn't fit, burn the rest of the page
|
||||
if self.paragraphs and self._continuation_mode not in _WORD_PAGED_MODES:
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
first = next(self.paragraphs[0].render(previous, paragraph_layout_state))
|
||||
if first.height + table_height > layout_state.remaining_page_height:
|
||||
table_height += layout_state.remaining_page_height
|
||||
layout_state.add_height(layout_state.remaining_page_height)
|
||||
|
||||
for paragraph in self.paragraphs:
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
||||
|
||||
if paragraph_rendered_info.height > layout_state.remaining_page_height: # todo add before after
|
||||
table_rendered_info = RenderedInfo(table, table_height)
|
||||
yield table_rendered_info
|
||||
|
||||
table_height = Pt(1) # table borders, 4 eights of point for each border
|
||||
|
||||
continuation_paragraph = Paragraph(self._parent)
|
||||
continuation_paragraph.add_run(f"Продолжение Листинга {self._number}")
|
||||
continuation_paragraph.style = "Caption Listing"
|
||||
continuation_paragraph.first_line_indent = 0
|
||||
continuation_paragraph.page_break_before = True
|
||||
|
||||
continuation_rendered_info = next(
|
||||
continuation_paragraph.render(None, copy(layout_state)))
|
||||
|
||||
layout_state.add_height(continuation_rendered_info.height)
|
||||
yield continuation_rendered_info
|
||||
|
||||
table = self._create_table(self._parent, layout_state.max_width)
|
||||
if self._should_split_fragment(
|
||||
paragraph_rendered_info.height, layout_state, lines_in_fragment
|
||||
):
|
||||
yield RenderedInfo(table, table_height)
|
||||
yield from self._emit_page_break_and_optional_caption(layout_state)
|
||||
|
||||
table_height = Pt(1)
|
||||
table = create_table(self._parent, 0, 1, col_w)
|
||||
set_table_box_borders(table)
|
||||
previous = None
|
||||
lines_in_fragment = 0
|
||||
|
||||
paragraph_layout_state = copy(layout_state)
|
||||
paragraph_layout_state.max_width -= LISTING_OFFSET
|
||||
paragraph_rendered_info = next(paragraph.render(previous, paragraph_layout_state))
|
||||
|
||||
table._cells[0]._element.append(paragraph_rendered_info.docx_element._element)
|
||||
row = create_table_row(table, header=False)
|
||||
cell = create_table_cell(row, col_w)
|
||||
cell._element.append(paragraph_rendered_info.docx_element._element)
|
||||
row._element.append(cell._element)
|
||||
table._element.append(row._element)
|
||||
|
||||
layout_state.add_height(paragraph_rendered_info.height)
|
||||
table_height += paragraph_rendered_info.height
|
||||
lines_in_fragment += 1
|
||||
|
||||
previous = paragraph_rendered_info
|
||||
|
||||
yield RenderedInfo(table, table_height)
|
||||
|
||||
def _listing_width(self, layout_state: LayoutState) -> Length:
|
||||
left_margin = Twips(int(
|
||||
self._parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:left")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
right_margin = Twips(int(
|
||||
self._parent.part.styles["Normal Table"]._element.xpath("w:tblPr/w:tblCellMar/w:right")[0].attrib[
|
||||
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}w"]))
|
||||
return Twips(_twips(layout_state.max_width) + _twips(left_margin) + _twips(right_margin))
|
||||
|
||||
@@ -89,14 +89,16 @@ class Paragraph(Renderable):
|
||||
return link
|
||||
|
||||
def add_inline_equation(self, formula: str):
|
||||
# omml = inline_omml(latex_to_omml(formula))
|
||||
# for r in omml.xpath("//m:r", namespaces=omml.nsmap):
|
||||
# r.append(create_element("w:rPr", [
|
||||
# create_element("w:sz", {"w:val": "24"}),
|
||||
# create_element("w:szCs", {"w:val": "24"}),
|
||||
# ]))
|
||||
# self._docx_paragraph._element.append(omml)
|
||||
self.add_run(formula, is_italic=True)
|
||||
try:
|
||||
omml = inline_omml(latex_to_omml(formula))
|
||||
for r in omml.xpath("//m:r", namespaces=omml.nsmap):
|
||||
r.append(create_element("w:rPr", [
|
||||
create_element("w:sz", {"w:val": "28"}),
|
||||
create_element("w:szCs", {"w:val": "28"}),
|
||||
]))
|
||||
self._docx_paragraph._element.append(omml)
|
||||
except Exception:
|
||||
self.add_run(formula, is_italic=True)
|
||||
|
||||
@property
|
||||
def page_break_before(self) -> bool:
|
||||
@@ -170,6 +172,12 @@ class Paragraph(Renderable):
|
||||
images = iter(self._images)
|
||||
|
||||
for image in images:
|
||||
# +landscape: section break handles the new page. Do NOT measure against
|
||||
# portrait remaining (that queues add_to_new_page and leaves an empty page).
|
||||
if getattr(image, "landscape", False):
|
||||
yield SubRenderable(image, False)
|
||||
continue
|
||||
|
||||
rendered_image = list(image.render(previous_rendered, copy(layout_state)))
|
||||
rendered_image_height = sum([x.height for x in rendered_image])
|
||||
if rendered_image:
|
||||
|
||||
@@ -18,8 +18,9 @@ CELL_OFFSET = Pt(9) - Twips(108 * 2)
|
||||
# Slack only for modes that fragment by our height estimate (legacy/caption).
|
||||
ROW_HEIGHT_SLACK = Pt(4)
|
||||
|
||||
# Modes that do NOT cut the table into fragments — Word owns page breaks.
|
||||
_WORD_PAGED_MODES = frozenset({"off", "soft"})
|
||||
# Modes that do NOT cut the table into fragments — Word owns page breaks
|
||||
# (word: post-split via COM after save).
|
||||
_WORD_PAGED_MODES = frozenset({"off", "soft", "word"})
|
||||
|
||||
|
||||
class Table(Renderable, RequiresNumbering):
|
||||
@@ -35,6 +36,7 @@ class Table(Renderable, RequiresNumbering):
|
||||
|
||||
self._cell_margin_lr = left_margin + right_margin
|
||||
self._number = "?"
|
||||
self.landscape = bool(caption_info and caption_info.landscape)
|
||||
if caption_info and caption_info.unique_name:
|
||||
self.unique_name = caption_info.unique_name
|
||||
|
||||
@@ -144,7 +146,7 @@ class Table(Renderable, RequiresNumbering):
|
||||
def _make_continuation_paragraph(self) -> Paragraph:
|
||||
continuation_paragraph = Paragraph(self._parent)
|
||||
continuation_paragraph.add_run(f"Продолжение Таблицы {self._number}")
|
||||
continuation_paragraph.style = "Caption Table"
|
||||
continuation_paragraph.style = "Название таблицы"
|
||||
continuation_paragraph.first_line_indent = 0
|
||||
return continuation_paragraph
|
||||
|
||||
|
||||
@@ -116,7 +116,12 @@ class ToC(Renderable):
|
||||
continue
|
||||
|
||||
display = title.strip()
|
||||
if display.upper() in SPECIAL_TITLES or display.upper().startswith("ПРИЛОЖЕНИЕ"):
|
||||
# Special sections and numbered H1 → uppercase in TOC (method guide)
|
||||
if (
|
||||
display.upper() in SPECIAL_TITLES
|
||||
or display.upper().startswith("ПРИЛОЖЕНИЕ")
|
||||
or (level == 1 and numbered)
|
||||
):
|
||||
display = display.upper()
|
||||
|
||||
p.add_run(" " * (level - 1))
|
||||
|
||||
@@ -15,12 +15,13 @@ from .renderable.list import List
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.diagram import DiagramFigure
|
||||
from .label_pass import resolve_reference
|
||||
from .diagram_renderer import DIAGRAM_LANGS
|
||||
from .diagram_renderer import is_diagram_lang
|
||||
|
||||
|
||||
class RenderableFactory:
|
||||
def __init__(self, parent: Parented):
|
||||
def __init__(self, parent: Parented, hr_pagebreak: bool = False):
|
||||
self._parent = parent
|
||||
self._hr_pagebreak = hr_pagebreak
|
||||
|
||||
@singledispatchmethod
|
||||
def create(self, marko_element: extended_markdown.BlockElement,
|
||||
@@ -43,7 +44,12 @@ class RenderableFactory:
|
||||
elif isinstance(child, extended_markdown.CodeSpan):
|
||||
paragraph_or_link.add_run(child.children, is_italic=True)
|
||||
elif isinstance(child, extended_markdown.Image):
|
||||
caption = CaptionInfo(child.unique_name, child.title)
|
||||
caption = CaptionInfo(
|
||||
child.unique_name,
|
||||
child.title,
|
||||
getattr(child, "with_listing", False),
|
||||
getattr(child, "landscape", False),
|
||||
)
|
||||
paragraph_or_link.add_image(child.dest, caption)
|
||||
elif isinstance(child, extended_markdown.LineBreak):
|
||||
pass
|
||||
@@ -82,7 +88,7 @@ class RenderableFactory:
|
||||
def _(self, marko_code_block: extended_markdown.FencedCode, caption_info: CaptionInfo):
|
||||
lang = (marko_code_block.lang or "").strip().lower()
|
||||
source = marko_code_block.children[0].children
|
||||
if lang in DIAGRAM_LANGS:
|
||||
if is_diagram_lang(lang):
|
||||
return DiagramFigure(
|
||||
self._parent,
|
||||
lang,
|
||||
@@ -98,7 +104,7 @@ class RenderableFactory:
|
||||
def _(self, marko_code_block: extended_markdown.CodeBlock, caption_info: CaptionInfo):
|
||||
lang = (getattr(marko_code_block, "lang", "") or "").strip().lower()
|
||||
source = marko_code_block.children[0].children
|
||||
if lang in DIAGRAM_LANGS:
|
||||
if is_diagram_lang(lang):
|
||||
return DiagramFigure(
|
||||
self._parent,
|
||||
lang,
|
||||
@@ -161,3 +167,16 @@ class RenderableFactory:
|
||||
def _(self, marko_toc: extended_markdown.TOC, caption_info: CaptionInfo):
|
||||
toc = ToC(self._parent)
|
||||
return toc
|
||||
|
||||
@create.register
|
||||
def _(self, marko_hr: extended_markdown.ThematicBreak, caption_info: CaptionInfo):
|
||||
if self._hr_pagebreak:
|
||||
from .renderable.page_break import PageBreak
|
||||
return PageBreak(self._parent)
|
||||
paragraph = Paragraph(self._parent)
|
||||
paragraph.add_run(
|
||||
"ThematicBreak is not supported",
|
||||
color=RGBColor.from_string("ff0000"),
|
||||
)
|
||||
logging.warning("ThematicBreak is not supported")
|
||||
return paragraph
|
||||
|
||||
+203
-100
@@ -3,18 +3,24 @@ from itertools import chain
|
||||
import re
|
||||
|
||||
from docx.document import Document
|
||||
from docx.shared import Length, Cm, Parented, Pt, Mm
|
||||
from docx.shared import Length, Parented, Mm, Cm
|
||||
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
|
||||
from docx.enum.section import WD_SECTION
|
||||
|
||||
from .numberer import Numberer, APPENDIX_LETTERS
|
||||
from .renderable import Renderable
|
||||
from .renderable.requires_numbering import RequiresNumbering
|
||||
from .renderable.heading import Heading
|
||||
from .renderable.equation import Equation
|
||||
from .renderable.paragraph import Paragraph
|
||||
from .rendered_info import RenderedInfo
|
||||
from .sub_renderable import SubRenderable
|
||||
from .util import create_element
|
||||
from .layout_tracker import LayoutTracker
|
||||
from .page_geometry import (
|
||||
apply_centered_page_footer,
|
||||
apply_section_geometry,
|
||||
content_size,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .debugger import Debugger
|
||||
@@ -22,7 +28,12 @@ if TYPE_CHECKING:
|
||||
BOTTOM_MARGIN = Mm(20)
|
||||
|
||||
APPENDIX_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-Z])\b",
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Lettered appendix title: «Приложение А» or «Приложение А Название»
|
||||
APPENDIX_ITEM_RE = re.compile(
|
||||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
SPECIAL_CENTER = {
|
||||
@@ -48,13 +59,9 @@ class Renderer:
|
||||
self._appendix_index = 0
|
||||
self._after_toc = False
|
||||
self._body_section_started = False
|
||||
self._landscape_depth = 0
|
||||
|
||||
max_height = (document.sections[0].page_height
|
||||
- document.sections[0].top_margin
|
||||
- BOTTOM_MARGIN)
|
||||
max_width = (self._document.sections[0].page_width
|
||||
- self._document.sections[0].left_margin
|
||||
- self._document.sections[0].right_margin)
|
||||
max_height, max_width = content_size(landscape=False)
|
||||
self._layout_tracker = LayoutTracker(max_height, max_width)
|
||||
|
||||
# Front-matter section: no page numbers (титул / задание / содержание)
|
||||
@@ -62,6 +69,8 @@ class Renderer:
|
||||
|
||||
self.previous_rendered = None
|
||||
self._to_new_page: list[Renderable] = []
|
||||
self._after_current: list[Renderable] = []
|
||||
self._need_space_after_object = False
|
||||
|
||||
@staticmethod
|
||||
def _clear_footer(section):
|
||||
@@ -77,27 +86,39 @@ class Renderer:
|
||||
if self._body_section_started:
|
||||
return
|
||||
self._body_section_started = True
|
||||
# Continuous/new page section break is inserted via last paragraph sectPr;
|
||||
# python-docx: add_section creates new section.
|
||||
from docx.enum.section import WD_ORIENT, WD_SECTION
|
||||
new_section = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||
new_section.page_width = self._document.sections[0].page_width
|
||||
new_section.page_height = self._document.sections[0].page_height
|
||||
new_section.left_margin = Mm(30)
|
||||
new_section.right_margin = Mm(10)
|
||||
new_section.top_margin = Mm(20)
|
||||
new_section.bottom_margin = Mm(20)
|
||||
apply_section_geometry(new_section, landscape=False)
|
||||
apply_centered_page_footer(new_section)
|
||||
|
||||
paragraph = new_section.footer.paragraphs[0]
|
||||
paragraph.paragraph_format.first_line_indent = 0
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
run = paragraph.add_run()
|
||||
run.font.name = "Times New Roman"
|
||||
run.font.size = Pt(12)
|
||||
# PAGE field
|
||||
paragraph._p.append(create_element("w:fldSimple", {
|
||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
||||
}))
|
||||
def _enter_landscape(self) -> None:
|
||||
if self._landscape_depth > 0:
|
||||
self._landscape_depth += 1
|
||||
return
|
||||
self._flush_to_new_screen()
|
||||
if self._after_toc:
|
||||
self._ensure_body_section_with_page_numbers()
|
||||
elif not self._body_section_started:
|
||||
# No TOC — still need page numbers on subsequent sections.
|
||||
self._body_section_started = True
|
||||
apply_centered_page_footer(self._document.sections[0])
|
||||
section = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||
apply_section_geometry(section, landscape=True)
|
||||
apply_centered_page_footer(section)
|
||||
max_height, max_width = content_size(landscape=True)
|
||||
self._layout_tracker.set_page_size(max_height, max_width)
|
||||
self._landscape_depth = 1
|
||||
|
||||
def _exit_landscape(self) -> None:
|
||||
if self._landscape_depth <= 0:
|
||||
return
|
||||
self._landscape_depth -= 1
|
||||
if self._landscape_depth > 0:
|
||||
return
|
||||
section = self._document.add_section(WD_SECTION.NEW_PAGE)
|
||||
apply_section_geometry(section, landscape=False)
|
||||
apply_centered_page_footer(section)
|
||||
max_height, max_width = content_size(landscape=False)
|
||||
self._layout_tracker.set_page_size(max_height, max_width)
|
||||
|
||||
def process(self, renderables: list[Renderable]):
|
||||
for i in range(len(renderables)):
|
||||
@@ -107,46 +128,84 @@ class Renderer:
|
||||
# If document had no TOC, still add page numbers to the only section
|
||||
if not self._body_section_started:
|
||||
section = self._document.sections[0]
|
||||
paragraph = section.footer.paragraphs[0]
|
||||
paragraph.paragraph_format.first_line_indent = 0
|
||||
paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
paragraph._p.append(create_element("w:fldSimple", {
|
||||
"w:instr": "PAGE \\* MERGEFORMAT"
|
||||
}))
|
||||
apply_centered_page_footer(section)
|
||||
if self._debugger:
|
||||
self._debugger.after_rendered()
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_heading_text(heading: Heading, text: str) -> None:
|
||||
runs = heading._docx_paragraph.runs
|
||||
if runs:
|
||||
runs[0].text = text
|
||||
for r in runs[1:]:
|
||||
r.text = ""
|
||||
else:
|
||||
heading.add_run(text)
|
||||
|
||||
def _make_appendix_title_paragraph(self, title: str) -> Paragraph:
|
||||
"""Title line under «Приложение А»: Normal, centered, no first-line indent."""
|
||||
p = Paragraph(self._document._body)
|
||||
p.add_run(title)
|
||||
p.style = "Normal"
|
||||
p.first_line_indent = Cm(0)
|
||||
pf = p._docx_paragraph.paragraph_format
|
||||
pf.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
pf.left_indent = Cm(0)
|
||||
pf.keep_with_next = True
|
||||
return p
|
||||
|
||||
def _handle_heading(self, heading: Heading):
|
||||
text = (heading.text or "").strip()
|
||||
upper = text.upper()
|
||||
upper = text.upper().replace("Ё", "Е")
|
||||
|
||||
if heading.level == 1 and heading.is_numbered:
|
||||
self._section_count += 1
|
||||
self._numberer.enter_section(self._section_count)
|
||||
|
||||
# Appendix: «Приложение А» / «ПРИЛОЖЕНИЕ А»
|
||||
m = APPENDIX_RE.match(upper.replace("Ё", "Е"))
|
||||
if m or (heading.level <= 3 and upper.startswith("ПРИЛОЖЕНИЕ")):
|
||||
letter = None
|
||||
if m:
|
||||
letter = m.group(1).upper()
|
||||
else:
|
||||
parts = upper.split()
|
||||
if len(parts) >= 2 and parts[1] in APPENDIX_LETTERS:
|
||||
letter = parts[1]
|
||||
elif self._appendix_index < len(APPENDIX_LETTERS):
|
||||
letter = APPENDIX_LETTERS[self._appendix_index]
|
||||
self._appendix_index += 1
|
||||
if letter:
|
||||
# Letter appendix item: «Приложение А» / «Приложение А Название»
|
||||
# (not the section header «ПРИЛОЖЕНИЯ»)
|
||||
item = APPENDIX_ITEM_RE.match(upper) if upper.startswith("ПРИЛОЖЕНИЕ ") else None
|
||||
if item:
|
||||
letter = item.group(1).upper().replace("Ё", "Е")
|
||||
title_tail = (item.group(2) or "").strip()
|
||||
if letter in APPENDIX_LETTERS:
|
||||
self._numberer.enter_appendix(letter)
|
||||
elif self._appendix_index < len(APPENDIX_LETTERS):
|
||||
letter = APPENDIX_LETTERS[self._appendix_index]
|
||||
self._appendix_index += 1
|
||||
self._numberer.enter_appendix(letter)
|
||||
|
||||
# Center special unnumbered headings that must be centered (already centered if unnumbered)
|
||||
if upper in SPECIAL_CENTER or upper.startswith("ПРИЛОЖЕНИЕ"):
|
||||
# Style as Heading 3, centered, new page (method guide §6)
|
||||
heading.style = "Heading 3"
|
||||
heading._level = 3
|
||||
heading._remove_numbering()
|
||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
heading._docx_paragraph.paragraph_format.left_indent = Cm(0)
|
||||
heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0)
|
||||
heading.page_break_before = True
|
||||
heading._docx_paragraph.paragraph_format.keep_with_next = True
|
||||
|
||||
# СОДЕРЖАНИЕ / СПИСОК — center even if somehow numbered
|
||||
if upper in ("СОДЕРЖАНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"):
|
||||
orig = (heading.text or "").strip()
|
||||
m_orig = re.match(r"(?i)^Приложение\s+([А-ЯA-ZЁ])\b", orig)
|
||||
letter_display = m_orig.group(1).upper().replace("Ё", "Е") if m_orig else letter
|
||||
self._rewrite_heading_text(heading, f"Приложение {letter_display}")
|
||||
|
||||
if title_tail:
|
||||
m_tail = re.match(r"(?i)^Приложение\s+[А-ЯA-ZЁ]\s+(.+)$", orig)
|
||||
title_text = m_tail.group(1).strip() if m_tail else title_tail
|
||||
self._after_current.append(self._make_appendix_title_paragraph(title_text))
|
||||
return
|
||||
|
||||
# Section «ПРИЛОЖЕНИЯ» / bare «ПРИЛОЖЕНИЕ» — left like H1, not centered
|
||||
if upper in ("ПРИЛОЖЕНИЯ", "ПРИЛОЖЕНИЕ"):
|
||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.LEFT
|
||||
return
|
||||
|
||||
# Only СОДЕРЖАНИЕ / СПИСОК … are centered (method guide p. 8)
|
||||
if upper in SPECIAL_CENTER:
|
||||
heading._docx_paragraph.alignment = WD_PARAGRAPH_ALIGNMENT.CENTER
|
||||
heading._docx_paragraph.paragraph_format.left_indent = Cm(0)
|
||||
heading._docx_paragraph.paragraph_format.first_line_indent = Cm(0)
|
||||
|
||||
# Native Word TOC picks up Heading 1–3; «СОДЕРЖАНИЕ» must not list itself
|
||||
if upper == "СОДЕРЖАНИЕ":
|
||||
@@ -159,69 +218,113 @@ class Renderer:
|
||||
# After ToC page-break renderable we open body section — detected via ToC's PageBreak
|
||||
from .renderable.toc import ToC
|
||||
from .renderable.page_break import PageBreak
|
||||
from .renderable.table import Table
|
||||
from .renderable.listing import Listing
|
||||
from .renderable.diagram import DiagramFigure
|
||||
from .renderable.list import List as RList
|
||||
|
||||
if isinstance(renderable, PageBreak) and self._after_toc:
|
||||
self._ensure_body_section_with_page_numbers()
|
||||
|
||||
if isinstance(renderable, ToC):
|
||||
self._after_toc = True
|
||||
|
||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||
if isinstance(renderable, Equation):
|
||||
label = renderable.unique_name
|
||||
if renderable.needs_numbering or (label and label in self._numbered_equations):
|
||||
renderable.enable_numbering()
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category, label)
|
||||
renderable.set_number(number)
|
||||
else:
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category,
|
||||
getattr(renderable, "unique_name", None),
|
||||
)
|
||||
renderable.set_number(number)
|
||||
# Space 6 mm before next body paragraph after table/listing
|
||||
if self._need_space_after_object:
|
||||
self._need_space_after_object = False
|
||||
if isinstance(renderable, (Paragraph, RList)) and not isinstance(renderable, Heading):
|
||||
if isinstance(renderable, RList):
|
||||
# Apply to first list item when rendered — mark on list
|
||||
setattr(renderable, "_space_before_mm6", True)
|
||||
else:
|
||||
renderable._docx_paragraph.paragraph_format.space_before = Mm(6)
|
||||
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
wants_landscape = bool(getattr(renderable, "landscape", False))
|
||||
deferred_listing = None
|
||||
if wants_landscape and isinstance(renderable, DiagramFigure) and renderable.listing is not None:
|
||||
# Method guide: landscape pages are for figures/tables only — listing after
|
||||
deferred_listing = renderable.listing
|
||||
renderable.listing = None
|
||||
|
||||
if wants_landscape:
|
||||
self._enter_landscape()
|
||||
|
||||
try:
|
||||
first = next(infos)
|
||||
if isinstance(first, RenderedInfo) and first.height \
|
||||
>= self._layout_tracker.current_state.remaining_page_height:
|
||||
self._flush_to_new_screen()
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
else:
|
||||
infos = chain([first], infos)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
for info in infos:
|
||||
if isinstance(info, SubRenderable):
|
||||
if info.add_to_new_page:
|
||||
self._to_new_page.append(info.renderable)
|
||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||
if isinstance(renderable, Equation):
|
||||
label = renderable.unique_name
|
||||
if renderable.needs_numbering or (label and label in self._numbered_equations):
|
||||
renderable.enable_numbering()
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category, label)
|
||||
renderable.set_number(number)
|
||||
else:
|
||||
self.render(info.renderable)
|
||||
else:
|
||||
self._add(info.docx_element, info.height)
|
||||
self.previous_rendered = info
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category,
|
||||
getattr(renderable, "unique_name", None),
|
||||
)
|
||||
renderable.set_number(number)
|
||||
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
|
||||
try:
|
||||
first = next(infos)
|
||||
if isinstance(first, RenderedInfo) and first.height \
|
||||
>= self._layout_tracker.current_state.remaining_page_height:
|
||||
self._flush_to_new_screen()
|
||||
infos = renderable.render(self.previous_rendered, self._layout_tracker.current_state)
|
||||
else:
|
||||
infos = chain([first], infos)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
for info in infos:
|
||||
if isinstance(info, SubRenderable):
|
||||
if info.add_to_new_page:
|
||||
self._to_new_page.append(info.renderable)
|
||||
else:
|
||||
self.render(info.renderable)
|
||||
else:
|
||||
self._add(info.docx_element, info.height)
|
||||
self.previous_rendered = info
|
||||
finally:
|
||||
if wants_landscape:
|
||||
self._exit_landscape()
|
||||
|
||||
# Appendix title line queued by _handle_heading
|
||||
while self._after_current:
|
||||
extra = self._after_current.pop(0)
|
||||
self.render(extra)
|
||||
|
||||
if deferred_listing is not None:
|
||||
self.render(deferred_listing)
|
||||
|
||||
if isinstance(renderable, (Table, Listing)):
|
||||
self._need_space_after_object = True
|
||||
elif isinstance(renderable, DiagramFigure) and deferred_listing is None and renderable.listing is None:
|
||||
# Diagram figure alone — no table/listing spacing needed after image caption
|
||||
pass
|
||||
|
||||
def _flush_to_new_screen(self):
|
||||
while self._to_new_page:
|
||||
renderable = self._to_new_page.pop(0)
|
||||
if not self._skip_numbering and isinstance(renderable, RequiresNumbering):
|
||||
number = self._numberer.next_number(
|
||||
renderable.numbering_category,
|
||||
getattr(renderable, "unique_name", None),
|
||||
)
|
||||
renderable.set_number(number)
|
||||
for info_ in renderable.render(self.previous_rendered, self._layout_tracker.current_state):
|
||||
if isinstance(info_, SubRenderable):
|
||||
continue
|
||||
self._add(info_.docx_element, info_.height)
|
||||
self.previous_rendered = info_
|
||||
self.render(renderable)
|
||||
|
||||
def _add(self, element: Parented, height: Length):
|
||||
self._document._body._element.append(
|
||||
element._element
|
||||
)
|
||||
# MUST insert before the body-level w:sectPr. append() puts content after it,
|
||||
# so section breaks from +landscape never wrap the figure (Word shows portrait).
|
||||
body = self._document._body._element
|
||||
el = element._element
|
||||
from docx.oxml.ns import qn
|
||||
sect_pr = None
|
||||
for child in body:
|
||||
if child.tag == qn("w:sectPr"):
|
||||
sect_pr = child
|
||||
break
|
||||
if sect_pr is not None:
|
||||
sect_pr.addprevious(el)
|
||||
else:
|
||||
body.append(el)
|
||||
self._layout_tracker.add_height(height)
|
||||
|
||||
if self._debugger:
|
||||
|
||||
+40
-8
@@ -173,13 +173,11 @@ def apply_document_styles(document: Document, style_preset: str = "mirea") -> No
|
||||
|
||||
|
||||
def _apply_common_page_and_body(document: Document) -> None:
|
||||
from .page_geometry import apply_section_geometry, is_landscape_section
|
||||
|
||||
for section in document.sections:
|
||||
section.page_width = Mm(210)
|
||||
section.page_height = Mm(297)
|
||||
section.left_margin = Mm(30)
|
||||
section.right_margin = Mm(10)
|
||||
section.top_margin = Mm(20)
|
||||
section.bottom_margin = Mm(20)
|
||||
# Do not wipe landscape sections created for +landscape figures/tables.
|
||||
apply_section_geometry(section, landscape=is_landscape_section(section))
|
||||
|
||||
_fix_toc_tab_stops(document)
|
||||
|
||||
@@ -209,7 +207,7 @@ def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
cpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
|
||||
cpf.widow_control = True
|
||||
|
||||
# --- Caption Table: 12pt italic, left, above table ---
|
||||
# --- 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
|
||||
@@ -219,8 +217,22 @@ def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
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)
|
||||
@@ -256,7 +268,9 @@ def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
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
|
||||
@@ -276,12 +290,28 @@ def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
bhpf = biblio_h.paragraph_format
|
||||
bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
bhpf.first_line_indent = Cm(0)
|
||||
bhpf.left_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)
|
||||
@@ -301,4 +331,6 @@ def _apply_common_captions_and_misc(document: Document) -> None:
|
||||
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
|
||||
|
||||
Vendored
@@ -0,0 +1,525 @@
|
||||
"""Post-process DOCX in Word: split tables/listings at real page breaks + «Продолжение…».
|
||||
|
||||
Requires Windows + Microsoft Word + pywin32. Pure helpers below are unit-testable
|
||||
without Word.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# wdActiveEndPageNumber
|
||||
_WD_ACTIVE_END_PAGE_NUMBER = 3
|
||||
|
||||
_CAPTION_RE = re.compile(
|
||||
r"^(?:Продолжение\s+)?"
|
||||
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
|
||||
r"(?P<number>[\d.]+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
|
||||
|
||||
MAX_PASSES = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptionInfo:
|
||||
kind: str # "table" | "listing"
|
||||
number: str
|
||||
is_continuation: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FixResult:
|
||||
ok: bool
|
||||
splits: int = 0
|
||||
skipped: int = 0
|
||||
message: str = ""
|
||||
details: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_caption_text(text: str) -> CaptionInfo | None:
|
||||
"""Parse «Таблица 2.1 — …» / «Продолжение Таблицы 2.1» / «Листинг 1 …»."""
|
||||
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
first = raw.split("\n", 1)[0].strip()
|
||||
m = _CAPTION_RE.match(first)
|
||||
if not m:
|
||||
return None
|
||||
kind_raw = m.group("kind").lower()
|
||||
if kind_raw.startswith("табл"):
|
||||
kind = "table"
|
||||
elif kind_raw.startswith("лист"):
|
||||
kind = "listing"
|
||||
else:
|
||||
return None
|
||||
return CaptionInfo(
|
||||
kind=kind,
|
||||
number=m.group("number"),
|
||||
is_continuation=bool(_CONTINUATION_RE.match(first)),
|
||||
)
|
||||
|
||||
|
||||
def find_page_break_row(page_numbers: list[int]) -> int | None:
|
||||
"""
|
||||
Return 1-based Word row index where a new page starts.
|
||||
|
||||
page_numbers[i] is the page of row i+1. None if the table does not cross pages.
|
||||
"""
|
||||
if len(page_numbers) < 2:
|
||||
return None
|
||||
for i in range(1, len(page_numbers)):
|
||||
if page_numbers[i] > page_numbers[i - 1]:
|
||||
row_1based = i + 1
|
||||
if row_1based <= 1:
|
||||
return None
|
||||
return row_1based
|
||||
return None
|
||||
|
||||
|
||||
def continuation_label(kind: str, number: str) -> str:
|
||||
if kind == "listing":
|
||||
return f"Продолжение Листинга {number}"
|
||||
return f"Продолжение Таблицы {number}"
|
||||
|
||||
|
||||
def caption_style_name(kind: str) -> str:
|
||||
return "Caption Listing" if kind == "listing" else "Название таблицы"
|
||||
|
||||
|
||||
def fix_continuations(
|
||||
path: str,
|
||||
*,
|
||||
tables: bool = True,
|
||||
listings: bool = True,
|
||||
repeat_header: bool = False,
|
||||
) -> FixResult:
|
||||
"""
|
||||
Open DOCX in Word, split cross-page tables/listings, insert continuation captions.
|
||||
|
||||
On missing Word/pywin32 returns ok=False with a reason (caller should not fail convert).
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return FixResult(
|
||||
False,
|
||||
message="Пост-разрыв таблиц (word): только Windows + Microsoft Word.",
|
||||
)
|
||||
try:
|
||||
import win32com.client # type: ignore
|
||||
except ImportError:
|
||||
return FixResult(
|
||||
False,
|
||||
message=(
|
||||
"Пост-разрыв таблиц пропущен: нужен pywin32 "
|
||||
"(pip install pywin32) и Microsoft Word."
|
||||
),
|
||||
)
|
||||
|
||||
abs_path = os.path.abspath(path)
|
||||
if not os.path.isfile(abs_path):
|
||||
return FixResult(False, message=f"Файл не найден: {abs_path}")
|
||||
|
||||
word = None
|
||||
doc = None
|
||||
splits = 0
|
||||
details: list[str] = []
|
||||
try:
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(abs_path, ReadOnly=False)
|
||||
for _pass in range(MAX_PASSES):
|
||||
doc.Repaginate()
|
||||
made = _fix_pass(
|
||||
doc,
|
||||
tables=tables,
|
||||
listings=listings,
|
||||
repeat_header=repeat_header,
|
||||
details=details,
|
||||
)
|
||||
if made == 0:
|
||||
break
|
||||
splits += made
|
||||
|
||||
skipped = sum(1 for d in details if d.startswith("skip:"))
|
||||
doc.Save()
|
||||
msg = (
|
||||
f"Пост-разрыв Word: разрезов {splits}"
|
||||
+ (f", пропусков {skipped}" if skipped else "")
|
||||
+ "."
|
||||
)
|
||||
return FixResult(True, splits=splits, skipped=skipped, message=msg, details=details)
|
||||
except Exception as exc:
|
||||
skipped = sum(1 for d in details if d.startswith("skip:"))
|
||||
return FixResult(
|
||||
False,
|
||||
splits=splits,
|
||||
skipped=skipped,
|
||||
message=f"Пост-разрыв таблиц не удался: {exc}",
|
||||
details=details,
|
||||
)
|
||||
finally:
|
||||
if doc is not None:
|
||||
try:
|
||||
doc.Close(False)
|
||||
except Exception:
|
||||
pass
|
||||
if word is not None:
|
||||
try:
|
||||
word.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _row_page_numbers(table) -> list[int]:
|
||||
"""Page number at the start of each row (more reliable than end-of-range)."""
|
||||
pages: list[int] = []
|
||||
n = int(table.Rows.Count)
|
||||
for i in range(1, n + 1):
|
||||
try:
|
||||
rng = table.Rows(i).Range
|
||||
# Collapse to start so a tall row reports where it begins
|
||||
start = int(rng.Start)
|
||||
page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
try:
|
||||
page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
page = pages[-1] if pages else 1
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def _paragraph_page_numbers_in_cell(table) -> list[int]:
|
||||
"""Fallback: page of each paragraph in a single-cell table (legacy 1-row listings)."""
|
||||
try:
|
||||
if int(table.Rows.Count) != 1:
|
||||
return []
|
||||
cell = table.Cell(1, 1)
|
||||
paras = cell.Range.Paragraphs
|
||||
pages: list[int] = []
|
||||
for i in range(1, int(paras.Count) + 1):
|
||||
try:
|
||||
start = int(paras(i).Range.Start)
|
||||
page = int(paras(i).Range.Document.Range(start, start).Information(
|
||||
_WD_ACTIVE_END_PAGE_NUMBER
|
||||
))
|
||||
except Exception:
|
||||
page = pages[-1] if pages else 1
|
||||
pages.append(page)
|
||||
return pages
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _expand_single_cell_listing_to_rows(table) -> bool:
|
||||
"""
|
||||
Convert 1-row listing (many paragraphs in one cell) into one row per paragraph.
|
||||
Returns True if the table was rewritten.
|
||||
"""
|
||||
try:
|
||||
if int(table.Rows.Count) != 1:
|
||||
return False
|
||||
cell = table.Cell(1, 1)
|
||||
paras = cell.Range.Paragraphs
|
||||
count = int(paras.Count)
|
||||
if count <= 1:
|
||||
return False
|
||||
# Collect plain texts first (mutating while iterating is unsafe)
|
||||
texts: list[str] = []
|
||||
for i in range(1, count + 1):
|
||||
t = (paras(i).Range.Text or "").replace("\r", "").replace("\x07", "")
|
||||
texts.append(t)
|
||||
# Clear cell, keep first paragraph as first row content
|
||||
cell.Range.Text = texts[0] if texts else ""
|
||||
# Add rows for remaining lines
|
||||
for t in texts[1:]:
|
||||
row = table.Rows.Add()
|
||||
row.Cells(1).Range.Text = t
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _fix_pass(
|
||||
doc,
|
||||
*,
|
||||
tables: bool,
|
||||
listings: bool,
|
||||
repeat_header: bool,
|
||||
details: list[str],
|
||||
) -> int:
|
||||
"""One pass over tables (bottom-up). Returns number of splits performed."""
|
||||
made = 0
|
||||
count = int(doc.Tables.Count)
|
||||
for ti in range(count, 0, -1):
|
||||
try:
|
||||
table = doc.Tables(ti)
|
||||
except Exception:
|
||||
details.append(f"skip: table[{ti}] inaccessible")
|
||||
continue
|
||||
try:
|
||||
if int(table.NestingLevel) > 1:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
caption = _caption_before_table(table)
|
||||
if caption is None:
|
||||
continue
|
||||
if caption.kind == "table" and not tables:
|
||||
continue
|
||||
if caption.kind == "listing" and not listings:
|
||||
continue
|
||||
|
||||
# Legacy single-cell listings: expand to rows so Split works
|
||||
if caption.kind == "listing" and int(table.Rows.Count) == 1:
|
||||
para_pages = _paragraph_page_numbers_in_cell(table)
|
||||
if find_page_break_row(para_pages) is not None:
|
||||
if _expand_single_cell_listing_to_rows(table):
|
||||
details.append(f"expand: listing {caption.number} → {table.Rows.Count} rows")
|
||||
doc.Repaginate()
|
||||
|
||||
pages = _row_page_numbers(table)
|
||||
break_at = find_page_break_row(pages)
|
||||
if break_at is None:
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
continue
|
||||
|
||||
had_header = False
|
||||
try:
|
||||
had_header = bool(table.Rows(1).HeadingFormat)
|
||||
except Exception:
|
||||
had_header = False
|
||||
|
||||
try:
|
||||
table.Split(break_at)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: split table[{ti}] row {break_at}: {exc}")
|
||||
continue
|
||||
|
||||
try:
|
||||
cont = doc.Tables(ti + 1)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
|
||||
made += 1
|
||||
continue
|
||||
|
||||
before_cont = _paragraph_text_before_table(cont)
|
||||
first_line = before_cont.strip().split("\n", 1)[0] if before_cont else ""
|
||||
if not (first_line and _CONTINUATION_RE.match(first_line)):
|
||||
try:
|
||||
_insert_continuation_before(doc, cont, caption)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: insert caption after split: {exc}")
|
||||
|
||||
# Open bottom of first fragment (tables only — listings keep a full frame)
|
||||
if caption.kind == "table":
|
||||
try:
|
||||
_clear_table_bottom_border(table)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: clear bottom border: {exc}")
|
||||
|
||||
if caption.kind == "listing":
|
||||
try:
|
||||
_apply_listing_box_borders(table)
|
||||
_apply_listing_box_borders(cont)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: listing borders: {exc}")
|
||||
|
||||
# Header repeat is opt-in (default off)
|
||||
if repeat_header and had_header and caption.kind == "table":
|
||||
try:
|
||||
_ensure_header_on_continuation(first_table=table, cont_table=cont)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: header copy: {exc}")
|
||||
|
||||
made += 1
|
||||
details.append(f"split: {caption.kind} {caption.number} @row {break_at}")
|
||||
|
||||
return made
|
||||
|
||||
def _caption_before_table(table) -> CaptionInfo | None:
|
||||
text = _paragraph_text_before_table(table)
|
||||
if not text:
|
||||
return None
|
||||
return parse_caption_text(text)
|
||||
|
||||
|
||||
def _paragraph_text_before_table(table) -> str:
|
||||
try:
|
||||
rng = table.Range
|
||||
if rng.Start <= 1:
|
||||
return ""
|
||||
doc = table.Range.Document
|
||||
prev = doc.Range(rng.Start - 1, rng.Start)
|
||||
p = prev.Paragraphs(1)
|
||||
return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None:
|
||||
"""Insert caption paragraph *outside* the table (before its start).
|
||||
|
||||
``InsertBefore`` at ``table.Range.Start`` puts text into the first cell —
|
||||
Word treats the table start as inside the table. Move one character before
|
||||
the table, insert a paragraph break, then fill that new paragraph.
|
||||
"""
|
||||
label = continuation_label(caption.kind, caption.number)
|
||||
style = caption_style_name(caption.kind)
|
||||
|
||||
# wdCollapseStart=1, wdCharacter=1
|
||||
rng = table.Range.Duplicate
|
||||
rng.Collapse(1)
|
||||
start0 = int(table.Range.Start)
|
||||
if start0 > 0:
|
||||
rng.Move(1, -1) # land on the paragraph mark before the table
|
||||
rng.InsertParagraphAfter()
|
||||
# New empty paragraph sits between previous content and the table.
|
||||
# Refresh table start — it moved forward by one paragraph mark.
|
||||
table_start = int(table.Range.Start)
|
||||
if table_start < 1:
|
||||
raise RuntimeError("table at document start after insert")
|
||||
# Paragraph immediately before the table
|
||||
para = doc.Range(table_start - 1, table_start - 1).Paragraphs(1)
|
||||
# Write into the paragraph without including the trailing \r that borders the table
|
||||
text_rng = para.Range.Duplicate
|
||||
# Exclude final paragraph mark so we don't merge into the table
|
||||
if int(text_rng.End) > int(text_rng.Start):
|
||||
text_rng.End = int(text_rng.End) - 1
|
||||
text_rng.Text = label
|
||||
|
||||
try:
|
||||
para.Style = style
|
||||
except Exception:
|
||||
# Fallback if RU style missing in older docs
|
||||
try:
|
||||
para.Style = "Caption Table" if caption.kind == "table" else "Caption"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
para.Range.Font.Italic = True
|
||||
para.Range.Font.Bold = False
|
||||
para.Range.Font.Underline = 0 # wdUnderlineNone
|
||||
para.Range.Font.Name = "Times New Roman"
|
||||
para.Range.Font.Size = 12
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
para.Format.FirstLineIndent = 0
|
||||
para.Format.SpaceAfter = 0
|
||||
para.Format.SpaceBefore = 6 # pt ≈ Mm(6) for first continuation look
|
||||
para.Format.KeepWithNext = True
|
||||
para.Format.Alignment = 0 # wdAlignParagraphLeft
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sanity: caption must not live inside a table cell
|
||||
try:
|
||||
if int(para.Range.Tables.Count) > 0:
|
||||
raise RuntimeError("continuation caption landed inside a table")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
# WdBorderType (Word): top=-1, left=-2, bottom=-3, right=-4, insideH=-5, insideV=-6
|
||||
_WD_BORDER_TOP = -1
|
||||
_WD_BORDER_LEFT = -2
|
||||
_WD_BORDER_BOTTOM = -3
|
||||
_WD_BORDER_RIGHT = -4
|
||||
_WD_BORDER_HORIZONTAL = -5
|
||||
_WD_BORDER_VERTICAL = -6
|
||||
_WD_LINE_STYLE_NONE = 0
|
||||
_WD_LINE_STYLE_SINGLE = 1
|
||||
|
||||
|
||||
def _set_border(table, border_id: int, *, line_style: int, line_width: float = 0.5) -> None:
|
||||
b = table.Borders(border_id)
|
||||
b.LineStyle = line_style
|
||||
if line_style != _WD_LINE_STYLE_NONE:
|
||||
try:
|
||||
b.LineWidth = line_width
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _clear_table_bottom_border(table) -> None:
|
||||
_set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_NONE)
|
||||
|
||||
|
||||
def _apply_listing_box_borders(table) -> None:
|
||||
"""Outer frame only — no inside H/V (code block look)."""
|
||||
_set_border(table, _WD_BORDER_TOP, line_style=_WD_LINE_STYLE_SINGLE)
|
||||
_set_border(table, _WD_BORDER_LEFT, line_style=_WD_LINE_STYLE_SINGLE)
|
||||
_set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_SINGLE)
|
||||
_set_border(table, _WD_BORDER_RIGHT, line_style=_WD_LINE_STYLE_SINGLE)
|
||||
try:
|
||||
_set_border(table, _WD_BORDER_HORIZONTAL, line_style=_WD_LINE_STYLE_NONE)
|
||||
_set_border(table, _WD_BORDER_VERTICAL, line_style=_WD_LINE_STYLE_NONE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cell_plain_text(cell) -> str:
|
||||
"""Cell text without end-of-cell markers."""
|
||||
raw = cell.Range.Text or ""
|
||||
return raw.replace("\r", "").replace("\x07", "").replace("\a", "").strip("\n")
|
||||
|
||||
|
||||
def _ensure_header_on_continuation(*, first_table, cont_table) -> None:
|
||||
"""Prepend a copy of the first fragment's header row onto the continuation table."""
|
||||
try:
|
||||
if int(cont_table.Rows.Count) < 1:
|
||||
return
|
||||
if bool(cont_table.Rows(1).HeadingFormat):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
hdr = first_table.Rows(1)
|
||||
n_hdr = int(hdr.Cells.Count)
|
||||
# Insert empty row above first data row
|
||||
new_row = cont_table.Rows.Add(BeforeRow=cont_table.Rows(1))
|
||||
n_new = int(new_row.Cells.Count)
|
||||
n = min(n_hdr, n_new)
|
||||
for ci in range(1, n + 1):
|
||||
try:
|
||||
# Prefer FormattedText but strip the cell's terminal markers by
|
||||
# assigning only the in-cell paragraph text.
|
||||
src_cell = hdr.Cells(ci)
|
||||
dst_cell = new_row.Cells(ci)
|
||||
# Clear destination cell paragraphs then copy plain text
|
||||
dst_cell.Range.Text = ""
|
||||
plain = _cell_plain_text(src_cell)
|
||||
# Setting Range.Text on a cell appends \r\a — pass plain only
|
||||
if plain:
|
||||
dst_cell.Range.Text = plain
|
||||
# Best-effort: copy bold/italic from first paragraph of source
|
||||
try:
|
||||
src_font = src_cell.Range.Paragraphs(1).Range.Font
|
||||
dst_p = dst_cell.Range.Paragraphs(1).Range
|
||||
dst_p.Font.Bold = src_font.Bold
|
||||
dst_p.Font.Italic = False # header data, not caption
|
||||
dst_p.Font.Name = src_font.Name
|
||||
dst_p.Font.Size = src_font.Size
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
continue
|
||||
new_row.HeadingFormat = True
|
||||
# Ensure data rows are not marked as header
|
||||
try:
|
||||
for ri in range(2, int(cont_table.Rows.Count) + 1):
|
||||
cont_table.Rows(ri).HeadingFormat = False
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Don't leave a half-broken row — best effort only
|
||||
pass
|
||||
Reference in New Issue
Block a user