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