Update 0.4.0
Python application / build (push) Has been cancelled

- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
This commit is contained in:
Igor20264
2026-09-04 22:28:39 +03:00
parent 516abe7b83
commit b38661f588
70 changed files with 69532 additions and 413 deletions
Binary file not shown.
+135 -13
View File
@@ -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 (шаги 23).
Формат в 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).
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+261
View File
@@ -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
View File
@@ -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",
]
+335
View File
@@ -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
+60
View File
@@ -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
View File
@@ -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
+25
View File
@@ -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)
+55
View File
@@ -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
+20 -5
View File
@@ -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:
+7 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -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.
![описание](images/arch.png "%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
+10
View File
@@ -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
+226
View File
@@ -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)
+142
View File
@@ -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
View File
@@ -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
+303
View File
@@ -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
View File
@@ -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")
+6 -2
View File
@@ -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
+10 -3
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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)))
+4
View File
@@ -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)
+91 -32
View File
@@ -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))
+16 -8
View File
@@ -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:
+5 -3
View File
@@ -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
+6 -1
View File
@@ -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))
+24 -5
View File
@@ -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
View File
@@ -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 13; «СОДЕРЖАНИЕ» 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
View File
@@ -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
View File
+525
View File
@@ -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