v0.5.2
Python application / build (push) Waiting to run

Что то сделал
This commit is contained in:
Igor20264
2026-09-08 19:37:54 +03:00
parent 1a5b35eb54
commit 510f7e7adf
90 changed files with 11720 additions and 5547 deletions
+10 -30
View File
@@ -102,7 +102,7 @@ python -m md2gost report.md -o report.docx --toc manual
| Таблица | `%id Подпись` перед таблицей + `@Таблица:id` |
| Склеивание ячеек | `^` — rowspan (ячейка сверху), `>` — colspan (ячейка слева) |
| Листинг | `%id Подпись` перед code fence |
| Диаграмма UML / Mermaid / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````bpmn` / ````mermaid` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json`. IDEF0 нет |
| Диаграмма UML / Mermaid / IDEF0 / DFD / схемы | `%id Подпись` + ````uml` / ````uml-c4` / ````mermaid` / ````idef0` / ````dfd` → Рисунок; `+listing` — ещё и Листинг. Схемы в `md2gost.schemes.json` |
| Формула | `%eq1` + `$$…$$` + `@Формула:eq1` (номер только при ссылке) |
| Источник | `[1]` в тексте (кликабельная ссылка на пункт списка); `[1]: …` в списке |
| Разрыв страницы | `---` на отдельной строке + `--hr-pagebreak` (по умолчанию `---` игнорируется) |
@@ -136,7 +136,7 @@ Word COM режет по реальной пагинации. Без Word — у
| Режим | Поведение |
|--------|-----------|
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`) |
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
@@ -208,34 +208,7 @@ Rel(user, app, "логин")
```
````
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **BPMN 2.0** — оградка ````bpmn` / ````uml-bpmn` (макросы `Start`, `UserTask`, `XOR`, `Flow`, `Pool`…; библиотека `diagrams/BPMN.puml`). **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** конвертер не рисует — вставляйте готовый PNG.
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw, "ок?")
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
````markdown
%bpmn1 Процесс заявки
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw, "ок?")
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
````
URL в `includes` схемы скачиваются в кэш (`md2gost.include-cache.json` + папка `include-cache/`). CLI: `--schemes path.json`. **Mermaid** — ````mermaid` / ````mmd`: системный браузер → Playwright Chromium → QuickJS (`mermaidx`) → Kroki (свой `--kroki-url` или kroki.io). **IDEF0** — ````idef0`: локальный Pillow (рамка NIST, стрелки ICOM). **DFD** — ````dfd`: [data-flow-diagram](https://github.com/pbauermeister/dfd) → вшитый/системный Graphviz или Kroki (`scripts/fetch_graphviz.py`, GUI «Скачать Graphviz»).
Обычному пользователю jar/Kroki указывать не нужно. Порядок для UML:
@@ -250,6 +223,12 @@ Mermaid:
3. QuickJS + mermaid.js (`mermaidx`) — офлайн без браузера
4. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
DFD (````dfd`):
1. Пакет `data-flow-diagram` (зависимость md2gost) → DOT
2. Graphviz: вшитый `md2gost/vendor/graphviz` (сборка exe / `scripts/fetch_graphviz.py`) → `%LOCALAPPDATA%\md2gost\graphviz` (GUI «Скачать Graphviz») → `PATH`
3. Локальный Kroki → `https://kroki.io` при `--diagram-fallback remote`
Формат в Word: `--diagram-format png` (по умолчанию; PlantUML и локальный Mermaid с `--diagram-scale`, по умолчанию 2 — только качество, размер на странице как при 1) или `svg` — вектор через `svgBlip` + PNG-запасной (Word 2016+; LibreOffice покажет растр).
Широкие схемы/таблицы: в подписи флаг `+landscape` — отдельная альбомная A4-страница, затем снова книжная.
@@ -260,6 +239,7 @@ Mermaid:
```bash
python scripts/fetch_plantuml.py
python scripts/fetch_graphviz.py
python scripts/fetch_mermaid.py
python -m md2gost --install-chromium
python -m md2gost report.md -o report.docx --diagram-fallback local
Binary file not shown.
+82 -2
View File
@@ -2,6 +2,7 @@
from argparse import ArgumentParser, BooleanOptionalAction
import sys
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
from .pipeline import ConvertRequest, convert, should_launch_gui
from .profiles import (
DEFAULT_HEADING_NUMBERING,
@@ -23,12 +24,13 @@ def build_parser() -> ArgumentParser:
"Генерация DOCX-отчётов из Markdown по ТЗ МИРЭА / ГОСТ. "
"Типы: coursework/practice/vkr, APID_coursework, PIS_custom. "
"Без файла или с --gui открывается окно. "
"DOCX → Markdown: python -m word2md файл.docx (или md2gost файл.docx). "
"FODT: python -m md2fodt …"
),
)
parser.add_argument(
"filename", nargs="?",
help="Путь до .md (или .docx с --check-pages). Без файла открывается GUI",
help="Путь до .md (сборка DOCX), .docx (импорт в Markdown) или .docx с --check-pages",
)
parser.add_argument(
"--gui", action="store_true",
@@ -116,8 +118,75 @@ def build_parser() -> ArgumentParser:
"По умолчанию такие строки игнорируются; включить: --hr-pagebreak."
),
)
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом)")
parser.add_argument("--title", help="DOCX титульного листа (вставляется перед телом; "
"отключает автогенерацию)")
parser.add_argument(
"--auto-title",
action=BooleanOptionalAction,
default=False,
help=(
"Сгенерировать титул из info_conv.yaml / блока ```title (нужны ФИО и группа). "
"По умолчанию выкл.; включить: --auto-title. Явный --title отключает генератор."
),
)
parser.add_argument("--assignment", help="DOCX бланка задания")
parser.add_argument(
"--student",
help="ФИО студента для автотитула (иначе md2gost.user.json)",
)
parser.add_argument(
"--group",
help="Учебная группа для автотитула (иначе md2gost.user.json)",
)
parser.add_argument(
"--doc-author-from",
choices=AUTHOR_SOURCES,
default=None,
help=(
"Автор в свойствах DOCX: os — имя пользователя ОС; "
"student — ФИО из профиля; custom — --doc-author / md2gost.user.json. "
"Иначе берётся из Настройки → Метаданные (по умолчанию os)."
),
)
parser.add_argument(
"--doc-author",
default=None,
help="Автор в свойствах DOCX (перебивает --doc-author-from)",
)
parser.add_argument(
"--doc-last-modified-by",
default=None,
help="Поле «Кем изменён» в свойствах DOCX; пусто — как автор",
)
parser.add_argument(
"--doc-title",
default=None,
help="Название в свойствах DOCX (File → Info)",
)
parser.add_argument(
"--doc-subject",
default=None,
help="Тема в свойствах DOCX",
)
parser.add_argument(
"--doc-keywords",
default=None,
help="Ключевые слова (теги) в свойствах DOCX",
)
parser.add_argument(
"--doc-comments",
default=None,
help=(
"Примечание в свойствах DOCX. "
f"По умолчанию: «{DEFAULT_DOC_COMMENTS}». "
"Пустая строка — не писать примечание."
),
)
parser.add_argument(
"--doc-category",
default=None,
help="Категория в свойствах DOCX",
)
parser.add_argument("--check", help="Проверить markdown по ТЗ и вывести отчёт",
action="store_true")
parser.add_argument("--check-only", help="Только проверка, без генерации документа",
@@ -215,6 +284,17 @@ def request_from_args(args) -> ConvertRequest:
hr_pagebreak=args.hr_pagebreak,
title=args.title,
assignment=args.assignment,
student=getattr(args, "student", None),
group=getattr(args, "group", None),
doc_author_source=getattr(args, "doc_author_from", None),
doc_author=getattr(args, "doc_author", None),
doc_last_modified_by=getattr(args, "doc_last_modified_by", None),
doc_title=getattr(args, "doc_title", None),
doc_subject=getattr(args, "doc_subject", None),
doc_keywords=getattr(args, "doc_keywords", None),
doc_comments=getattr(args, "doc_comments", None),
doc_category=getattr(args, "doc_category", None),
auto_title=bool(getattr(args, "auto_title", False)),
check=args.check,
check_only=args.check_only,
check_pages=bool(getattr(args, "check_pages", False)),
+17 -2
View File
@@ -39,6 +39,21 @@ def entries_from_text(text: str) -> list[BiblioEntry]:
return entries
# After citation linking, `[5]: …` often becomes `[]: …` in paragraph.text
# (the digits live in a hyperlink). Still a biblio source line to drop.
BIBLIO_RESIDUE_RE = re.compile(r"^\[\d*(?:\.\d+)?\]:\s*\S")
def is_biblio_source_paragraph(text: str) -> bool:
"""True if paragraph is a [n]: / []: bibliography source line (or empty)."""
stripped = (text or "").strip()
if not stripped:
return True
if entries_from_text(stripped):
return True
return bool(BIBLIO_RESIDUE_RE.match(stripped))
def extract_bibliography_from_markdown(md: str) -> list[BiblioEntry] | list[tuple[str, list[BiblioEntry]]]:
"""
Parse bibliography from raw markdown (reliable even if Marko merges lines).
@@ -126,8 +141,8 @@ def fold_bibliography(renderables: list[Renderable], parent,
consumed += 1
continue
if isinstance(item, Paragraph):
text = (item._docx_paragraph.text or "").strip()
if entries_from_text(text) or (not text):
text = item._docx_paragraph.text or ""
if is_biblio_source_paragraph(text):
consumed += 1
continue
break
+8 -3
View File
@@ -46,7 +46,8 @@ class Converter:
table_continuation: str = DEFAULT_TABLE_CONTINUATION,
listing_continuation: str = DEFAULT_LISTING_CONTINUATION,
hr_pagebreak: bool = False,
styles_path: str | None = None):
styles_path: str | None = None,
markdown_text: str | None = None):
if heading_numbering not in HEADING_NUMBERING_MODES:
raise ValueError(
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
@@ -79,6 +80,7 @@ class Converter:
self._document: Document = docx.Document(template_path)
self._document._body.clear_content()
md_dir = os.path.dirname(os.path.abspath(input_path)) or "."
os.environ["WORKING_DIR"] = md_dir
self._style_config = apply_document_styles(
self._document,
self._profile.style_preset,
@@ -86,8 +88,11 @@ class Converter:
styles_path=styles_path,
)
self._debugger = Debugger(self._document) if debug else None
with open(input_path, encoding="utf-8") as f:
raw = f.read()
if markdown_text is not None:
raw = markdown_text
else:
with open(input_path, encoding="utf-8") as f:
raw = f.read()
self._raw_markdown = raw
self._preprocessed = preprocess_markdown(raw, emdash_to_hyphen=emdash_to_hyphen)
self.parser = Parser(self._document, self._preprocessed, hr_pagebreak=hr_pagebreak)
+477
View File
@@ -0,0 +1,477 @@
"""DFD via pbauermeister/data-flow-diagram (Graphviz).
Fence: ```dfd (aliases ```uml-dfd, ```data-flow-diagram, ```yourdon, …).
DSL: https://github.com/pbauermeister/dfd
Pipeline:
1. Compile DSL → Graphviz DOT (`data-flow-diagram` Python API).
2. Rasterize: bundled/system Graphviz (`dot`/`neato`), else Kroki `graphviz`.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import requests
from . import package_dir
_log = logging.getLogger(__name__)
CACHE_KEY_PREFIX = "dfd-v3-pbauermeister\n"
# Context diagrams use neato; everything else uses dot (matches upstream templates).
_ENGINE_CONTEXT = "neato"
_ENGINE_DEFAULT = "dot"
# Official Windows portable build (same pattern as plantuml.jar vendor).
GRAPHVIZ_VERSION = "16.1.0"
GRAPHVIZ_ZIP_URL = (
"https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/"
f"{GRAPHVIZ_VERSION}/windows_10_cmake_Release_Graphviz-{GRAPHVIZ_VERSION}-win64.zip"
)
# Portable Graphviz ships Tcl 8.6.10 as tcl86t.dll. CPython 3.11's tkinter needs
# exactly 8.6.12 — if both land in the onefile extract, GUI dies on Tk().
# `dot` does not need the Tcl bindings.
GRAPHVIZ_TCL_DLL_NAMES = frozenset(
{
"tcl86t.dll",
"tk86t.dll",
"tcl86.dll",
"tk86.dll",
"tcldot.dll",
"tcldot_builtin.dll",
"tclplan.dll",
"gdtclft.dll",
}
)
class DfdParseError(ValueError):
"""Invalid DFD DSL or render tooling failure surfaced as parse/render error."""
SAMPLE_CONTEXT = """\
style context
entity Client Клиент
entity Warehouse Склад
process System Система учёта заявок
Client --> System заявка
System --> Client статус
System --> Warehouse накладная
"""
SAMPLE_LEVEL0 = """\
entity Client Клиент
entity Warehouse Склад
process Accept Принять заявку
process Register Зарегистрировать
process Ship Сформировать накладную
store Orders Заявки
Client --> Accept заявка
Accept --> Orders новая заявка
Orders --> Register данные заявки
Register --> Ship подтверждённая заявка
Ship --> Warehouse накладная
Ship --> Client уведомление
"""
def vendor_graphviz_root() -> Path:
return Path(package_dir()) / "vendor" / "graphviz"
def cached_graphviz_root() -> Path:
if os.name == "nt":
root = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / "md2gost"
else:
root = Path.home() / ".md2gost"
return root / "graphviz"
def _graphviz_bin_candidates() -> list[Path]:
roots: list[Path] = [vendor_graphviz_root()]
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
roots.append(Path(mei) / "md2gost" / "vendor" / "graphviz")
roots.append(Path(mei) / "vendor" / "graphviz")
roots.append(Path(sys.executable).resolve().parent / "graphviz")
roots.append(cached_graphviz_root())
bins: list[Path] = []
for root in roots:
bins.append(root / "bin")
bins.append(root)
return bins
def _engine_in_bin(bin_dir: Path, context: bool) -> Path | None:
if not bin_dir.is_dir():
return None
names = (
(_ENGINE_CONTEXT, _ENGINE_DEFAULT)
if context
else (_ENGINE_DEFAULT, _ENGINE_CONTEXT)
)
for name in names:
for cand in (bin_dir / f"{name}.exe", bin_dir / name):
if cand.is_file():
return cand
return None
def resolve_graphviz_engine(context: bool = False) -> str | None:
"""Path to Graphviz layout engine: vendor → cache → PATH."""
for bin_dir in _graphviz_bin_candidates():
hit = _engine_in_bin(bin_dir, context)
if hit is not None:
return str(hit)
if context:
return _which_many(
_ENGINE_CONTEXT, f"{_ENGINE_CONTEXT}.exe", _ENGINE_DEFAULT, "dot.exe",
)
return _which_many(_ENGINE_DEFAULT, "dot.exe", _ENGINE_CONTEXT, "neato.exe")
def _which_many(*names: str) -> str | None:
for name in names:
hit = shutil.which(name)
if hit:
return hit
return None
def _normalize_extracted_graphviz(extract_dir: Path, dest_root: Path) -> bool:
"""Find bin/dot(.exe) under extract_dir and copy tree into dest_root."""
dot = None
for path in extract_dir.rglob("dot.exe"):
if path.is_file():
dot = path
break
if dot is None:
for path in extract_dir.rglob("dot"):
if path.is_file() and os.access(path, os.X_OK):
dot = path
break
if dot is None:
return False
src_bin = dot.parent
src_root = src_bin.parent if src_bin.name.lower() == "bin" else src_bin
if dest_root.exists():
shutil.rmtree(dest_root)
dest_root.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src_root, dest_root)
strip_graphviz_tcl_libs(dest_root)
return (
_engine_in_bin(dest_root / "bin", False) is not None
or _engine_in_bin(dest_root, False) is not None
)
def strip_graphviz_tcl_libs(root: Path) -> int:
"""Remove Graphviz Tcl/Tk DLLs so they cannot shadow CPython's tkinter."""
root = Path(root)
if not root.is_dir():
return 0
removed = 0
bins = [root / "bin", root]
seen: set[Path] = set()
for bin_dir in bins:
if not bin_dir.is_dir():
continue
resolved = bin_dir.resolve()
if resolved in seen:
continue
seen.add(resolved)
for name in GRAPHVIZ_TCL_DLL_NAMES:
path = bin_dir / name
if path.is_file():
path.unlink()
removed += 1
tcl_mod = root / "lib" / "graphviz" / "tcl"
if tcl_mod.is_dir():
shutil.rmtree(tcl_mod)
removed += 1
return removed
def fetch_graphviz(dest_root: Path | None = None, url: str = GRAPHVIZ_ZIP_URL) -> bool:
"""Download official Graphviz win64 ZIP into dest_root (default: vendor)."""
dest = Path(dest_root) if dest_root is not None else vendor_graphviz_root()
if _engine_in_bin(dest / "bin", False) or _engine_in_bin(dest, False):
strip_graphviz_tcl_libs(dest)
return True
try:
_log.info("Downloading Graphviz %s", GRAPHVIZ_VERSION)
with requests.get(url, stream=True, timeout=180) as resp:
resp.raise_for_status()
with tempfile.TemporaryDirectory(prefix="md2gost-gv-") as tmp:
zpath = Path(tmp) / "graphviz.zip"
with zpath.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=1024 * 256):
if chunk:
fh.write(chunk)
extract_dir = Path(tmp) / "out"
extract_dir.mkdir()
with zipfile.ZipFile(zpath) as zf:
zf.extractall(extract_dir)
if not _normalize_extracted_graphviz(extract_dir, dest):
_log.warning("Graphviz ZIP распакован, но dot.exe не найден")
return False
return True
except Exception as exc:
_log.warning("Не удалось скачать Graphviz: %s", exc)
return False
def resolve_dfd_cli() -> list[str] | None:
"""Argv prefix that compiles DSL → DOT without needing Graphviz for compile."""
py = _which_many("py", "py.exe")
if py:
for ver in ("-3.14", "-3.13", "-3.12", "-3.11"):
try:
probe = subprocess.run(
[py, ver, "-c", "import data_flow_diagram"],
capture_output=True,
timeout=20,
)
if probe.returncode == 0:
return [py, ver, "-c", _API_DOT_RUNNER]
except (OSError, subprocess.TimeoutExpired):
continue
exe = _which_many("data-flow-diagram", "data-flow-diagram.exe")
if exe:
return [exe]
return None
_API_DOT_RUNNER = r"""
import sys
from data_flow_diagram.dfd import build
from data_flow_diagram import model
src_path = sys.argv[1]
text = open(src_path, encoding="utf-8").read()
root = model.SourceLine("", "<md2gost>", None, 0)
opts = model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = build(root, text, "", opts)
sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stdout, "reconfigure") else None
sys.stdout.write(dot)
sys.stderr.write("\n__MD2GOST_DFD_CONTEXT__=%s\n" % int(bool(graph_options.is_context)))
"""
def _is_context_diagram(source: str) -> bool:
return bool(re.search(r"(?im)^\s*style\s+context\b", source))
def _compile_dot(source: str) -> tuple[str, bool]:
"""Return (dot_text, is_context). Raises DfdParseError."""
text = (source or "").strip()
if not text:
raise DfdParseError("пустой DFD")
try:
from data_flow_diagram.dfd import build as dfd_build
from data_flow_diagram import model as dfd_model
root = dfd_model.SourceLine("", "<md2gost>", None, 0)
opts = dfd_model.Options(
format="dot",
background_color=None,
no_graph_title=True,
no_check_dependencies=False,
debug=False,
)
dot, graph_options = dfd_build(root, text, "", opts)
return dot, bool(graph_options.is_context)
except ImportError:
pass
except Exception as exc:
raise DfdParseError(str(exc)) from exc
cli = resolve_dfd_cli()
if not cli:
raise DfdParseError(
"Пакет data-flow-diagram не найден. "
"Установите зависимости проекта (Python 3.11+) — "
"https://github.com/pbauermeister/dfd"
)
with tempfile.TemporaryDirectory(prefix="md2gost-dfd-") as tmp:
src_path = Path(tmp) / "diagram.dfd"
src_path.write_text(text + "\n", encoding="utf-8")
if cli[-1] == _API_DOT_RUNNER or (len(cli) >= 2 and cli[-2] == "-c"):
cmd = list(cli) + [str(src_path)]
elif len(cli) == 1:
cmd = cli + [str(src_path), "-f", "dot", "-o", "-", "--no-graph-title"]
else:
cmd = list(cli) + [str(src_path)]
try:
proc = subprocess.run(
cmd,
capture_output=True,
timeout=120,
text=True,
encoding="utf-8",
errors="replace",
)
except FileNotFoundError as exc:
raise DfdParseError(f"Не удалось запустить data-flow-diagram: {exc}") from exc
except subprocess.TimeoutExpired as exc:
raise DfdParseError("Таймаут data-flow-diagram") from exc
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "").strip() or f"код {proc.returncode}"
raise DfdParseError(f"DFD: {err}")
dot = proc.stdout or ""
if not dot.strip():
raise DfdParseError("data-flow-diagram вернул пустой DOT")
is_ctx = _is_context_diagram(text)
m = re.search(r"__MD2GOST_DFD_CONTEXT__=(\d+)", proc.stderr or "")
if m:
is_ctx = bool(int(m.group(1)))
return dot, is_ctx
def _graphviz_run_env(engine: str) -> dict[str, str]:
env = os.environ.copy()
bin_dir = str(Path(engine).resolve().parent)
env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "")
env["GVBINDIR"] = bin_dir
return env
def _readable_dot(dot: str) -> str:
"""Make Graphviz DFD easier to read in printed Word reports."""
text = dot
text = re.sub(
r'edge\[color=gray fontname="times-italic" fontsize=\d+\]',
'edge[color=black fontname="helvetica" fontsize=13]',
text,
)
text = re.sub(
r'node\[fontname="helvetica" fontsize=\d+\]',
'node[fontname="helvetica" fontsize=13]',
text,
)
if "dpi=" not in text:
text = text.replace("digraph D {", 'digraph D {\n graph[dpi=180 nodesep=0.55 ranksep=0.7]', 1)
return text
def _render_dot_local(dot: str, out_path: Path, fmt: str, *, context: bool) -> bool:
engine = resolve_graphviz_engine(context=context)
if not engine:
return False
try:
subprocess.run(
[engine, f"-T{fmt}", f"-o{out_path}"],
input=_readable_dot(dot),
encoding="utf-8",
check=True,
capture_output=True,
timeout=120,
env=_graphviz_run_env(engine),
)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
_log.warning("Graphviz %s failed: %s", engine, exc)
return False
return out_path.is_file() and out_path.stat().st_size > 0
def _render_dot_kroki(dot: str, out_path: Path, fmt: str) -> bool:
try:
from .diagram_renderer import (
DEFAULT_KROKI_URL,
REMOTE_KROKI_URL,
_CONFIG,
_render_kroki,
)
except Exception:
return False
local = (_CONFIG.kroki_url or os.environ.get("KROKI_URL") or DEFAULT_KROKI_URL).rstrip("/")
if _render_kroki(dot, "graphviz", local, out_path, fmt):
return True
if _CONFIG.fallback == "remote":
return _render_kroki(dot, "graphviz", REMOTE_KROKI_URL, out_path, fmt)
return False
def dfd_engine_status() -> str:
cli_ok = False
try:
import data_flow_diagram # noqa: F401
cli_ok = True
except ImportError:
cli_ok = resolve_dfd_cli() is not None
gv = resolve_graphviz_engine(False)
parts = ["DFD (pbauermeister/data-flow-diagram), оградка ```dfd"]
parts.append(
"компилятор: data-flow-diagram OK"
if cli_ok
else "компилятор: не найден (зависимость md2gost, Python 3.11+)"
)
if gv:
parts.append(f"Graphviz: {gv}")
else:
parts.append(
"Graphviz: нет — «Скачать Graphviz» / scripts/fetch_graphviz.py или Kroki"
)
return "\n".join(parts)
def write_dfd_outputs(
source: str,
out_png: Path,
out_svg: Path | None,
*,
scale: float = 2.0,
) -> bool:
"""Compile DSL and write PNG (+ optional SVG). Raises DfdParseError."""
del scale
dot, is_context = _compile_dot(source)
out_png = Path(out_png)
out_png.parent.mkdir(parents=True, exist_ok=True)
ok_png = _render_dot_local(dot, out_png, "png", context=is_context)
if not ok_png:
ok_png = _render_dot_kroki(dot, out_png, "png")
if not ok_png:
raise DfdParseError(
"Не удалось отрисовать DFD: скачайте Graphviz "
"(GUI: Диаграммы → Скачать Graphviz, или scripts/fetch_graphviz.py) "
"или включите Kroki (--diagram-fallback remote). "
"DSL: https://github.com/pbauermeister/dfd"
)
if out_svg is not None:
out_svg = Path(out_svg)
ok_svg = _render_dot_local(dot, out_svg, "svg", context=is_context)
if not ok_svg:
_render_dot_kroki(dot, out_svg, "svg")
_log.info("DFD ok → %s", out_png.name)
return True
+50 -5
View File
@@ -33,11 +33,16 @@ DIAGRAM_LANGS = frozenset(
"c4context",
"c4component",
"usecase",
"bpmn",
"mermaid",
"mmd",
"idef0",
"dfd",
}
)
# Native Pillow renderers — never jar / Kroki.
LOCAL_ONLY_TYPES = frozenset({"idef0", "dfd"})
# Local hi-res path uses a scale-specific cache key.
SCALED_LOCAL_TYPES = frozenset({"mermaid", "idef0", "dfd"})
FallbackMode = Literal["local", "remote", "off"]
DiagramFormat = Literal["png", "svg"]
@@ -255,6 +260,13 @@ def diagram_engine_status(explicit_jar: str | None = None) -> str:
lines = lines + "\n" + mermaid_engine_status()
except Exception as exc:
lines = lines + f"\nMermaid: статус недоступен ({exc})"
lines = lines + "\nIDEF0: локальный рендер (Pillow), оградка ```idef0"
try:
from .dfd import dfd_engine_status
lines = lines + "\n" + dfd_engine_status()
except Exception as exc:
lines = lines + f"\nDFD: статус недоступен ({exc})"
return lines
@@ -424,14 +436,23 @@ def render_diagram(
cache_prefix = CACHE_KEY_PREFIX
except Exception:
cache_prefix = "mmd-local-v4\n"
elif kroki_type == "idef0":
from .idef0 import CACHE_KEY_PREFIX as IDEF0_CACHE_PREFIX
# Mermaid local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
cache_prefix = IDEF0_CACHE_PREFIX
elif kroki_type == "dfd":
from .dfd import CACHE_KEY_PREFIX as DFD_CACHE_PREFIX
cache_prefix = DFD_CACHE_PREFIX
# Local hi-res uses a scale-specific cache key; Kroki uses unscaled key.
local_prefix = cache_prefix
if kroki_type == "mermaid" and scale > 1:
if kroki_type in SCALED_LOCAL_TYPES and scale > 1:
local_prefix = f"{cache_prefix}scale={scale:g}\n"
out_png, out_svg_path = _cache_paths(
prepared, cache_dir, cache_prefix=local_prefix if kroki_type == "mermaid" else cache_prefix,
prepared, cache_dir,
cache_prefix=local_prefix if kroki_type in SCALED_LOCAL_TYPES else cache_prefix,
)
# Kroki / remote paths (mermaid): never share the scaled local key.
kroki_png, kroki_svg_path = out_png, out_svg_path
@@ -453,7 +474,7 @@ def render_diagram(
has_svg = out_svg_path.is_file() and out_svg_path.stat().st_size > 0
if not want_svg or has_svg:
hit_scale = pixel_scale
if kroki_type == "mermaid" and scale > 1 and local_prefix != cache_prefix:
if kroki_type in SCALED_LOCAL_TYPES and scale > 1 and local_prefix != cache_prefix:
hit_scale = scale
return DiagramRenderResult(
png_path=str(out_png),
@@ -505,6 +526,30 @@ def render_diagram(
if _render_plantuml_jar(prepared, out_png, jar or "", out_svg=svg_target):
return _result()
# 1a) Native Pillow (IDEF0 / DFD) — never Kroki / PlantUML
if kroki_type in LOCAL_ONLY_TYPES:
if kroki_type == "idef0":
from .idef0 import Idef0ParseError, write_idef0_outputs
try:
if write_idef0_outputs(prepared, out_png, svg_target, scale=scale):
return _result(pscale=scale if scale > 1 else 1.0)
except Idef0ParseError as exc:
raise RuntimeError(str(exc)) from exc
raise RuntimeError("Не удалось отрендерить IDEF0-диаграмму")
if kroki_type == "dfd":
from .dfd import DfdParseError, write_dfd_outputs
try:
if write_dfd_outputs(prepared, out_png, svg_target, scale=scale):
return _result(pscale=scale if scale > 1 else 1.0)
except DfdParseError as exc:
raise RuntimeError(str(exc)) from exc
raise RuntimeError("Не удалось отрендерить DFD-диаграмму")
raise RuntimeError(f"Неизвестный локальный тип диаграммы: {kroki_type}")
# 1b) Mermaid local: system browser → Playwright Chromium → QuickJS
if kroki_type == "mermaid":
try:
+142 -15
View File
@@ -21,10 +21,33 @@ _log = logging.getLogger(__name__)
SCHEMES_FILENAME = "md2gost.schemes.json"
SCHEME_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
APP_SCHEME_AUTHOR = "md2gost"
BUILTIN_DIAGRAM_LANGS = frozenset({"uml", "plantuml"})
# Rendered via Kroki only (not PlantUML jar / schemes).
KROKI_DIAGRAM_LANGS = frozenset({"mermaid", "mmd"})
KROKI_TYPE_BY_LANG = {"mermaid": "mermaid", "mmd": "mermaid"}
# Local native renderers (not PlantUML / Kroki / schemes.json).
NATIVE_DIAGRAM_LANGS = {
"idef0": "idef0",
"uml-idef0": "idef0",
"dfd": "dfd",
"uml-dfd": "dfd",
"data-flow-diagram": "dfd",
"yourdon": "dfd",
"uml-yourdon": "dfd",
}
@dataclass
class SchemesMigrationResult:
added: list[str] = field(default_factory=list)
updated: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
written: bool = False
@property
def changed(self) -> bool:
return bool(self.added or self.updated or self.removed)
@dataclass
@@ -82,22 +105,84 @@ def user_schemes_path(base: Path | None = None) -> Path:
return (base or app_dir()) / SCHEMES_FILENAME
def is_app_owned_scheme(scheme: DiagramScheme) -> bool:
"""True if scheme is managed by the app template (author=md2gost)."""
return scheme.author.strip().lower() == APP_SCHEME_AUTHOR
def _schemes_payload(schemes: dict[str, DiagramScheme]) -> dict[str, Any]:
return {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
def migrate_user_schemes(base: Path | None = None) -> SchemesMigrationResult:
"""
Sync app-owned schemes in the user file with the bundled template.
- Bundled ids: always present; overwrite user entries with author=md2gost.
- User overrides of bundled ids (empty/other author): kept as-is.
- Orphaned author=md2gost ids no longer in the template: removed.
- Other custom schemes: kept.
"""
result = SchemesMigrationResult()
path = user_schemes_path(base)
if not path.is_file():
return result
bundled = _load_schemes_file(bundled_schemes_path())
user = _load_schemes_file(path)
out: dict[str, DiagramScheme] = {}
for sid, scheme in user.items():
if sid in bundled:
if is_app_owned_scheme(scheme):
continue # step below writes bundled
out[sid] = scheme
continue
if is_app_owned_scheme(scheme):
result.removed.append(sid)
_log.info("Миграция схем: удалена устаревшая встроенная «%s»", sid)
continue
out[sid] = scheme
for sid, scheme in bundled.items():
if sid in out:
continue # user-owned override
prev = user.get(sid)
if prev is None:
result.added.append(sid)
_log.info("Миграция схем: добавлена «%s»", sid)
elif prev.to_dict() != scheme.to_dict():
result.updated.append(sid)
_log.info("Миграция схем: обновлена «%s»", sid)
out[sid] = scheme
if _schemes_payload(out) == _schemes_payload(user):
result.added.clear()
result.updated.clear()
result.removed.clear()
return result
save_schemes(out, path)
result.written = True
return result
def ensure_user_schemes(base: Path | None = None) -> Path:
"""
On first run copy bundled template next to the app.
Never overwrite an existing user file.
Ensure md2gost.schemes.json exists next to the app, then sync app-owned
schemes from the bundled template (add / update / remove orphans).
"""
dest = user_schemes_path(base)
if dest.is_file():
return dest
src = bundled_schemes_path()
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copyfile(src, dest)
_log.info("Создан файл схем: %s", dest)
else:
dest.write_text("{}\n", encoding="utf-8")
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
if not dest.is_file():
src = bundled_schemes_path()
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copyfile(src, dest)
_log.info("Создан файл схем: %s", dest)
else:
dest.write_text("{}\n", encoding="utf-8")
_log.warning("Шаблон схем не найден (%s), создан пустой %s", src, dest)
migrate_user_schemes(base)
return dest
@@ -129,7 +214,7 @@ def load_schemes_from_path(path: Path) -> dict[str, DiagramScheme]:
def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) -> Path:
dest = path or user_schemes_path()
payload = {sid: scheme.to_dict() for sid, scheme in sorted(schemes.items())}
payload = _schemes_payload(schemes)
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
@@ -138,12 +223,50 @@ def save_schemes(schemes: dict[str, DiagramScheme], path: Path | None = None) ->
return dest
def claim_user_scheme_if_diverged(
scheme: DiagramScheme,
*,
bundled: dict[str, DiagramScheme] | None = None,
) -> DiagramScheme:
"""
If scheme id is in the app template and content differs while still
author=md2gost, clear author so migration will not overwrite user edits.
"""
stock = (bundled if bundled is not None else _load_schemes_file(bundled_schemes_path())).get(
scheme.id
)
if stock is None:
return scheme
if not is_app_owned_scheme(scheme):
return scheme
if scheme.to_dict() == stock.to_dict():
return scheme
return DiagramScheme(
id=scheme.id,
title=scheme.title,
version=scheme.version,
author="",
docs=scheme.docs,
ai_prompt=scheme.ai_prompt,
includes=list(scheme.includes),
prefix=scheme.prefix,
postfix=scheme.postfix,
theme=scheme.theme,
)
def native_diagram_type(lang: str) -> str | None:
"""Kroki/PlantUML-independent diagram type, or None."""
lang = (lang or "").lower().strip()
return NATIVE_DIAGRAM_LANGS.get(lang)
def scheme_id_from_lang(lang: str) -> str | None:
"""Normalize fence lang to scheme id, or None for bare uml/plantuml."""
lang = (lang or "").lower().strip()
if not lang:
return None
if lang in BUILTIN_DIAGRAM_LANGS:
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
return None
if lang.startswith("uml-"):
sid = lang[4:]
@@ -224,7 +347,7 @@ def get_scheme(scheme_id: str) -> DiagramScheme | None:
def is_diagram_lang(lang: str) -> bool:
"""True if fence should render as a diagram (not a plain listing)."""
lang = (lang or "").lower().strip()
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS:
if lang in BUILTIN_DIAGRAM_LANGS or lang in KROKI_DIAGRAM_LANGS or lang in NATIVE_DIAGRAM_LANGS:
return True
if lang.startswith("uml-"):
# Force diagram path so missing scheme becomes an error, not a listing.
@@ -316,6 +439,10 @@ def prepare_with_schemes(lang: str, source: str, *, base: Path | None = None) ->
if lang in KROKI_DIAGRAM_LANGS:
return text, KROKI_TYPE_BY_LANG[lang]
native = native_diagram_type(lang)
if native:
return text, native
sid = scheme_id_from_lang(lang)
if sid is None:
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -45,16 +45,14 @@
"prefix": "@startuml\nleft to right direction\nskinparam actorStyle awesome\n",
"postfix": "\n@enduml"
},
"bpmn": {
"title": "BPMN 2.0",
"archimate": {
"title": "ArchiMate 3.2",
"version": "1.0.0",
"author": "md2gost",
"docs": "Pool(alias, \"Участник\") { Lane(alias, \"Роль\") { … } }\nStart / StartMessage / StartTimer / StartSignal\nCatchMessage / ThrowMessage / CatchTimer / CatchError\nEnd / EndMessage / EndError / EndTerminate\nTask(alias, \"Имя\") / UserTask / ServiceTask / ScriptTask / ManualTask / SendTask / ReceiveTask / BusinessRuleTask\nSubProcess / SubProcessExpanded { … } / CallActivity\nXOR / AND / OR / EventBased / ComplexGW\nFlow / Flow_R/L/U/D / CondFlow(from, to, \"условие\") / DefaultFlow / MessageFlow\nDataObject / DataStore / Annotation\nBoundaryError + Attach(task, event)\nBPMN_VERTICAL() / BPMN_LEGEND()",
"ai-prompt": "Рисуй BPMN 2.0 (OMG). В блоке ```uml-bpmn (или ```bpmn) только макросы библиотеки md2gost. Без @startuml и без !include.\n\nПравила: Sequence Flow (Flow) только внутри одного пула. Между пулами — MessageFlow. У процесса есть Start и хотя бы один End. Развилка и слияние — шлюзы (XOR/AND/OR), не рисуй ветвление со стрелок задачи без шлюза, если это решение. Подпись условия — на CondFlow/Flow от шлюза. DefaultFlow — ветка «иначе». Не используй activity-syntax (start/:task/;), mxgraph и чистый UML.\n\nМакросы:\nПул: Pool(alias, \"Имя\") { Lane(alias, \"Роль\") { ... } }\nСобытия: Start, StartMessage, StartTimer, StartSignal, StartCondition, Intermediate, CatchMessage, ThrowMessage, CatchTimer, CatchSignal, ThrowSignal, CatchError, End, EndMessage, EndError, EndTerminate, EndSignal\nЗадачи: Task, UserTask, ServiceTask, ScriptTask, ManualTask, SendTask, ReceiveTask, BusinessRuleTask, SubProcess, SubProcessExpanded { ... }, CallActivity\nШлюзы: Exclusive/XOR, Parallel/AND, Inclusive/OR, EventBased, ComplexGW\nПотоки: Flow, Flow_R, Flow_L, Flow_U, Flow_D, CondFlow(from,to,\"условие\"), DefaultFlow, MessageFlow, Assoc, DataAssoc\nДанные: DataObject, DataStore, Annotation\nГраница: BoundaryError/Timer/Message + Attach(task, event)\nМакет: Lay_R/L/U/D, BPMN_VERTICAL(), BPMN_LEGEND()\nПсевдонимы элементов — латиница (s, t1, gw, e_ok). Не называй alias зарезервированными словами end/start/group.",
"includes": [
"BPMN.puml"
],
"prefix": "@startuml\nleft to right direction\n",
"docs": "LAYOUT_TOP_DOWN() / LAYOUT_LEFT_RIGHT()\nMotivation_Stakeholder/Driver/Assessment/Goal/Outcome/Principle/Requirement/Constraint/Meaning/Value\nStrategy_Resource/Capability/ValueStream/CourseOfAction\nBusiness_Actor/Role/Collaboration/Interface/Process/Function/Interaction/Event/Service/Object/Contract/Representation/Product\nApplication_Component/Collaboration/Interface/Function/Interaction/Process/Event/Service/DataObject\nTechnology_Node/Device/SystemSoftware/Collaboration/Interface/Path/CommunicationNetwork/Function/Process/Interaction/Event/Service/Artifact\nPhysical_Equipment/Facility/DistributionNetwork/Material\nImplementation_WorkPackage/Deliverable/Event/Plateau/Gap\nGrouping / Other_Grouping / Group / Other_Location / Junction_And / Junction_Or\nRel_Composition/Aggregation/Assignment/Realization/Serving/Access(_r|_w|_rw)/Influence/Triggering/Flow/Specialization/Association(_dir)\nRel_Type_Up/Down/Left/Right(from, to, \"label?\")\nLay_U/D/L/R — скрытое выравнивание",
"ai-prompt": "Рисуй ArchiMate 3.2 (Open Group C226). В блоке ```uml-archimate (или ```archimate) только макросы Archimate-PlantUML. Без @startuml и без !include — схема добавит сама.\n\nПравила: один рисунок = один viewpoint. Поведение по слоям (Business_Process ≠ Application_Process ≠ Technology_Process). Цепочка: Active —Assignment→ Process/Function —Realization→ Service —Serving→ потребитель; Access к пассивному объекту. Serving от поставщика к потребителю; Realization от конкретного к абстрактному; Composition/Aggregation целое→часть. Не Association «на всё». Не UML/C4/BPMN. Alias латиница (не end/start/group). 818 элементов. Сначала элементы, потом Rel_*. Подписи по-русски.\n\nМакросы: Motivation_*, Strategy_*, Business_*, Application_*, Technology_*, Physical_*, Implementation_*, Grouping, Junction_And/Or. Связи: Rel_Composition/Aggregation/Assignment/Realization/Serving/Access_r|w|rw/Influence/Triggering/Flow/Specialization/Association + _Up/_Down/_Left/_Right. Макет: LAYOUT_TOP_DOWN() или LAYOUT_LEFT_RIGHT(). Полный гайд: docs/archimate.md.",
"includes": [],
"prefix": "@startuml\n!include <archimate/Archimate>\n!theme archimate-standard from <archimate/themes>\n",
"postfix": "\n@enduml"
}
}
+7
View File
@@ -65,6 +65,13 @@ def first_markdown(paths: list[str]) -> str | None:
return None
def first_docx(paths: list[str]) -> str | None:
for path in paths:
if path.lower().endswith(".docx") and os.path.isfile(path):
return path
return None
def enable_file_drop(widget, callback: DropCallback) -> str:
"""Enable dropping files onto widget. Returns backend name: tkdnd | win32 | none."""
def _deliver(items: list[str] | str) -> None:
+148
View File
@@ -0,0 +1,148 @@
"""DOCX core properties (File → Info) for generated reports."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from getpass import getuser
from typing import Any
CORE_PROP_MAX = 255
AUTHOR_SOURCES = ("os", "student", "custom")
DEFAULT_AUTHOR_SOURCE = "os"
DEFAULT_DOC_COMMENTS = "Создано при помощи md2gost (ТЗ МИРЭА)"
def os_user() -> str:
try:
return (getuser() or "").strip()
except Exception:
return ""
def clip_core(value: str | None) -> str:
text = "" if value is None else str(value)
if len(text) > CORE_PROP_MAX:
return text[:CORE_PROP_MAX]
return text
def _normalize_source(value: str | None) -> str:
src = (value or DEFAULT_AUTHOR_SOURCE).strip().lower()
return src if src in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
@dataclass
class DocumentMetadata:
author_source: str = DEFAULT_AUTHOR_SOURCE
author: str = ""
last_modified_by: str = ""
title: str = ""
subject: str = ""
keywords: str = ""
comments: str = DEFAULT_DOC_COMMENTS
category: str = ""
def to_dict(self) -> dict[str, str]:
return {
"author_source": _normalize_source(self.author_source),
"author": (self.author or "").strip(),
"last_modified_by": (self.last_modified_by or "").strip(),
"title": (self.title or "").strip(),
"subject": (self.subject or "").strip(),
"keywords": (self.keywords or "").strip(),
"comments": "" if self.comments is None else str(self.comments).strip(),
"category": (self.category or "").strip(),
}
@classmethod
def from_dict(cls, raw: object) -> DocumentMetadata:
if not isinstance(raw, dict):
return cls()
comments = raw.get("comments")
if comments is None:
comments_s = DEFAULT_DOC_COMMENTS
else:
comments_s = str(comments).strip()
return cls(
author_source=_normalize_source(str(raw.get("author_source") or "")),
author=str(raw.get("author") or "").strip(),
last_modified_by=str(raw.get("last_modified_by") or "").strip(),
title=str(raw.get("title") or "").strip(),
subject=str(raw.get("subject") or "").strip(),
keywords=str(raw.get("keywords") or "").strip(),
comments=comments_s,
category=str(raw.get("category") or "").strip(),
)
def _pick(override: str | None, fallback: str) -> str:
if override is not None:
return str(override)
return fallback
def resolve_document_metadata(
*,
stored: DocumentMetadata | None = None,
student: str | None = None,
author_source: str | None = None,
author: str | None = None,
last_modified_by: str | None = None,
title: str | None = None,
subject: str | None = None,
keywords: str | None = None,
comments: str | None = None,
category: str | None = None,
) -> DocumentMetadata:
"""Merge CLI/request overrides over the saved profile into concrete core properties."""
base = stored or DocumentMetadata()
src = _normalize_source(author_source or base.author_source)
if author is not None:
resolved_author = str(author).strip()
elif src == "custom":
resolved_author = (base.author or "").strip()
elif src == "student":
resolved_author = (student or "").strip()
else:
resolved_author = ""
if not resolved_author:
resolved_author = os_user()
last_mod = _pick(last_modified_by, base.last_modified_by).strip()
if not last_mod:
last_mod = resolved_author
return DocumentMetadata(
author_source=src,
author=resolved_author,
last_modified_by=last_mod,
title=_pick(title, base.title).strip(),
subject=_pick(subject, base.subject).strip(),
keywords=_pick(keywords, base.keywords).strip(),
comments=_pick(comments, base.comments).strip(),
category=_pick(category, base.category).strip(),
)
def apply_document_metadata(
document: Any,
meta: DocumentMetadata,
*,
now: datetime | None = None,
) -> None:
stamp = now or datetime.utcnow()
if stamp.tzinfo is not None:
stamp = stamp.replace(tzinfo=None)
cp = document.core_properties
cp.author = clip_core(meta.author)
cp.last_modified_by = clip_core(meta.last_modified_by or meta.author)
cp.title = clip_core(meta.title)
cp.subject = clip_core(meta.subject)
cp.keywords = clip_core(meta.keywords)
cp.comments = clip_core(meta.comments)
cp.category = clip_core(meta.category)
cp.created = stamp
cp.modified = stamp
cp.revision = 1
+395 -20
View File
@@ -13,6 +13,7 @@ from .diagram_renderer import diagram_engine_status
from .diagram_schemes import (
DiagramScheme,
SCHEME_ID_RE,
claim_user_scheme_if_diverged,
ensure_user_schemes,
get_schemes,
load_schemes_from_path,
@@ -20,7 +21,7 @@ from .diagram_schemes import (
save_schemes,
user_schemes_path,
)
from .dnd import enable_file_drop, first_markdown
from .dnd import enable_file_drop, first_docx, first_markdown
from .help_content import (
SCHEMES_HELP,
USAGE_HELP,
@@ -200,6 +201,8 @@ class Md2GostApp:
self._win_files: tk.Toplevel | None = None
self._win_diagrams: tk.Toplevel | None = None
self._win_schemes: tk.Toplevel | None = None
self._win_student: tk.Toplevel | None = None
self._win_meta: tk.Toplevel | None = None
self._win_help: dict[str, tk.Toplevel] = {}
ensure_user_schemes()
@@ -209,6 +212,7 @@ class Md2GostApp:
self._build()
self._apply_request(self.req)
self.root.after(300, self._setup_dnd)
self.root.after(400, self._ensure_student_profile)
if self.req.filename:
self._set_markdown(self.req.filename, from_user_output=bool(self.req.output))
@@ -229,6 +233,7 @@ class Md2GostApp:
style.configure("Title.TLabel", font=font_title, foreground=NAVY, background=PAPER)
style.configure("Hint.TLabel", foreground=MUTED, background=PAPER)
style.configure("TCheckbutton", background=PAPER, font=font)
style.configure("TRadiobutton", background=PAPER, foreground=INK, font=font)
style.configure("TButton", font=font, padding=(10, 4))
style.configure("TEntry", padding=3)
style.configure("TCombobox", padding=3)
@@ -242,6 +247,7 @@ class Md2GostApp:
self.listing_var = tk.StringVar()
self.emdash_var = tk.BooleanVar(value=False)
self.hr_pagebreak_var = tk.BooleanVar(value=False)
self.auto_title_var = tk.BooleanVar(value=False)
self.open_var = tk.BooleanVar(value=True)
self.check_var = tk.BooleanVar(value=False)
self.check_only_var = tk.BooleanVar(value=False)
@@ -268,6 +274,8 @@ class Md2GostApp:
settings = tk.Menu(menubar, tearoff=0)
settings.add_command(label="Файлы…", command=self._open_files_window)
settings.add_command(label="Студент…", command=self._open_student_window)
settings.add_command(label="Метаданные…", command=self._open_metadata_window)
settings.add_command(label="Диаграммы…", command=self._open_diagrams_window)
menubar.add_cascade(label="Настройки", menu=settings)
@@ -340,6 +348,8 @@ class Md2GostApp:
hint = ttk.Label(
frame,
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.\n"
"Автотитул (info_conv.yaml / ```title) — галочка «Автотитул» на главном окне "
"(по умолчанию выкл.; ФИО/группа — Настройки → Студент…).\n"
"Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.",
style="Hint.TLabel",
wraplength=560,
@@ -348,6 +358,225 @@ class Md2GostApp:
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files"))
return win
def _ensure_student_profile(self) -> None:
from .user_profile import load_user_profile
if load_user_profile().is_complete():
return
self._open_student_window(first_run=True)
def _open_student_window(self, first_run: bool = False) -> None:
existing = getattr(self, "_win_student", None)
if existing is not None and existing.winfo_exists():
existing.lift()
existing.focus_force()
return
self._win_student = self._create_student_window(first_run=first_run)
def _create_student_window(self, *, first_run: bool = False) -> tk.Toplevel:
from .user_profile import load_user_profile, save_user_profile
profile = load_user_profile()
win = tk.Toplevel(self.root)
win.title("Данные студента" if first_run else "Настройки — Студент")
win.configure(bg=PAPER)
win.minsize(420, 180)
win.geometry("480x200")
win.transient(self.root)
if first_run:
win.grab_set()
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
ttk.Label(
frame,
text="ФИО и группа нужны для автотитульника (info_conv.yaml / ```title).",
style="Hint.TLabel",
wraplength=440,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
student_var = tk.StringVar(value=profile.student)
group_var = tk.StringVar(value=profile.group)
ttk.Label(frame, text="ФИО").grid(row=1, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=student_var).grid(row=1, column=1, sticky="ew", pady=4, padx=(8, 0))
ttk.Label(frame, text="Группа").grid(row=2, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=group_var).grid(row=2, column=1, sticky="ew", pady=4, padx=(8, 0))
def _save(close: bool = True) -> None:
student = student_var.get().strip()
group = group_var.get().strip()
if not student or not group:
messagebox.showwarning(
"Данные студента",
"Заполните ФИО и группу.",
parent=win,
)
return
current = load_user_profile()
current.student = student
current.group = group
save_user_profile(current)
if close:
self._close_toplevel(win, "_win_student")
btns = ttk.Frame(frame)
btns.grid(row=3, column=0, columnspan=2, sticky="e", pady=(12, 0))
ttk.Button(btns, text="Сохранить", command=lambda: _save(True)).pack(side=tk.RIGHT)
def _on_close() -> None:
if first_run and not load_user_profile().is_complete():
# Allow closing only after save; remind once
if not student_var.get().strip() or not group_var.get().strip():
messagebox.showinfo(
"Данные студента",
"Можно заполнить позже: Настройки → Студент…\n"
"Без ФИО/группы автотитул не соберётся.",
parent=win,
)
self._close_toplevel(win, "_win_student")
win.protocol("WM_DELETE_WINDOW", _on_close)
return win
def _open_metadata_window(self) -> None:
self._win_meta = self._raise_or_create(self._win_meta, self._create_metadata_window)
def _create_metadata_window(self) -> tk.Toplevel:
from .doc_metadata import (
AUTHOR_SOURCES,
DEFAULT_AUTHOR_SOURCE,
DEFAULT_DOC_COMMENTS,
DocumentMetadata,
os_user,
)
from .user_profile import load_user_profile, save_user_profile
profile = load_user_profile()
meta = profile.metadata or DocumentMetadata()
win = tk.Toplevel(self.root)
win.title("Настройки — Метаданные")
win.configure(bg=PAPER)
win.minsize(520, 420)
win.geometry("580x500")
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
ttk.Label(
frame,
text=(
"Свойства файла Word (Файл → сведения): автор, название, тема, теги, примечание. "
"Хранятся в md2gost.user.json рядом с приложением."
),
style="Hint.TLabel",
wraplength=540,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
src_value = meta.author_source if meta.author_source in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
author_src = tk.StringVar(value=src_value)
author_var = tk.StringVar(value=meta.author)
last_mod_var = tk.StringVar(value=meta.last_modified_by)
title_var = tk.StringVar(value=meta.title)
subject_var = tk.StringVar(value=meta.subject)
keywords_var = tk.StringVar(value=meta.keywords)
category_var = tk.StringVar(value=meta.category)
comments_var = tk.StringVar(value=meta.comments)
ttk.Label(frame, text="Автор").grid(row=1, column=0, sticky="nw", pady=(4, 0))
src_box = ttk.Frame(frame)
src_box.grid(row=1, column=1, sticky="ew", pady=(4, 0), padx=(8, 0))
os_name = os_user() or ""
student_name = (profile.student or "").strip()
student_text = (
f"ФИО студента ({student_name})"
if student_name
else "ФИО студента (сначала Настройки → Студент…)"
)
ttk.Radiobutton(
src_box, text=f"Имя пользователя Windows ({os_name})",
variable=author_src, value="os",
).pack(anchor="w")
ttk.Radiobutton(
src_box, text=student_text, variable=author_src, value="student",
).pack(anchor="w")
ttk.Radiobutton(
src_box, text="Указать вручную", variable=author_src, value="custom",
).pack(anchor="w")
author_entry = ttk.Entry(frame, textvariable=author_var)
author_entry.grid(row=2, column=1, sticky="ew", pady=(2, 4), padx=(8, 0))
_tip(author_entry, "Используется, если выбран пункт «Указать вручную»")
def _sync_author_entry(*_a) -> None:
state = "normal" if author_src.get() == "custom" else "disabled"
try:
author_entry.configure(state=state)
except tk.TclError:
pass
author_src.trace_add("write", _sync_author_entry)
_sync_author_entry()
def _row(row: int, label: str, var: tk.StringVar, tip: str) -> None:
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
entry = ttk.Entry(frame, textvariable=var)
entry.grid(row=row, column=1, sticky="ew", pady=4, padx=(8, 0))
_tip(entry, tip)
_row(3, "Кем изменён", last_mod_var, "Пусто — то же значение, что у автора")
_row(4, "Название", title_var, "dc:title в свойствах файла; пусто — не задавать")
_row(5, "Тема", subject_var, "Тема документа в свойствах файла")
_row(6, "Ключевые слова", keywords_var, "Теги в свойствах файла Word")
_row(7, "Категория", category_var, "Категория в свойствах файла")
_row(
8,
"Примечание",
comments_var,
f"По умолчанию: «{DEFAULT_DOC_COMMENTS}». Пусто — не писать примечание.",
)
ttk.Label(
frame,
text="Пустое примечание убирает и текст md2gost, и «generated by python-docx» из шаблона.",
style="Hint.TLabel",
wraplength=540,
).grid(row=9, column=0, columnspan=2, sticky="w", pady=(4, 0))
def _save() -> None:
src = author_src.get() if author_src.get() in AUTHOR_SOURCES else DEFAULT_AUTHOR_SOURCE
current = load_user_profile()
current.metadata = DocumentMetadata(
author_source=src,
author=author_var.get().strip(),
last_modified_by=last_mod_var.get().strip(),
title=title_var.get().strip(),
subject=subject_var.get().strip(),
keywords=keywords_var.get().strip(),
comments=comments_var.get().strip(),
category=category_var.get().strip(),
)
save_user_profile(current)
self._close_toplevel(win, "_win_meta")
def _reset() -> None:
author_src.set(DEFAULT_AUTHOR_SOURCE)
author_var.set("")
last_mod_var.set("")
title_var.set("")
subject_var.set("")
keywords_var.set("")
category_var.set("")
comments_var.set(DEFAULT_DOC_COMMENTS)
btns = ttk.Frame(frame)
btns.grid(row=10, column=0, columnspan=2, sticky="e", pady=(12, 0))
ttk.Button(btns, text="Сбросить", command=_reset).pack(side=tk.LEFT, padx=(0, 8))
ttk.Button(btns, text="Сохранить", command=_save).pack(side=tk.RIGHT)
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_meta"))
return win
def _create_diagrams_window(self) -> tk.Toplevel:
win = tk.Toplevel(self.root)
win.title("Настройки — Диаграммы")
@@ -362,10 +591,10 @@ class Md2GostApp:
hint = ttk.Label(
extra,
text=(
"Блоки ```uml / ```uml-c4 / ```bpmn / ```mermaid сами станут рисунками.\n"
"Блоки ```uml / ```uml-c4 / ```mermaid / ```idef0 / ```dfd сами станут рисунками.\n"
"Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n"
"UML: PlantUML + Java, иначе Kroki. Mermaid: системный браузер → "
"Chromium → QuickJS → Kroki (kroki.io остаётся запасным)."
"Chromium → QuickJS → Kroki. IDEF0 — Pillow. DFD — Graphviz / Kroki."
),
style="Hint.TLabel",
wraplength=640,
@@ -377,14 +606,17 @@ class Md2GostApp:
status_row.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(0, 8))
status_row.columnconfigure(0, weight=1)
self.diag_status = ttk.Label(status_row, text="", style="Hint.TLabel", wraplength=480, justify=tk.LEFT)
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=2)
self.diag_status.grid(row=0, column=0, sticky="w", rowspan=3)
btn_dl = ttk.Button(status_row, text="Скачать PlantUML", command=self._download_plantuml)
btn_dl.grid(row=0, column=1, sticky="e", padx=(8, 0))
_tip(btn_dl, "Скачать plantuml.jar для локальной отрисовки (нужна Java)")
btn_gv = ttk.Button(status_row, text="Скачать Graphviz", command=self._download_graphviz)
btn_gv.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(btn_gv, "Скачать portable Graphviz (dot/neato) для локальных DFD")
btn_cr = ttk.Button(
status_row, text="Скачать headless Chromium", command=self._download_chromium,
)
btn_cr.grid(row=1, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
btn_cr.grid(row=2, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(
btn_cr,
"Скачать Chromium для Mermaid в %LOCALAPPDATA%\\md2gost "
@@ -629,7 +861,7 @@ class Md2GostApp:
def _build_schemes_help(self, parent) -> None:
ttk.Label(
parent,
text="Настраиваемые схемы диаграмм (uml-c4, bpmn, …)",
text="Настраиваемые схемы диаграмм (uml-c4, usecase, …)",
style="Hint.TLabel",
).pack(anchor=tk.W, pady=(0, 6))
box = self._readonly_text(parent, SCHEMES_HELP.strip() + "\n")
@@ -641,7 +873,7 @@ class Md2GostApp:
parent,
text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. "
"Кнопки схем дописывают макросы из md2gost.schemes.json — "
"когда ИИ должен знать C4/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
"когда ИИ должен знать C4/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
style="Hint.TLabel",
wraplength=640,
).pack(anchor=tk.W, pady=(0, 8))
@@ -800,7 +1032,7 @@ class Md2GostApp:
self.zone.pack(fill=tk.X, pady=(0, 8))
self.zone_label = tk.Label(
self.zone,
text="Перетащите .md сюда · или нажмите, чтобы выбрать файл",
text="Перетащите .md или .docx сюда · или нажмите, чтобы выбрать файл",
bg=ZONE, fg=NAVY, font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
justify=tk.CENTER, pady=14,
)
@@ -812,8 +1044,8 @@ class Md2GostApp:
self.zone_path.pack(fill=tk.X, pady=(0, 8))
for w in (self.zone, self.zone_label, self.zone_path):
w.bind("<Button-1>", lambda _e: self._browse_md())
_tip(self.zone, "Перетащите .md или кликните, чтобы выбрать файл")
_tip(self.zone_label, "Перетащите .md или кликните, чтобы выбрать файл")
_tip(self.zone, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
_tip(self.zone_label, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
grid = ttk.Frame(outer)
grid.pack(fill=tk.X)
@@ -857,6 +1089,11 @@ class Md2GostApp:
checks = [
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
(
"Автотитул",
self.auto_title_var,
"Собрать титул из info_conv.yaml / ```title (нужны ФИО и группа). По умолчанию выкл.",
),
("Открыть после конвертации", self.open_var, "Открыть DOCX после успешной сборки"),
("Проверить по ТЗ", self.check_var, "Замечания по структуре, «рис.», источникам"),
("Только проверка", self.check_only_var, "Проверить без записи DOCX"),
@@ -886,7 +1123,17 @@ class Md2GostApp:
relief=tk.FLAT, padx=22, pady=7, cursor="hand2",
)
self.convert_btn.pack(side=tk.LEFT)
_tip(self.convert_btn, "Собрать DOCX из выбранного markdown")
_tip(self.convert_btn, "Собрать DOCX из markdown или импортировать DOCX → Markdown")
self.import_btn = tk.Button(
actions, text="Импорт DOCX→MD",
command=self._import_docx,
bg="#3d5a40", fg="white", activebackground="#2d4430", activeforeground="white",
disabledforeground="#d0d5dc",
font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
relief=tk.FLAT, padx=14, pady=7, cursor="hand2",
)
self.import_btn.pack(side=tk.LEFT, padx=(8, 0))
_tip(self.import_btn, "Выбрать .docx и сохранить диалект md2gost (.md + медиа)")
self.progress = ttk.Progressbar(actions, mode="indeterminate", length=180)
self.progress.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
@@ -901,7 +1148,7 @@ class Md2GostApp:
self.log.configure(yscrollcommand=scroll.set)
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.log.insert("1.0", "Готово к работе. Перетащите markdown-файл в верхнюю область.\n")
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
self.log.configure(state=tk.DISABLED)
def _build_schemes_tab(self, parent) -> None:
@@ -1067,6 +1314,7 @@ class Md2GostApp:
scheme = self._scheme_from_form(old_id)
if scheme is None:
return None
scheme = claim_user_scheme_if_diverged(scheme)
if old_id and old_id != scheme.id and old_id in self._schemes:
del self._schemes[old_id]
self._schemes[scheme.id] = scheme
@@ -1197,7 +1445,7 @@ class Md2GostApp:
if backend == "none":
self._append_log("Перетаскивание недоступно — выберите файл кликом по области.")
else:
self._append_log("Можно перетаскивать .md из проводника в окно.")
self._append_log("Можно перетаскивать .md / .docx из проводника в окно.")
def _on_drop(self, paths: list[str]) -> None:
md = first_markdown(paths)
@@ -1205,8 +1453,16 @@ class Md2GostApp:
self._set_markdown(md)
self._flash_zone(ZONE_OK)
return
docx = first_docx(paths)
if docx:
self._set_docx(docx)
self._flash_zone(ZONE_OK)
return
if paths:
self._append_log("Нужен файл .md, а не: " + ", ".join(os.path.basename(p) for p in paths))
self._append_log(
"Нужен файл .md или .docx, а не: "
+ ", ".join(os.path.basename(p) for p in paths)
)
self._flash_zone("#f8d7da")
def _flash_zone(self, color: str) -> None:
@@ -1222,10 +1478,19 @@ class Md2GostApp:
def _browse_md(self) -> None:
path = filedialog.askopenfilename(
title="Исходный Markdown",
filetypes=[("Markdown", "*.md"), ("Все файлы", "*.*")],
title="Исходный Markdown или Word",
filetypes=[
("Markdown / Word", "*.md;*.docx"),
("Markdown", "*.md"),
("Word", "*.docx"),
("Все файлы", "*.*"),
],
)
if path:
if not path:
return
if path.lower().endswith(".docx"):
self._set_docx(path)
else:
self._set_markdown(path)
def _browse_output(self) -> None:
@@ -1290,6 +1555,55 @@ class Md2GostApp:
"Можно ничего не делать — диаграммы нарисует kroki.io.",
)
def _download_graphviz(self) -> None:
from .dfd import (
cached_graphviz_root,
fetch_graphviz,
vendor_graphviz_root,
)
dest = vendor_graphviz_root()
try:
dest.mkdir(parents=True, exist_ok=True)
probe = dest / ".write_test"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError:
dest = cached_graphviz_root()
self._append_log(f"Скачиваю Graphviz → {dest}")
self.root.update_idletasks()
def work() -> None:
ok = fetch_graphviz(dest)
self.root.after(0, lambda: self._on_graphviz_installed(ok, dest))
import threading
threading.Thread(target=work, daemon=True).start()
def _on_graphviz_installed(self, ok: bool, dest) -> None:
self._refresh_diagram_status()
if ok:
engine = None
try:
from .dfd import resolve_graphviz_engine
engine = resolve_graphviz_engine(False)
except Exception:
pass
messagebox.showinfo(
"md2gost",
f"Graphviz сохранён:\n{dest}\n\n"
f"Движок: {engine or 'dot в каталоге'}",
)
else:
messagebox.showerror(
"md2gost",
"Не удалось скачать Graphviz.\n"
"DFD может пойти через kroki.io "
"(«Как рисовать» → через интернет) или системный PATH.",
)
def _download_chromium(self) -> None:
from .mermaid_renderer import install_playwright_chromium, playwright_browsers_dir, playwright_available
@@ -1351,6 +1665,32 @@ class Md2GostApp:
self._auto_output = True
self._append_log(f"Файл: {path}")
def _set_docx(self, path: str) -> None:
path = os.path.abspath(path)
self._last_md = path
self.zone_label.configure(text=os.path.basename(path))
self.zone_path.configure(text=path)
if self._auto_output:
stem = os.path.splitext(path)[0]
self.output_var.set(stem + ".md")
self._auto_output = True
self._append_log(f"DOCX для импорта: {path}")
def _import_docx(self) -> None:
"""Pick a DOCX (or use current) and run word2md."""
if self._busy:
return
path = self._last_md if self._last_md.lower().endswith(".docx") else ""
if not path:
path = filedialog.askopenfilename(
title="Импорт DOCX → Markdown",
filetypes=[("Word", "*.docx"), ("Все файлы", "*.*")],
)
if not path:
return
self._set_docx(path)
self._run_convert(force_import=True)
def _apply_request(self, req: ConvertRequest) -> None:
self.type_var.set(_display(DOC_TYPE_LABELS, req.doc_type if req.doc_type in DOC_TYPES else "practice"))
self.heading_var.set(_display(HEADING_LABELS, req.heading_numbering or DEFAULT_HEADING_NUMBERING))
@@ -1363,6 +1703,7 @@ class Md2GostApp:
self.scale_var.set(str(scale).rstrip("0").rstrip(".") if isinstance(scale, float) else str(scale))
self.emdash_var.set(bool(req.emdash_to_hyphen))
self.hr_pagebreak_var.set(bool(req.hr_pagebreak))
self.auto_title_var.set(bool(getattr(req, "auto_title", False)))
self.open_var.set(True)
self.check_var.set(bool(req.check))
self.check_only_var.set(bool(req.check_only))
@@ -1398,6 +1739,7 @@ class Md2GostApp:
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
emdash_to_hyphen=self.emdash_var.get(),
hr_pagebreak=self.hr_pagebreak_var.get(),
auto_title=self.auto_title_var.get(),
title=self.title_var.get().strip() or None,
assignment=self.assignment_var.get().strip() or None,
check=self.check_var.get() or self.check_only_var.get(),
@@ -1436,13 +1778,39 @@ class Md2GostApp:
return value if value >= 1 else 1.0
def _convert(self) -> None:
self._run_convert(force_import=False)
def _run_convert(self, *, force_import: bool) -> None:
if self._busy:
return
req = self._collect()
if not req.filename:
messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.")
messagebox.showinfo(
"md2gost",
"Сначала перетащите или выберите .md (сборка) или .docx (импорт).",
)
return
if not req.check_only:
is_docx = req.filename.lower().endswith(".docx")
if force_import and not is_docx:
messagebox.showinfo("md2gost", "Для импорта нужен файл .docx.")
return
if is_docx:
target = (req.output or "").strip()
if target.lower().endswith(".docx"):
target = os.path.splitext(req.filename)[0] + ".md"
req.output = target
self.output_var.set(target)
if target and os.path.isfile(target):
if not messagebox.askyesno(
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
):
base, ext = os.path.splitext(target)
target = f"{base}_import{ext or '.md'}"
self._append_log(f"Не перезаписываем, пишем в {target}")
req.output = target
self.output_var.set(target)
self._append_log("Импорт DOCX → Markdown…")
elif not req.check_only:
target = (req.output or "").strip() or (
default_output_path(req.filename) if req.filename else ""
)
@@ -1455,10 +1823,15 @@ class Md2GostApp:
req.output = target
self.output_var.set(target)
self._auto_output = False
self._append_log("Конвертация…")
else:
self._append_log("Проверка…")
self._busy = True
self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM)
if hasattr(self, "import_btn"):
self.import_btn.configure(state=tk.DISABLED)
self.progress.start(12)
self._append_log("Конвертация…")
def work():
result = convert(req, log=lambda m: self.root.after(0, self._append_log, m))
@@ -1469,6 +1842,8 @@ class Md2GostApp:
def _on_done(self, result) -> None:
self._busy = False
self.convert_btn.configure(state=tk.NORMAL, bg=NAVY)
if hasattr(self, "import_btn"):
self.import_btn.configure(state=tk.NORMAL, bg="#3d5a40")
self.progress.stop()
if self._debug_next or self.debug_var.get():
self.debug_var.set(False)
+65 -38
View File
@@ -13,6 +13,8 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
1. Перетащите .md в верхнюю область (или кликните по ней).
2. Выберите тип документа и параметры в блоке «Основные».
Шаблон / титул / задание Настройки Файлы.
ФИО и группа студента Настройки Студент (спрашивают при первом запуске).
Свойства DOCX (автор, название, примечание) Настройки Метаданные.
PlantUML / Mermaid / Kroki Настройки Диаграммы.
Свои UML-схемы меню «Шаблоны UML».
3. Нажмите «Конвертировать». Документ сохранится рядом с исходником (или по пути «Выходной DOCX»).
@@ -28,14 +30,29 @@ USAGE_HELP = """md2gost — Markdown → DOCX (ТЗ МИРЭА / ГОСТ 7.32)
Проверить по ТЗ замечания по структуре, «рис.», источникам.
Проверить вёрстку в Word полупустые страницы (эвристика, возможны ложные срабатывания; нужны Word + pywin32).
Титул / задание отдельные DOCX, вставляются перед телом отчёта.
Автотитул галочка «Автотитул» / CLI `--auto-title` (по умолчанию **выкл.**):
info_conv.yaml рядом с .md + опционально блок ```title (number, year);
ФИО/группа Настройки Студент или --student / --group.
Явный путь к титулу отключает генератор. Подробно: docs/title.md.
Свойства файла Word Настройки Метаданные / --doc-author-from, --doc-author, --doc-title, --doc-comments.
По умолчанию автор = имя Windows, примечание «Создано при помощи md2gost (ТЗ МИРЭА)».
CLI (тот же движок)
python -m md2gost report.md -o report.docx --type coursework --check
python -m md2gost report.md --auto-title --student "Иванов И. И." --group ИНБО-31-23
python -m md2gost report.md --doc-author-from student --doc-comments ""
python -m md2gost report.docx -o report.md
python -m word2md report.docx
python -m md2gost --gui
md2gost.exe report.md --type PIS_custom --title title.docx
md2gost.exe report.md --schemes path/to/md2gost.schemes.json
md2gost.exe report.md --styles path/to/md2gost.styles.json
Импорт Word Markdown
Перетащите .docx или кнопка «Импорт DOCXMD». Подписи, таблицы, спецразделы
переводятся в диалект md2gost. UML/Mermaid из картинок не восстанавливаются.
Подробнее: docs/word2md.md.
Стили JSON (опционально)
Оверлей поверх пресета типа документа (--type). Файл md2gost.styles.json рядом с .md
или --styles / Настройки Файлы «Стили JSON». Меняет поля страницы и параметры
@@ -101,12 +118,32 @@ CLI (тот же движок)
Alice->>Bob: hello
```
IDEF0 (контекст A-0, рамка NIST):
%a0 Контекст +landscape
```idef0
title Распорядиться товаром
node A-0
[A0] Распорядиться товаром
<- Спрос
^ Нормативная документация
-> Товар
v Персонал
```
DFD (pbauermeister/dfd Graphviz вшитый/PATH / Kroki):
%dfd0 Контекстная DFD
```dfd
style context
entity Client Клиент
process System Система учёта заявок
Client --> System заявка
System --> Client статус
```
+listing ещё и листинг с исходником.
+landscape отдельная альбомная страница (A4, повёрт на 90°) вокруг рисунка/таблицы, затем снова книжная.
Языки: uml, plantuml, mermaid / mmd, или uml-<id> / <id> из файла схем (c4, usecase, bpmn, ).
BPMN 2.0: оградка ```bpmn / ```uml-bpmn, макросы Start, UserTask, XOR, Flow, Pool.
Формат в Word: PNG по умолчанию (PlantUML и локальный Mermaid ~2× для чёткости); --diagram-format svg вектор + PNG-запасной (Word 2016+).
IDEF0 конвертер не рисует вставляйте готовый PNG как обычный Рисунок.
Языки: uml, plantuml, mermaid / mmd, idef0, dfd / data-flow-diagram, или uml-<id> / <id> из файла схем (c4, usecase, archimate, ).
Формат в Word: PNG по умолчанию (PlantUML, локальный Mermaid, IDEF0 и DFD ~2× для чёткости); --diagram-format svg вектор + PNG-запасной (Word 2016+).
Подробнее Справка Схемы и раздел ниже в инструкции.
Формула (номер только если есть ссылка)
@@ -129,13 +166,17 @@ IDEF0 конвертер не рисует — вставляйте готовы
native поле Word (обновить при открытии); manual собирает md2gost.
Продолжение таблиц / листингов
word после сборки Word COM режет по реальной пагинации и вставляет «Продолжение»
(по умолчанию; нужны Windows + Word + pywin32).
off не резать, Word сам переносит.
word по умолчанию: после сборки Word COM режет по реальной пагинации и
вставляет «Продолжение» (нужны Windows + Word + pywin32). Также уводит
таблицу целиком, если внизу страницы зависли только название или
название + шапка граф (методичка: рис. Г.3).
off одна таблица, Word сам переносит строки; без авто«Продолжение».
Защита от сирот: keep_with_next у подписи/шапки и оценка
«подпись+шапка+1 строка» при рендере (без COM слабее, чем word).
legacy / caption режем по оценке высоты в md2gost (может не совпасть с Word).
Промпт для ИИ Справка Промпт для ИИ: выберите промпт, при необходимости
включите схемы (C4, BPMN, ) кнопками макросы допишутся в конец скопируйте
включите схемы (C4, usecase, ) кнопками макросы допишутся в конец скопируйте
в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
Документация Справка Документация: встроенный просмотр docs/*.md
@@ -151,15 +192,22 @@ SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
Первый запуск
Рядом с программой (рядом с md2gost.exe или в текущей папке при python -m)
создаётся md2gost.schemes.json из встроенного шаблона.
Если файл уже есть программа его не перезаписывает (ваши схемы сохраняются).
При каждом запуске схемы с author=md2gost синхронизируются с шаблоном приложения
(добавление новых id, обновление, удаление исчезнувших вроде bpmn).
Схемы с пустым или другим author не трогаются.
Если правите встроенную схему в GUI и сохраняете author сбрасывается,
чтобы следующая миграция не затёрла ваши правки.
Оградка в markdown
```uml обычный PlantUML (или ```plantuml)
```uml-c4 схема с id «c4» (то же, что ```c4)
```uml-usecase схема «usecase»
```bpmn / ```uml-bpmn BPMN 2.0 (макросы в diagrams/BPMN.puml)
```uml-archimate ArchiMate 3.2 (макросы Business_*, Rel_Serving, )
```mermaid / ```mmd Mermaid локально (браузер / Chromium / QuickJS) или через Kroki
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase, bpmn.
```idef0 IDEF0 (FIPS 183): боксы, стрелки ICOM, рамка NIST; локальный Pillow
```dfd / ```data-flow-diagram DFD (pbauermeister/dfd): process/entity/store, --> ;
Graphviz (вшитый / «Скачать Graphviz» / PATH) или Kroki
Встроенные пресеты PlantUML: c4, c4context, c4component, usecase.
Пример
@@ -171,18 +219,6 @@ System(app, "Портал")
Rel(user, app, "логин")
```
```bpmn
StartMessage(s, "заявка")
UserTask(t, "Проверить")
XOR(gw)
End(e_ok)
End(e_no)
Flow(s, t)
Flow(t, gw)
CondFlow(gw, e_ok, "да")
DefaultFlow(gw, e_no)
```
Поля схемы в JSON
title подпись в GUI
version версия схемы
@@ -207,27 +243,17 @@ URL из includes (и !include https://… внутри .puml) при перво
«Сбросить кэш includes» в Настройки Диаграммы или в «Шаблоны UML» удаляет индекс
и только файлы, перечисленные в нём.
BPMN 2.0 (```bpmn)
Пул: Pool(alias, "Участник") { Lane(alias, "Роль") { } }
События: Start, StartMessage, StartTimer, CatchMessage, ThrowMessage,
CatchTimer, CatchError, End, EndMessage, EndError, EndTerminate
Задачи: Task / UserTask / ServiceTask / ScriptTask / ManualTask /
SendTask / ReceiveTask / BusinessRuleTask / SubProcess
Шлюзы: XOR (Exclusive), AND (Parallel), OR (Inclusive), EventBased
Потоки: Flow, CondFlow(from, to, "условие"), DefaultFlow, MessageFlow
Данные: DataObject, DataStore, Annotation
Граница: BoundaryError/Timer/Message + Attach(task, event)
Sequence Flow только внутри пула; между пулами MessageFlow.
Чего нет
IDEF0 / DFD PlantUML не умеет; вставляйте готовый PNG как Рисунок.
У BPMN нет «прилипания» boundary-события к кромке задачи (ставьте Attach)
и нет настоящей двойной окружности у intermediate (толщина линии).
Балансировка родитель/потомок DFD между уровнями не проверяется автоматически.
Рендер
UML: Java + plantuml.jar (локально) иначе локальный Kroki иначе kroki.io.
Mermaid: системный Chrome/Edge Playwright Chromium (кнопка / --install-chromium)
QuickJS (mermaidx, офлайн) локальный Kroki kroki.io.
IDEF0: Pillow, без сети и Java.
DFD: data-flow-diagram Graphviz (dot/neato) или Kroki graphviz.
Нужны: Graphviz (вшитый / «Скачать Graphviz» / PATH) либо Kroki;
пакет data-flow-diagram ставится с md2gost.
Формат: PNG по умолчанию (PlantUML / локальный Mermaid ~scale 2 для чёткости, размер на странице как при 1; --diagram-scale); --diagram-format svg PNG + SVG (svgBlip в Word 2016+).
+landscape у %подписи альбомная страница под широкий рисунок/таблицу.
"""
@@ -236,6 +262,7 @@ PROMPT_FILES = (
("generate-md.md", "Markdown для md2gost"),
("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"),
("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"),
("emulate-student.md", "Стиль: человечный текст, не отчёт ИИ"),
)
+2185
View File
File diff suppressed because it is too large Load Diff
+125 -15
View File
@@ -2,13 +2,22 @@
from __future__ import annotations
import os
import tempfile
import zipfile
from pathlib import Path
from docx.enum.section import WD_ORIENT
from docx.enum.text import WD_PARAGRAPH_ALIGNMENT
from docx.oxml.ns import qn
from docx.shared import Mm, Pt
from lxml import etree
from .util import create_element
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_W = f"{{{_W_NS}}}"
# GOST-like A4 margins (same as styles._apply_common_page_and_body)
MARGIN_LEFT = Mm(30)
MARGIN_RIGHT = Mm(10)
@@ -91,6 +100,10 @@ def apply_section_geometry(section, *, landscape: bool) -> None:
if landscape:
sect_pr.append(create_element("w:vAlign", {"w:val": "center"}))
# add_section copies template sectPr including w:pgNumType/@w:start — strip so
# landscape / return-to-portrait sections continue numbering instead of restarting.
clear_section_page_start(section)
def content_size(*, landscape: bool) -> tuple:
"""Return (max_height, max_width) usable content area for LayoutTracker."""
@@ -131,31 +144,31 @@ def clear_section_footer(section) -> None:
footer.add_paragraph()
def clear_section_page_start(section) -> None:
"""Remove w:pgNumType/@w:start so PAGE continues from the previous section."""
sect_pr = section._sectPr
for child in list(sect_pr):
if child.tag != qn("w:pgNumType"):
continue
if child.get(qn("w:start")) is not None:
del child.attrib[qn("w:start")]
# Empty pgNumType (no start/fmt) is useless — drop the element.
if len(child.attrib) == 0:
sect_pr.remove(child)
def ensure_continuous_page_numbers(document) -> None:
"""
After docxcompose of title/assignment: no PAGE on early sections,
continuous numbering (do not restart at 1 on body section).
"""
from docx.oxml.ns import qn
sections = list(document.sections)
if not sections:
return
# Heuristic: sections before the first that already has a PAGE field /
# or first N-1 if title was prepended — clear footer on all but keep
# continuous pgNumType. Body renderer already puts PAGE on body section;
# after compose, early sections may inherit footers — clear empty ones
# that belong to front matter (no Heading-like body content is hard to
# detect), so: clear footer on every section that has no PAGE field, and
# strip w:pgNumType start=1 everywhere.
# Strip w:pgNumType start everywhere so landscape / body sections stay continuous.
for section in sections:
sect_pr = section._sectPr
for child in list(sect_pr):
if child.tag == qn("w:pgNumType"):
# Keep continuous: remove start attribute if present
if child.get(qn("w:start")) is not None:
del child.attrib[qn("w:start")]
clear_section_page_start(section)
def set_section_page_start(section, start: int) -> None:
@@ -191,3 +204,100 @@ def apply_page_number_start(
if idx >= len(sections):
return
set_section_page_start(sections[idx], int(start))
def _iter_sect_pr(document_root) -> list:
"""Return sectPr elements in document order (inline + final body sectPr)."""
body = document_root.find(f"{_W}body")
if body is None:
return []
sects: list = []
for child in body:
tag = child.tag
if tag == f"{_W}sectPr":
sects.append(child)
continue
if tag != f"{_W}p":
continue
p_pr = child.find(f"{_W}pPr")
if p_pr is None:
continue
sect = p_pr.find(f"{_W}sectPr")
if sect is not None:
sects.append(sect)
return sects
def _strip_pg_num_start_xml(sect_pr) -> None:
for child in list(sect_pr):
if child.tag != f"{_W}pgNumType":
continue
if child.get(f"{_W}start") is not None:
del child.attrib[f"{_W}start"]
if len(child.attrib) == 0:
sect_pr.remove(child)
def _set_pg_num_start_xml(sect_pr, start: int) -> None:
pg = sect_pr.find(f"{_W}pgNumType")
if pg is None:
pg = etree.SubElement(sect_pr, f"{_W}pgNumType")
pg.set(f"{_W}start", str(int(start)))
def patch_docx_page_starts(
path: str | Path,
start: int | None = None,
*,
front_sections: int = 0,
) -> None:
"""
Strip (and optionally re-apply) w:pgNumType/@w:start inside word/document.xml.
Used after Word COM Save, which often rewrites start=\"1\" onto every section.
Zip/lxml only does not round-trip through python-docx (preserves
continuation captions and other Word-written parts).
"""
abs_path = os.path.abspath(str(path))
if not os.path.isfile(abs_path):
return
with zipfile.ZipFile(abs_path, "r") as zin:
names = zin.namelist()
if "word/document.xml" not in names:
return
doc_bytes = zin.read("word/document.xml")
other = {n: zin.read(n) for n in names if n != "word/document.xml"}
root = etree.fromstring(doc_bytes)
sects = _iter_sect_pr(root)
for sect in sects:
_strip_pg_num_start_xml(sect)
if start is not None and int(start) >= 1:
idx = max(0, int(front_sections))
if idx < len(sects):
_set_pg_num_start_xml(sects[idx], int(start))
new_doc = etree.tostring(
root,
xml_declaration=True,
encoding="UTF-8",
standalone=True,
)
parent = os.path.dirname(abs_path) or "."
fd, tmp_name = tempfile.mkstemp(suffix=".docx", dir=parent)
os.close(fd)
try:
with zipfile.ZipFile(tmp_name, "w", compression=zipfile.ZIP_DEFLATED) as zout:
for name, data in other.items():
zout.writestr(name, data)
zout.writestr("word/document.xml", new_doc)
os.replace(tmp_name, abs_path)
except Exception:
try:
os.remove(tmp_name)
except OSError:
pass
raise
+144 -17
View File
@@ -9,7 +9,7 @@ import subprocess
import traceback
from dataclasses import dataclass
from datetime import datetime
from getpass import getuser
from pathlib import Path
from typing import Callable
from docx import Document
@@ -41,6 +41,10 @@ class ConvertRequest:
hr_pagebreak: bool = False
title: str | None = None
assignment: str | None = None
student: str | None = None
group: str | None = None
# Auto-build title from info_conv.yaml / ```title (off by default).
auto_title: bool = False
check: bool = False
check_only: bool = False
strict: bool = False
@@ -58,6 +62,15 @@ class ConvertRequest:
table_repeat_header: bool = False
# PAGE field start on body section; None = continuous from document start.
page_number_start: int | None = None
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
doc_author_source: str | None = None
doc_author: str | None = None
doc_last_modified_by: str | None = None
doc_title: str | None = None
doc_subject: str | None = None
doc_keywords: str | None = None
doc_comments: str | None = None
doc_category: str | None = None
@dataclass
@@ -127,16 +140,33 @@ def _apply_page_offset(document, req: ConvertRequest, *, had_title: bool, had_as
from .page_geometry import apply_page_number_start
start = getattr(req, "page_number_start", None)
start_i: int | None
if start is None:
return
try:
start_i = int(start)
except (TypeError, ValueError):
return
start_i = None
else:
try:
start_i = int(start)
except (TypeError, ValueError):
start_i = None
front = int(bool(had_title)) + int(bool(had_assignment))
# Always strip copied w:pgNumType/@w:start (landscape sections); optional body restart.
apply_page_number_start(document, start_i, front_sections=front)
def _page_offset_args(req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> tuple[int | None, int]:
"""Return (page_number_start, front_sections) for post-Word patch."""
start = getattr(req, "page_number_start", None)
if start is None:
start_i = None
else:
try:
start_i = int(start)
except (TypeError, ValueError):
start_i = None
front = int(bool(had_title)) + int(bool(had_assignment))
return start_i, front
def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
"""Run check and/or conversion. Does not re-raise; errors go into ConvertResult."""
emit: LogFn = log if log is not None else print
@@ -154,6 +184,24 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
emit(report)
return ConvertResult(True, 0, check_report=report, message=report)
# DOCX → Markdown (word2md) when input is .docx without --check-pages
if filename.lower().endswith(".docx"):
from word2md import ImportRequest, convert_docx
out = (req.output or "").strip() or None
if out and out.lower().endswith(".docx"):
out = str(Path(filename).with_suffix(".md"))
result = convert_docx(
ImportRequest(filename=filename, output=out),
log=emit,
)
return ConvertResult(
result.ok,
result.exit_code,
output_path=result.output_path,
message=result.message,
)
if not filename:
return fail(2, "Укажите исходный markdown-файл")
if not filename.lower().endswith(".md"):
@@ -192,10 +240,57 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
with open(filename, encoding="utf-8") as f:
md_text = f.read()
from .title_page import (
build_title_docx,
load_info_conv,
merge_title_meta,
parse_and_strip_title_fence,
should_generate_title,
)
from .user_profile import load_user_profile
fence_fields, body_md = parse_and_strip_title_fence(md_text)
yaml_fields = load_info_conv(md_dir)
generated_title_path: str | None = None
title_path = (req.title or "").strip() or None
if not output:
output = default_output_path(filename)
if should_generate_title(
explicit_title=title_path,
fence_fields=fence_fields,
md_dir=md_dir,
enabled=bool(req.auto_title),
):
profile = load_user_profile()
student = (req.student or "").strip() or profile.student
group = (req.group or "").strip() or profile.group
if not student or not group:
return fail(
2,
"Для генерации титула нужны ФИО и группа студента "
"(GUI: Настройки → Студент…, или CLI: --student / --group).",
)
meta = merge_title_meta(
yaml_fields=yaml_fields,
fence_fields=fence_fields,
student=student,
group=group,
)
out_dir = os.path.dirname(os.path.abspath(output)) or md_dir
generated_title_path = os.path.join(out_dir, ".md2gost_title.docx")
try:
build_title_docx(meta, generated_title_path)
title_path = generated_title_path
emit(f"Титульный лист: {generated_title_path}")
except Exception as exc:
return fail(1, f"Не удалось сгенерировать титул: {exc}")
check_report = ""
if req.check or req.check_only:
issues = check_markdown(
md_text,
body_md,
req.doc_type,
table_continuation=req.table_continuation,
listing_continuation=req.listing_continuation,
@@ -211,9 +306,6 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
message=check_report,
)
if not output:
output = default_output_path(filename)
template = (req.template or "").strip() or default_template_path()
handler = _CallbackLogHandler(emit)
@@ -236,11 +328,12 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
listing_continuation=req.listing_continuation,
hr_pagebreak=req.hr_pagebreak,
styles_path=req.styles_path or None,
markdown_text=body_md,
)
converter.convert()
document = converter.document
if req.title or req.assignment:
if title_path or req.assignment:
try:
from docxcompose.composer import Composer
except ImportError:
@@ -256,8 +349,8 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
body.remove(child)
composer = Composer(shell)
if req.title:
composer.append(Document(req.title))
if title_path:
composer.append(Document(title_path))
shell.add_page_break()
if req.assignment:
composer.append(Document(req.assignment))
@@ -267,19 +360,36 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
apply_style_config(document, style_cfg)
_fix_front_matter_after_compose(
document,
had_title=bool(req.title),
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
_apply_page_offset(
document,
req,
had_title=bool(req.title),
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
document.core_properties.author = getuser()
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
from .doc_metadata import apply_document_metadata, resolve_document_metadata
from .user_profile import load_user_profile as _load_user_profile
profile = _load_user_profile()
apply_document_metadata(
document,
resolve_document_metadata(
stored=profile.metadata,
student=(req.student or "").strip() or profile.student,
author_source=req.doc_author_source,
author=req.doc_author,
last_modified_by=req.doc_last_modified_by,
title=req.doc_title,
subject=req.doc_subject,
keywords=req.doc_keywords,
comments=req.doc_comments,
category=req.doc_category,
),
)
try:
document.save(output)
except PermissionError:
@@ -297,12 +407,19 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
root_log.removeHandler(handler)
attached_diag.removeHandler(handler)
attached_diag.setLevel(prev_diag_level)
if generated_title_path and os.path.isfile(generated_title_path):
try:
os.remove(generated_title_path)
except OSError:
pass
abs_out = os.path.abspath(output)
emit(f"Generated document: {abs_out}")
if req.table_continuation == "word" or req.listing_continuation == "word":
from .word_fix import fix_continuations
from .page_geometry import patch_docx_page_starts
fix = fix_continuations(
abs_out,
tables=(req.table_continuation == "word"),
@@ -317,6 +434,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
check_report = (
(check_report + "\n" + fix.message).strip() if check_report else fix.message
)
# Word Save often rewrites w:pgNumType/@w:start=\"1\" on every section — strip again.
start_i, front = _page_offset_args(
req,
had_title=bool(title_path),
had_assignment=bool(req.assignment),
)
try:
patch_docx_page_starts(abs_out, start_i, front_sections=front)
except Exception as exc:
emit(f"Не удалось восстановить сквозную нумерацию после Word: {exc}")
if req.check_pages:
from .page_fill_check import check_docx_page_fill, format_page_fill_report
+1 -1
View File
@@ -99,7 +99,7 @@ def get_profile(doc_type: str) -> DocProfile:
# --- text autofixes ---
# ```uml / ```mermaid / ```uml-c4 / ```bpmn … — кавычки и тире там синтаксис
# ```uml / ```mermaid / ```uml-c4 … — кавычки и тире там синтаксис
_FENCE_RE = re.compile(
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n"
r"(?P<body>[\s\S]*?)"
+5 -3
View File
@@ -293,7 +293,9 @@ class Paragraph(Renderable):
yield SubRenderable(image, False)
layout_state.add_height(rendered_image_height)
else:
# Defer this image and any remaining siblings to the next page
# (as SubRenderable — bare Image objects break the renderer).
yield SubRenderable(image, True)
break
yield from images
for rest in images:
yield SubRenderable(rest, True)
return
+93 -11
View File
@@ -113,6 +113,10 @@ class Table(Renderable, RequiresNumbering):
cell_height = 0
if not (apply_merge and merge_v == "continue"):
for paragraph in row[col]:
# Method guide §3: do not leave caption + column headers alone
# at page bottom — glue header row to the following data row.
if row_idx == 0:
paragraph._docx_paragraph.paragraph_format.keep_with_next = True
cell_layout_state = LayoutState(
layout_state.max_height, layout_state.max_width
)
@@ -133,6 +137,62 @@ class Table(Renderable, RequiresNumbering):
)
return docx_row, row_height
def _peek_row_height(self, row_idx: int, layout_state: LayoutState) -> Length:
"""Estimate row height without attaching cell paragraphs to a table."""
from .paragraph_sizer import ParagraphSizer
row = self._rows[row_idx]
merge_row = self._merge[row_idx]
row_height = 0
col_w = self._col_width(layout_state)
col = 0
while col < self._cols:
merge_v, merge_h = merge_row[col]
if merge_h == "continue":
col += 1
continue
span = self._grid_span(merge_row, col)
if span < 1:
span = 1
cell_height = 0
if merge_v != "continue":
max_w = col_w * span - CELL_OFFSET
for paragraph in row[col]:
cell_height += ParagraphSizer(
paragraph._docx_paragraph, None, max_w
).calculate_height().full
row_height = max(cell_height, row_height)
col += span
row_height += Pt(0.5) + (
ROW_HEIGHT_SLACK if self._continuation_mode not in _WORD_PAGED_MODES else Pt(0)
)
return row_height
def _start_block_height(
self, previous_rendered: RenderedInfo | None, layout_state: LayoutState
) -> Length:
"""Caption + header + first data row (method: no orphan title/header)."""
from docx.text.paragraph import Paragraph as DocxParagraph
from .paragraph_sizer import ParagraphSizer
# Dry-run caption sizing (temporary Caption; not yielded into the body).
cap = Caption(
self._parent, "Таблица", self._caption_info, self._number, True
)
prev_p = (
previous_rendered.docx_element
if previous_rendered and isinstance(previous_rendered.docx_element, DocxParagraph)
else None
)
total = ParagraphSizer(
cap._docx_paragraph, prev_p, layout_state.max_width
).calculate_height().full
if self._rows:
total += self._peek_row_height(0, layout_state)
if len(self._rows) > 1:
total += self._peek_row_height(1, layout_state)
return total
def _should_split_fragment(
self, row_height: Length, layout_state: LayoutState, rows_in_fragment: int
) -> bool:
@@ -169,10 +229,29 @@ class Table(Renderable, RequiresNumbering):
def render(self, previous_rendered: RenderedInfo, layout_state: LayoutState) \
-> Generator[RenderedInfo, None, None]:
# Method guide: never leave only «Таблица N» or «Таблица N» + column
# headers at the bottom of a page. If caption+header+first data row do
# not fit, push the whole block to the next page (layout leftover;
# Word also gets keep_with_next on caption/header).
leftover = Pt(0)
start_h = self._start_block_height(previous_rendered, layout_state)
if (
layout_state.current_page_height > 0
and start_h > layout_state.remaining_page_height
):
leftover = layout_state.remaining_page_height
layout_state.add_height(leftover)
previous_rendered = None
caption_rendered_infos = list(
Caption(self._parent, "Таблица", self._caption_info, self._number, True)
.render(previous_rendered, copy(layout_state))
)
if leftover and caption_rendered_infos:
first = caption_rendered_infos[0]
caption_rendered_infos[0] = RenderedInfo(
first.docx_element, first.height + leftover
)
layout_state.add_height(sum(info.height for info in caption_rendered_infos))
yield from caption_rendered_infos
@@ -190,19 +269,22 @@ class Table(Renderable, RequiresNumbering):
)
if self._should_split_fragment(row_height, layout_state, rows_in_fragment):
yield RenderedInfo(docx_table, table_height)
yield from self._emit_page_break_and_optional_caption(layout_state)
# Never cut after the header alone (method: no title+column headers
# without a data row). Prefer slight overflow over an orphan fragment.
if rows_in_fragment >= 2:
yield RenderedInfo(docx_table, table_height)
yield from self._emit_page_break_and_optional_caption(layout_state)
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
apply_merge = False
rows_in_fragment = 0
table_height = Pt(0.5)
docx_table = create_table(
self._parent, 0, self._cols, self._table_width(layout_state)
)
apply_merge = False
rows_in_fragment = 0
table_height = Pt(0.5)
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
docx_row, row_height = self._build_docx_row(
docx_table, row_idx, apply_merge, layout_state
)
docx_table._element.append(docx_row._element)
layout_state.add_height(row_height)
+3
View File
@@ -122,6 +122,9 @@ class Renderer:
def process(self, renderables: list[Renderable]):
items = self._ensure_appendix_index(list(renderables))
for i in range(len(items)):
# Figures that did not fit the previous page must land before the
# next top-level block (not after a later table / end of doc).
self._flush_to_new_screen()
self.render(items[i])
self._flush_to_new_screen()
+323
View File
@@ -0,0 +1,323 @@
"""Generate MIREA practice title page from TitleTemplate.docx + meta sources.
Red runs (w:color C00000/FF0000) in the bundled template are placeholders.
Sources (later wins for academic fields): info_conv.yaml < ```title fence.
Student ФИО/группа come from user profile / CLI, not from yaml.
"""
from __future__ import annotations
import logging
import os
import re
import shutil
from dataclasses import dataclass, fields
from datetime import datetime
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import RGBColor
from . import package_dir
_log = logging.getLogger(__name__)
INFO_CONV_FILENAME = "info_conv.yaml"
TITLE_FENCE_RE = re.compile(
r"^```title[ \t]*\r?\n(.*?)(?:\r?\n)```[ \t]*(?:\r?\n|$)",
re.DOTALL | re.IGNORECASE | re.MULTILINE,
)
KV_LINE_RE = re.compile(r"^([^\s:#][^:#]*?)\s*:\s*(.*)$")
# Canonical field → accepted key aliases (lowercased for match)
_KEY_ALIASES: dict[str, tuple[str, ...]] = {
"institute": (
"institute",
"институт",
"инститиут",
),
"department": (
"department",
"кафедра",
),
"discipline": (
"discipline",
"дисциплина",
),
"teacher": (
"teacher",
"преподаватель",
"преподователь",
),
"teacher_position": (
"teacher_position",
"teacherposition",
"должность",
"должность преподавателя",
),
"number": ("number", "номер", "", "n"),
"year": ("year", "год"),
"student": ("student", "фио", "студент"),
"group": ("group", "группа"),
}
# Paragraph indices in bundled TitleTemplate.docx (do not reorder template).
# P0 institute, P1 department, P5 discipline (red inside guillemets),
# P8 number, P13 group, P15 student, P19 teacher_position, P21 teacher, P28 year.
_SLOT_PARAS: dict[str, int] = {
"institute": 0,
"department": 1,
"discipline": 5,
"number": 8,
"group": 13,
"student": 15,
"teacher_position": 19,
"teacher": 21,
"year": 28,
}
_RED_VALS = frozenset({"FF0000", "C00000", "FF0", "F00"})
@dataclass
class TitleMeta:
institute: str = ""
department: str = ""
discipline: str = ""
number: str = ""
group: str = ""
student: str = ""
teacher_position: str = ""
teacher: str = ""
year: str = ""
def academic_filled(self) -> bool:
return any(
[
self.institute,
self.department,
self.discipline,
self.teacher,
self.teacher_position,
self.number,
self.year,
]
)
def default_title_template_path() -> str:
return os.path.join(package_dir(), "TitleTemplate.docx")
def _norm_key(key: str) -> str:
return re.sub(r"\s+", " ", (key or "").strip().lower().replace("ё", "е"))
def _alias_to_field(key: str) -> str | None:
nk = _norm_key(key)
for field_name, aliases in _KEY_ALIASES.items():
if nk in aliases:
return field_name
return None
def parse_kv_text(text: str) -> dict[str, str]:
"""Parse flat `key: value` lines (title fence / info_conv.yaml)."""
out: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if line in ("---", "..."):
continue
m = KV_LINE_RE.match(line)
if not m:
continue
field_name = _alias_to_field(m.group(1))
if not field_name:
continue
value = m.group(2).strip().strip("\"'")
# strip inline comment
if " #" in value:
value = value.split(" #", 1)[0].rstrip()
if value:
out[field_name] = value
return out
def parse_and_strip_title_fence(md: str) -> tuple[dict[str, str], str]:
"""Return (fields from first ```title fence, markdown without that fence)."""
m = TITLE_FENCE_RE.search(md or "")
if not m:
return {}, md or ""
fields_map = parse_kv_text(m.group(1))
body = (md or "")[: m.start()] + (md or "")[m.end() :]
# collapse leading blank lines left by stripping
body = re.sub(r"^\s*\n", "", body, count=1)
return fields_map, body
def load_info_conv(md_dir: str | Path) -> dict[str, str]:
path = Path(md_dir) / INFO_CONV_FILENAME
if not path.is_file():
return {}
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
_log.warning("Не удалось прочитать %s: %s", path, exc)
return {}
return parse_kv_text(text)
def info_conv_exists(md_dir: str | Path) -> bool:
return (Path(md_dir) / INFO_CONV_FILENAME).is_file()
def merge_title_meta(
*,
yaml_fields: dict[str, str] | None = None,
fence_fields: dict[str, str] | None = None,
student: str = "",
group: str = "",
) -> TitleMeta:
"""Fence overrides yaml for academic fields; student/group from args."""
merged: dict[str, str] = {}
for src in (yaml_fields or {}, fence_fields or {}):
for k, v in src.items():
if k in ("student", "group"):
continue # never from yaml/fence for title identity
if v:
merged[k] = v
meta = TitleMeta(**{f.name: merged.get(f.name, "") for f in fields(TitleMeta)})
meta.student = (student or "").strip()
meta.group = (group or "").strip()
if not meta.year:
meta.year = str(datetime.now().year)
if not meta.number:
meta.number = "1"
return meta
def _run_color_val(run) -> str | None:
rPr = run._element.find(qn("w:rPr"))
if rPr is None:
return None
c = rPr.find(qn("w:color"))
if c is None:
return None
return (c.get(qn("w:val")) or "").upper() or None
def _is_red_run(run) -> bool:
val = _run_color_val(run)
if not val:
return False
if val in _RED_VALS:
return True
# theme-ish: FF???? with green/blue near zero
if len(val) == 6 and val.startswith("FF") and val[2:4] == "00" and val[4:6] == "00":
return True
if len(val) == 6 and val.startswith("C0") and val[2:] == "0000":
return True
return val == "C00000"
def _clear_run_color(run) -> None:
rPr = run._element.find(qn("w:rPr"))
if rPr is None:
return
c = rPr.find(qn("w:color"))
if c is not None:
rPr.remove(c)
try:
run.font.color.rgb = RGBColor(0, 0, 0)
except Exception:
pass
def _replace_red_in_paragraph(paragraph, value: str) -> bool:
"""Replace contiguous red runs' text with ``value``; clear red color."""
runs = list(paragraph.runs)
red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r) and (r.text or "")]
if not red_idxs:
# also consider empty/tab-only already captured
red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r)]
if not red_idxs:
return False
first = red_idxs[0]
runs[first].text = value
_clear_run_color(runs[first])
for i in red_idxs[1:]:
runs[i].text = ""
_clear_run_color(runs[i])
return True
def fill_title_document(doc: Document, meta: TitleMeta) -> None:
"""Fill red placeholder slots in an opened title template document."""
paras = doc.paragraphs
mapping = {
"institute": meta.institute,
"department": meta.department,
"discipline": meta.discipline,
"number": meta.number,
"group": meta.group,
"student": meta.student,
"teacher_position": meta.teacher_position,
"teacher": meta.teacher,
"year": meta.year,
}
for field_name, idx in _SLOT_PARAS.items():
value = (mapping.get(field_name) or "").strip()
if not value:
continue
if idx >= len(paras):
_log.warning("TitleTemplate: нет абзаца %s для поля %s", idx, field_name)
continue
if not _replace_red_in_paragraph(paras[idx], value):
# Fallback: whole paragraph is placeholder (student name)
if field_name in ("student", "institute", "department") and paras[idx].runs:
paras[idx].runs[0].text = value
for r in paras[idx].runs[1:]:
r.text = ""
_clear_run_color(paras[idx].runs[0])
else:
_log.warning(
"TitleTemplate: не найдены красные runs в P%s (%s)", idx, field_name
)
def build_title_docx(
meta: TitleMeta,
out_path: str | Path,
*,
template_path: str | Path | None = None,
) -> Path:
"""Copy template, fill slots, save to out_path. Returns out_path."""
src = Path(template_path or default_title_template_path())
if not src.is_file():
raise FileNotFoundError(f"Шаблон титульника не найден: {src}")
dest = Path(out_path)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
doc = Document(str(dest))
fill_title_document(doc, meta)
doc.save(str(dest))
return dest
def should_generate_title(
*,
explicit_title: str | None,
fence_fields: dict[str, str],
md_dir: str | Path,
enabled: bool = False,
) -> bool:
if not enabled:
return False
if explicit_title and str(explicit_title).strip():
return False
if fence_fields:
return True
return info_conv_exists(md_dir)
+60
View File
@@ -0,0 +1,60 @@
"""Persistent student profile (ФИО + группа) and DOCX metadata defaults."""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from pathlib import Path
from .diagram_includes import app_dir
from .doc_metadata import DocumentMetadata
_log = logging.getLogger(__name__)
USER_PROFILE_FILENAME = "md2gost.user.json"
@dataclass
class UserProfile:
student: str = ""
group: str = ""
metadata: DocumentMetadata = field(default_factory=DocumentMetadata)
def is_complete(self) -> bool:
return bool(self.student.strip() and self.group.strip())
def user_profile_path(base: Path | None = None) -> Path:
return (base or app_dir()) / USER_PROFILE_FILENAME
def load_user_profile(base: Path | None = None) -> UserProfile:
path = user_profile_path(base)
if not path.is_file():
return UserProfile()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
_log.warning("Не удалось прочитать %s: %s", path, exc)
return UserProfile()
if not isinstance(raw, dict):
return UserProfile()
return UserProfile(
student=str(raw.get("student") or "").strip(),
group=str(raw.get("group") or "").strip(),
metadata=DocumentMetadata.from_dict(raw.get("metadata")),
)
def save_user_profile(profile: UserProfile, base: Path | None = None) -> Path:
path = user_profile_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
meta = profile.metadata or DocumentMetadata()
data = {
"student": (profile.student or "").strip(),
"group": (profile.group or "").strip(),
"metadata": meta.to_dict(),
}
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
+396 -15
View File
@@ -81,6 +81,24 @@ def find_page_break_row(page_numbers: list[int]) -> int | None:
return None
def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
"""True if a Word split would leave only the heading row on the previous page.
Method guide §3: do not leave «Таблица N» + column headers alone at page end.
"""
return bool(had_header and break_at == 2)
def is_caption_orphaned(caption_page: int | None, first_row_page: int | None) -> bool:
"""True if «Таблица N — …» sits on an earlier page than the table body.
Method guide: do not leave only the table title at the bottom of a page.
"""
if caption_page is None or first_row_page is None:
return False
return caption_page < first_row_page
def continuation_label(kind: str, number: str) -> str:
if kind == "listing":
return f"Продолжение Листинга {number}"
@@ -146,6 +164,11 @@ def fix_continuations(
splits += made
skipped = sum(1 for d in details if d.startswith("skip:"))
_clear_section_page_restarts(doc)
try:
doc.Fields.Update()
except Exception as exc:
details.append(f"skip: fields update: {exc}")
doc.Save()
msg = (
f"Пост-разрыв Word: разрезов {splits}"
@@ -175,25 +198,260 @@ def fix_continuations(
pass
def _clear_section_page_restarts(doc) -> None:
"""Turn off page-number restart on every section (continuous PAGE through landscape)."""
# wdHeaderFooterPrimary = 1, wdHeaderFooterFirstPage = 2, wdHeaderFooterEvenPages = 3
for i in range(1, int(doc.Sections.Count) + 1):
sec = doc.Sections(i)
for hf_kind in (1, 2, 3):
for getter in (sec.Headers, sec.Footers):
try:
hf = getter(hf_kind)
except Exception:
continue
try:
pns = hf.PageNumbers
except Exception:
continue
try:
if int(pns.Count) < 1:
continue
except Exception:
continue
try:
pns.RestartNumberingAtSection = False
except Exception:
pass
def _row_page_numbers(table) -> list[int]:
"""Page number at the start of each row (more reliable than end-of-range)."""
"""Page number at the start of each row (more reliable than end-of-range).
Tables with vertical merges (`^` in md2gost) often raise on ``Rows(i).Range``;
fall back to ``Cell(i, j).Range`` per column.
"""
pages: list[int] = []
n = int(table.Rows.Count)
for i in range(1, n + 1):
try:
rng = table.Rows(i).Range
# Collapse to start so a tall row reports where it begins
start = int(rng.Start)
page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
try:
page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
page = pages[-1] if pages else 1
page = _page_at_row(table, i)
if page is None:
page = pages[-1] if pages else 1
pages.append(page)
return pages
def _page_at_row(table, row_1based: int) -> int | None:
"""Best-effort page number for a table row (handles vertical merges)."""
# Prefer Cells — works when Rows(i) is blocked by vMerge.
for j in range(1, 32):
try:
start = int(table.Cell(row_1based, j).Range.Start)
return int(
table.Range.Document.Range(start, start).Information(
_WD_ACTIVE_END_PAGE_NUMBER
)
)
except Exception:
# Missing cell in this column (merge continue) or past last column
if j == 1:
continue
# After first column, consecutive failures → stop scanning columns
break
try:
rng = table.Rows(row_1based).Range
start = int(rng.Start)
return int(
rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER)
)
except Exception:
try:
return int(table.Rows(row_1based).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
except Exception:
return None
def _probe_ncols(table) -> int:
"""Max column index reachable from row 1 (and a few other rows)."""
ncols = 1
for row in (1, 2, 3):
for probe in range(1, 64):
try:
table.Cell(row, probe)
ncols = max(ncols, probe)
except Exception:
break
return max(ncols, 1)
def _rows_individually_accessible(table) -> bool:
"""False when Word blocks ``Rows(i)`` due to vertical merges."""
try:
n = int(table.Rows.Count)
if n < 1:
return True
_ = table.Rows(1).Range
if n > 1:
_ = table.Rows(n).Range
return True
except Exception:
return False
def _capture_vmerge_spans(table) -> list[tuple[int, int, int, str]]:
"""Return ``(row, col, span, text)`` for each vertical merge (1-based, span>1)."""
try:
nrows = int(table.Rows.Count)
except Exception:
return []
ncols = _probe_ncols(table)
spans: list[tuple[int, int, int, str]] = []
r = 1
while r <= nrows:
for c in range(1, ncols + 1):
try:
cell = table.Cell(r, c)
except Exception:
continue
span = 1
while r + span <= nrows:
try:
table.Cell(r + span, c)
break
except Exception:
row_exists = False
for cc in range(1, ncols + 1):
try:
table.Cell(r + span, cc)
row_exists = True
break
except Exception:
pass
if not row_exists:
break
span += 1
if span > 1:
spans.append((r, c, span, _cell_plain_text(cell)))
r += 1
return spans
def split_vmerge_spans_at_break(
spans: list[tuple[int, int, int, str]],
break_at: int,
) -> tuple[list[tuple[int, int, int, str]], list[tuple[int, int, int, str]]]:
"""Split captured merges across a 1-based ``Table.Split`` row index."""
first: list[tuple[int, int, int, str]] = []
cont: list[tuple[int, int, int, str]] = []
for r, c, span, text in spans:
if span <= 1:
continue
end = r + span - 1
if end < break_at:
first.append((r, c, span, text))
elif r >= break_at:
cont.append((r - break_at + 1, c, span, text))
else:
top = break_at - r
if top > 1:
first.append((r, c, top, text))
bottom = end - break_at + 1
if bottom >= 1:
cont.append((1, c, bottom, text))
return first, cont
def _set_cell_plain_text(cell, text: str) -> None:
"""Write plain text into a cell without eating the end-of-cell mark badly."""
raw = (text or "").replace("\r", "").replace("\x07", "").replace("\a", "")
try:
rng = cell.Range.Duplicate
# Exclude the cell's terminal marker characters
if int(rng.End) > int(rng.Start) + 1:
rng.End = int(rng.End) - 1
rng.Text = raw
except Exception:
try:
cell.Range.Text = raw
except Exception:
pass
def _restore_vmerge_spans(table, spans: list[tuple[int, int, int, str]]) -> int:
"""Re-apply vertical merges after unmerge+Split. Returns merges restored."""
made = 0
for r, c, span, text in spans:
if span <= 1:
continue
try:
nrows = int(table.Rows.Count)
if r < 1 or r > nrows or r + span - 1 > nrows:
continue
start = table.Cell(r, c)
if text and not _cell_plain_text(start):
_set_cell_plain_text(start, text)
if span == 1:
made += 1
continue
end = table.Cell(r + span - 1, c)
start.Merge(end)
made += 1
except Exception:
continue
return made
def _unmerge_vertical_cells(table) -> int:
"""Split vertically merged cells so ``Table.Split`` can run.
Returns number of cell-groups unmerged. Safe no-op if nothing to do.
Prefer capturing spans with ``_capture_vmerge_spans`` first and restoring
after the table split.
"""
try:
nrows = int(table.Rows.Count)
except Exception:
return 0
ncols = _probe_ncols(table)
made = 0
r = 1
while r <= nrows:
for c in range(1, ncols + 1):
try:
cell = table.Cell(r, c)
except Exception:
continue
span = 1
while r + span <= nrows:
try:
table.Cell(r + span, c)
break
except Exception:
row_exists = False
for cc in range(1, ncols + 1):
try:
table.Cell(r + span, cc)
row_exists = True
break
except Exception:
pass
if not row_exists:
break
span += 1
if span > 1:
try:
cell.Split(span, 1)
made += 1
except Exception:
pass
r += 1
try:
nrows = int(table.Rows.Count)
except Exception:
break
return made
def _paragraph_page_numbers_in_cell(table) -> list[int]:
"""Fallback: page of each paragraph in a single-cell table (legacy 1-row listings)."""
try:
@@ -245,6 +503,25 @@ def _expand_single_cell_listing_to_rows(table) -> bool:
return False
def _paragraph_page(paragraph) -> int | None:
try:
start = int(paragraph.Range.Start)
return int(
paragraph.Range.Document.Range(start, start).Information(
_WD_ACTIVE_END_PAGE_NUMBER
)
)
except Exception:
return None
def _caption_page_before_table(table) -> int | None:
p = _paragraph_before_table(table)
if p is None:
return None
return _paragraph_page(p)
def _fix_pass(
doc,
*,
@@ -285,18 +562,81 @@ def _fix_pass(
doc.Repaginate()
pages = _row_page_numbers(table)
# Method: only the title («Таблица N») at page bottom is forbidden.
if caption.kind == "table" and pages:
if is_caption_orphaned(_caption_page_before_table(table), pages[0]):
try:
if _page_break_before_table_caption(table):
details.append(
f"orphan-caption: {caption.kind} {caption.number} "
f"→ page break before caption"
)
doc.Repaginate()
else:
details.append(
f"skip: orphan-caption {caption.kind} {caption.number}: "
f"no caption para"
)
except Exception as exc:
details.append(f"skip: orphan-caption page break: {exc}")
continue
break_at = find_page_break_row(pages)
if break_at is None:
continue
if break_at > int(table.Rows.Count):
continue
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
vmerge_spans: list[tuple[int, int, int, str]] = []
if not _rows_individually_accessible(table):
try:
vmerge_spans = _capture_vmerge_spans(table)
n_un = _unmerge_vertical_cells(table)
if n_un:
details.append(
f"unmerge: {caption.kind} {caption.number} ({n_un} groups)"
)
doc.Repaginate()
table = doc.Tables(ti)
pages = _row_page_numbers(table)
break_at = find_page_break_row(pages)
if break_at is None:
details.append(
f"skip: {caption.kind} {caption.number} "
f"no page break after unmerge"
)
continue
if break_at > int(table.Rows.Count):
continue
except Exception as exc:
details.append(f"skip: unmerge {caption.kind} {caption.number}: {exc}")
continue
had_header = False
try:
had_header = bool(table.Rows(1).HeadingFormat)
except Exception:
had_header = False
# Method guide: caption + column headers alone on a page is forbidden.
# If Word broke right after the heading row, push the table (caption) to
# the next page instead of splitting into a header-only fragment.
if is_orphan_header_break(break_at, had_header=had_header):
try:
if _page_break_before_table_caption(table):
details.append(
f"orphan-header: {caption.kind} {caption.number} → page break before caption"
)
doc.Repaginate()
else:
details.append(
f"skip: orphan-header {caption.kind} {caption.number}: no caption para"
)
except Exception as exc:
details.append(f"skip: orphan-header page break: {exc}")
continue
try:
table.Split(break_at)
except Exception as exc:
@@ -304,12 +644,28 @@ def _fix_pass(
continue
try:
table = doc.Tables(ti)
cont = doc.Tables(ti + 1)
except Exception as exc:
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
made += 1
continue
if vmerge_spans:
try:
first_spans, cont_spans = split_vmerge_spans_at_break(
vmerge_spans, break_at
)
n1 = _restore_vmerge_spans(table, first_spans)
n2 = _restore_vmerge_spans(cont, cont_spans)
if n1 or n2:
details.append(
f"remerge: {caption.kind} {caption.number} "
f"(first={n1}, cont={n2})"
)
except Exception as exc:
details.append(f"skip: remerge {caption.kind} {caption.number}: {exc}")
before_cont = _paragraph_text_before_table(cont)
first_line = before_cont.strip().split("\n", 1)[0] if before_cont else ""
if not (first_line and _CONTINUATION_RE.match(first_line)):
@@ -332,7 +688,7 @@ def _fix_pass(
except Exception as exc:
details.append(f"skip: listing borders: {exc}")
# Header repeat is opt-in (default off)
# Header repeat is opt-in (default off) — not Word's native tblHeader paint
if repeat_header and had_header and caption.kind == "table":
try:
_ensure_header_on_continuation(first_table=table, cont_table=cont)
@@ -351,19 +707,44 @@ def _caption_before_table(table) -> CaptionInfo | None:
return parse_caption_text(text)
def _paragraph_text_before_table(table) -> str:
def _paragraph_before_table(table):
"""Return the Word Paragraph immediately before ``table``, or None."""
try:
rng = table.Range
if rng.Start <= 1:
return ""
return None
doc = table.Range.Document
prev = doc.Range(rng.Start - 1, rng.Start)
p = prev.Paragraphs(1)
return prev.Paragraphs(1)
except Exception:
return None
def _paragraph_text_before_table(table) -> str:
try:
p = _paragraph_before_table(table)
if p is None:
return ""
return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
except Exception:
return ""
def _page_break_before_table_caption(table) -> bool:
"""Set PageBreakBefore on the caption paragraph above the table."""
p = _paragraph_before_table(table)
if p is None:
return False
info = parse_caption_text(
(p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
)
if info is None or info.is_continuation:
return False
p.Format.PageBreakBefore = True
p.Format.KeepWithNext = True
return True
def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None:
"""Insert caption paragraph *outside* the table (before its start).