update 0.4.4
Python application / build (push) Has been cancelled

- update документация
- промт для ии полу конфигурируемый
This commit is contained in:
Igor20264
2026-09-06 11:04:01 +03:00
parent 638fd38d7f
commit 818a044aa1
26 changed files with 1987 additions and 457 deletions
+3 -1
View File
@@ -2,6 +2,8 @@
Конвертер Markdown → DOCX по методическим указаниям РТУ МИРЭА (ГОСТ 7.32-2017) на базе [md2gost](https://github.com/benzlokzik/md2gost).
**Полная документация:** [`docs/`](../docs/) (быстрый старт, синтаксис, схемы, типы, CLI/GUI, промпты).
## Установка
```bash
@@ -23,7 +25,7 @@ md2gost-gui
На Windows файл можно бросить из Проводника в верхнюю область окна. Клик по области — выбор через диалог. Все флаги CLI есть в форме (тип, нумерация, TOC, тире, `---` → разрыв страницы, титул/задание, диаграммы, проверка ТЗ).
Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (МИРЭА / ПИС) в буфер.
Вкладки **Инструкция** и **Промпт для ИИ** — справка и копирование системного промпта (с опциональным дописыванием UML-схем) в буфер.
### Сборка exe (Windows)
+9
View File
@@ -166,6 +166,14 @@ def build_parser() -> ArgumentParser:
dest="schemes_path",
help="Путь к md2gost.schemes.json (иначе рядом с приложением / с .md)",
)
parser.add_argument(
"--styles",
dest="styles_path",
help=(
"Путь к md2gost.styles.json (оверлей оформления поверх пресета --type; "
"также подхватывается md2gost.styles.json рядом с .md)"
),
)
parser.add_argument("--debug", help="Добавляет отладочные данные в документ",
action="store_true")
return parser
@@ -197,6 +205,7 @@ def request_from_args(args) -> ConvertRequest:
diagram_format=args.diagram_format,
diagram_scale=float(args.diagram_scale),
schemes_path=args.schemes_path,
styles_path=getattr(args, "styles_path", None),
debug=args.debug,
open_when_done=bool(args.debug),
)
+16 -2
View File
@@ -1,3 +1,5 @@
import os
import docx
from docx.document import Document
@@ -43,7 +45,8 @@ class Converter:
toc_mode: str = DEFAULT_TOC_MODE,
table_continuation: str = DEFAULT_TABLE_CONTINUATION,
listing_continuation: str = DEFAULT_LISTING_CONTINUATION,
hr_pagebreak: bool = False):
hr_pagebreak: bool = False,
styles_path: str | None = None):
if heading_numbering not in HEADING_NUMBERING_MODES:
raise ValueError(
f"heading_numbering must be one of {HEADING_NUMBERING_MODES}, "
@@ -71,10 +74,17 @@ class Converter:
self._table_continuation = table_continuation
self._listing_continuation = listing_continuation
self._hr_pagebreak = hr_pagebreak
self._styles_path = styles_path
self._profile = get_profile(doc_type)
self._document: Document = docx.Document(template_path)
self._document._body.clear_content()
apply_document_styles(self._document, self._profile.style_preset)
md_dir = os.path.dirname(os.path.abspath(input_path)) or "."
self._style_config = apply_document_styles(
self._document,
self._profile.style_preset,
md_dir=md_dir,
styles_path=styles_path,
)
self._debugger = Debugger(self._document) if debug else None
with open(input_path, encoding="utf-8") as f:
raw = f.read()
@@ -123,6 +133,10 @@ class Converter:
def document(self) -> Document:
return self._document
@property
def style_config(self):
return self._style_config
@property
def raw_markdown(self) -> str:
return self._raw_markdown
+175 -15
View File
@@ -14,13 +14,20 @@ from .diagram_schemes import (
DiagramScheme,
SCHEME_ID_RE,
ensure_user_schemes,
get_schemes,
load_schemes_from_path,
reload_schemes,
save_schemes,
user_schemes_path,
)
from .dnd import enable_file_drop, first_markdown
from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog
from .help_content import (
SCHEMES_HELP,
USAGE_HELP,
compose_prompt,
load_docs_catalog,
load_prompt_catalog,
)
from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path
from .profiles import (
DEFAULT_HEADING_NUMBERING,
@@ -244,6 +251,7 @@ class Md2GostApp:
self.template_var = tk.StringVar()
self.title_var = tk.StringVar()
self.assignment_var = tk.StringVar()
self.styles_var = tk.StringVar()
self.fallback_var = tk.StringVar()
self.format_var = tk.StringVar()
self.plantuml_var = tk.StringVar()
@@ -266,6 +274,7 @@ class Md2GostApp:
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="Инструкция", command=lambda: self._open_help_window("usage"))
help_menu.add_command(label="Документация", command=lambda: self._open_help_window("docs"))
help_menu.add_command(label="Схемы", command=lambda: self._open_help_window("schemes"))
help_menu.add_command(label="Промпт для ИИ", command=lambda: self._open_help_window("prompts"))
menubar.add_cascade(label="Справка", menu=help_menu)
@@ -313,8 +322,8 @@ class Md2GostApp:
win = tk.Toplevel(self.root)
win.title("Настройки — Файлы")
win.configure(bg=PAPER)
win.minsize(520, 200)
win.geometry("620x240")
win.minsize(520, 240)
win.geometry("620x280")
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
@@ -325,13 +334,16 @@ class Md2GostApp:
tip="DOCX титула, вставляется перед телом отчёта")
self._row_path(frame, 2, "Бланк задания", self.assignment_var, self._browse_assignment, clearable=True,
tip="DOCX бланка задания, вставляется перед телом отчёта")
self._row_path(frame, 3, "Стили JSON", self.styles_var, self._browse_styles, clearable=True,
tip="Оверлей md2gost.styles.json поверх пресета типа документа")
hint = ttk.Label(
frame,
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.",
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.\n"
"Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.",
style="Hint.TLabel",
wraplength=560,
)
hint.grid(row=3, column=0, columnspan=2, sticky="w", pady=(8, 0))
hint.grid(row=4, column=0, columnspan=2, sticky="w", pady=(8, 0))
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_files"))
return win
@@ -456,6 +468,7 @@ class Md2GostApp:
def _create_help_window(self, kind: str) -> tk.Toplevel:
titles = {
"usage": "Справка — Инструкция",
"docs": "Справка — Документация",
"schemes": "Справка — Схемы",
"prompts": "Справка — Промпт для ИИ",
}
@@ -463,12 +476,17 @@ class Md2GostApp:
win.title(titles.get(kind, "Справка"))
win.configure(bg=PAPER)
win.minsize(560, 400)
win.geometry("700x560")
if kind == "docs":
win.geometry("780x560")
else:
win.geometry("700x560")
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
if kind == "usage":
self._build_help(frame)
elif kind == "docs":
self._build_docs_browser(frame)
elif kind == "schemes":
self._build_schemes_help(frame)
else:
@@ -540,6 +558,60 @@ class Md2GostApp:
box = self._readonly_text(parent, USAGE_HELP.strip() + "\n")
box.pack(fill=tk.BOTH, expand=True)
def _build_docs_browser(self, parent) -> None:
ttk.Label(
parent,
text="Страницы слева; ссылки вида (file.md) в тексте открывайте тем же списком. "
"Markdown показывается как есть.",
style="Hint.TLabel",
wraplength=740,
).pack(anchor=tk.W, pady=(0, 8))
catalog = load_docs_catalog()
if not catalog:
ttk.Label(
parent,
text="Документация не найдена (папка docs/ рядом с программой или внутри сборки).",
style="Hint.TLabel",
).pack(anchor=tk.W)
return
self._docs_catalog = catalog
body = ttk.Frame(parent)
body.pack(fill=tk.BOTH, expand=True)
left = ttk.Frame(body)
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
self._docs_list = tk.Listbox(left, height=22, width=28, exportselection=False)
self._docs_list.pack(side=tk.TOP, fill=tk.BOTH, expand=True)
for _name, title, _text in catalog:
self._docs_list.insert(tk.END, title)
self._docs_list.bind("<<ListboxSelect>>", self._on_docs_select)
_tip(self._docs_list, "Разделы документации (docs/*.md)")
right = ttk.Frame(body)
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
self._docs_view = self._readonly_text(right, catalog[0][2])
self._docs_view.pack(fill=tk.BOTH, expand=True)
self._docs_list.selection_set(0)
self._docs_list.see(0)
def _on_docs_select(self, _event=None) -> None:
if not hasattr(self, "_docs_list") or not hasattr(self, "_docs_catalog"):
return
sel = self._docs_list.curselection()
if not sel:
return
idx = int(sel[0])
if idx < 0 or idx >= len(self._docs_catalog):
return
text = self._docs_catalog[idx][2]
self._docs_view.configure(state=tk.NORMAL)
self._docs_view.delete("1.0", tk.END)
self._docs_view.insert("1.0", text)
self._docs_view.configure(state=tk.DISABLED)
def _build_schemes_help(self, parent) -> None:
ttk.Label(
parent,
@@ -553,7 +625,9 @@ class Md2GostApp:
catalog = load_prompt_catalog()
ttk.Label(
parent,
text="Скопируйте промпт целиком и вставьте в ChatGPT / Cursor / Copilot. Следующим сообщением — тип, тема, черновик.",
text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. "
"Кнопки схем дописывают макросы из md2gost.schemes.json — "
"когда ИИ должен знать C4/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
style="Hint.TLabel",
wraplength=640,
).pack(anchor=tk.W, pady=(0, 8))
@@ -575,32 +649,113 @@ class Md2GostApp:
ttk.Label(row, text="Промпт").pack(side=tk.LEFT, padx=(0, 8))
box = ttk.Combobox(row, textvariable=self._prompt_choice, values=titles, state="readonly")
box.pack(side=tk.LEFT, fill=tk.X, expand=True)
box.bind("<<ComboboxSelected>>", lambda _e: self._show_selected_prompt())
box.bind("<<ComboboxSelected>>", lambda _e: self._refresh_composed_prompt())
btn = ttk.Button(row, text="Скопировать", command=self._copy_selected_prompt)
btn.pack(side=tk.LEFT, padx=(8, 0))
_tip(btn, "Скопировать выбранный промпт в буфер обмена")
_tip(btn, "Скопировать собранный промпт (база + выбранные схемы) в буфер")
self._prompt_view = self._readonly_text(parent, self._prompt_texts[titles[0]])
schemes_frame = ttk.LabelFrame(parent, text="Дописать схемы в промпт", padding=6)
schemes_frame.pack(fill=tk.X, pady=(0, 6))
ttk.Label(
schemes_frame,
text="UML/Mermaid и так конвертируются в Рисунок; схемы нужны для макросов.",
style="Hint.TLabel",
wraplength=620,
).pack(anchor=tk.W, pady=(0, 4))
try:
ensure_user_schemes()
schemes_map = get_schemes()
except Exception:
schemes_map = {}
self._prompt_scheme_ids = sorted(schemes_map.keys())
self._prompt_scheme_vars: dict[str, tk.BooleanVar] = {}
self._prompt_schemes_by_id = dict(schemes_map)
toggles = ttk.Frame(schemes_frame)
toggles.pack(fill=tk.X)
if not self._prompt_scheme_ids:
ttk.Label(
toggles,
text="Схемы не найдены (md2gost.schemes.json).",
style="Hint.TLabel",
).pack(anchor=tk.W)
else:
for sid in self._prompt_scheme_ids:
var = tk.BooleanVar(value=False)
self._prompt_scheme_vars[sid] = var
title = schemes_map[sid].title or sid
cb = ttk.Checkbutton(
toggles,
text=f"{title} ({sid})",
variable=var,
command=self._refresh_composed_prompt,
)
cb.pack(anchor=tk.W)
_tip(cb, f"Добавить в промпт макросы схемы ```uml-{sid}")
ctrl = ttk.Frame(schemes_frame)
ctrl.pack(fill=tk.X, pady=(4, 0))
btn_all = ttk.Button(ctrl, text="Все", command=self._prompt_schemes_select_all)
btn_all.pack(side=tk.LEFT, padx=(0, 6))
_tip(btn_all, "Включить все схемы")
btn_none = ttk.Button(ctrl, text="Сбросить", command=self._prompt_schemes_clear)
btn_none.pack(side=tk.LEFT)
_tip(btn_none, "Выключить все схемы")
self._prompt_view = self._readonly_text(parent, "")
self._prompt_view.pack(fill=tk.BOTH, expand=True)
self._copy_status = ttk.Label(parent, text="", style="Hint.TLabel")
self._copy_status.pack(anchor=tk.W, pady=(6, 0))
self._refresh_composed_prompt()
def _show_selected_prompt(self) -> None:
text = self._prompt_texts.get(self._prompt_choice.get(), "")
def _selected_prompt_schemes(self) -> list:
out = []
for sid in getattr(self, "_prompt_scheme_ids", []):
var = self._prompt_scheme_vars.get(sid)
if var is not None and var.get():
scheme = self._prompt_schemes_by_id.get(sid)
if scheme is not None:
out.append(scheme)
return out
def _composed_prompt_text(self) -> str:
base = self._prompt_texts.get(self._prompt_choice.get(), "")
return compose_prompt(base, self._selected_prompt_schemes())
def _refresh_composed_prompt(self) -> None:
if not hasattr(self, "_prompt_view"):
return
text = self._composed_prompt_text()
self._prompt_view.configure(state=tk.NORMAL)
self._prompt_view.delete("1.0", tk.END)
self._prompt_view.insert("1.0", text)
self._prompt_view.configure(state=tk.DISABLED)
self._copy_status.configure(text="")
if hasattr(self, "_copy_status"):
self._copy_status.configure(text="")
def _prompt_schemes_select_all(self) -> None:
for var in self._prompt_scheme_vars.values():
var.set(True)
self._refresh_composed_prompt()
def _prompt_schemes_clear(self) -> None:
for var in self._prompt_scheme_vars.values():
var.set(False)
self._refresh_composed_prompt()
def _copy_selected_prompt(self) -> None:
text = self._prompt_texts.get(self._prompt_choice.get(), "")
text = self._composed_prompt_text()
if not text:
return
self.root.clipboard_clear()
self.root.clipboard_append(text)
self.root.update_idletasks()
self._copy_status.configure(text="Скопировано в буфер обмена — можно вставлять в ИИ.")
n = len(self._selected_prompt_schemes())
extra = f" (+{n} схем)" if n else ""
self._copy_status.configure(
text=f"Скопировано в буфер обмена{extra} — можно вставлять в ИИ."
)
def _readonly_text(self, parent, content: str) -> tk.Text:
wrap = ttk.Frame(parent)
@@ -1065,6 +1220,9 @@ class Md2GostApp:
def _browse_assignment(self) -> None:
self._pick_into(self.assignment_var, "Бланк задания", [("Word", "*.docx")])
def _browse_styles(self) -> None:
self._pick_into(self.styles_var, "Стили JSON", [("JSON", "*.json"), ("Все файлы", "*.*")])
def _browse_jar(self) -> None:
self._pick_into(self.plantuml_var, "Свой plantuml.jar", [("JAR", "*.jar"), ("Все файлы", "*.*")])
self._refresh_diagram_status()
@@ -1149,6 +1307,7 @@ class Md2GostApp:
self.template_var.set(req.template or "")
self.title_var.set(req.title or "")
self.assignment_var.set(req.assignment or "")
self.styles_var.set(getattr(req, "styles_path", None) or "")
self.plantuml_var.set(req.plantuml_jar or "")
self.kroki_var.set(req.kroki_url or "")
self._refresh_diagram_status()
@@ -1181,6 +1340,7 @@ class Md2GostApp:
diagram_fallback=_combo_key(self.fallback_var.get(), ("remote", "local", "off"), "remote"),
diagram_format=_combo_key(self.format_var.get(), ("png", "svg"), "png"),
diagram_scale=self._parse_diagram_scale(),
styles_path=self.styles_var.get().strip() or None,
debug=self.debug_var.get(),
open_when_done=self.open_var.get(),
)
+111 -1
View File
@@ -33,6 +33,13 @@ CLI (тот же движок)
python -m md2gost --gui
md2gost.exe report.md --type PIS_custom --title title.docx
md2gost.exe report.md --schemes path/to/md2gost.schemes.json
md2gost.exe report.md --styles path/to/md2gost.styles.json
Стили JSON (опционально)
Оверлей поверх пресета типа документа (--type). Файл md2gost.styles.json рядом с .md
или --styles / Настройки → Файлы → «Стили JSON». Меняет поля страницы и параметры
стилей абзацев (Normal, Heading 13, подписи…). В шаблон DOCX стили руками добавлять не нужно.
Подробнее: docs/styles.md.
СИНТАКСИС MARKDOWN
@@ -126,7 +133,12 @@ IDEF0 конвертер не рисует — вставляйте готовы
off — не резать, Word сам переносит.
legacy / caption — режем по оценке высоты в md2gost (может не совпасть с Word).
Промпт для ИИ — Справка → Промпт для ИИ: скопируйте и вставьте в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
Промпт для ИИ — Справка → Промпт для ИИ: выберите промпт, при необходимости
включите схемы (C4, BPMN, …) кнопками — макросы допишутся в конец — скопируйте
в ChatGPT / Cursor / Copilot, затем дайте тему и черновик.
Документация — Справка → Документация: встроенный просмотр docs/*.md
(вшито в exe; внешняя папка docs/ не обязательна).
"""
SCHEMES_HELP = """СХЕМЫ ДИАГРАММ (PlantUML)
@@ -219,6 +231,7 @@ Mermaid: только Kroki (свой --kroki-url / localhost / kroki.io).
"""
PROMPT_FILES = (
("generate-md.md", "Markdown для md2gost"),
("generate-mirea-report.md", "МИРЭА / ГОСТ (курсовая, практика, ВКР)"),
("generate-pis-custom-report.md", "ПИС — отчёт по практическим работам"),
)
@@ -259,3 +272,100 @@ def load_prompt_catalog() -> list[tuple[str, str, str]]:
if text:
catalog.append((name, title, text))
return catalog
def scheme_prompt_block(scheme) -> str:
"""Format one DiagramScheme for appending to an AI prompt."""
sid = getattr(scheme, "id", "") or ""
title = (getattr(scheme, "title", None) or sid).strip()
lines = [
f"## Схема: {title} (`{sid}`)",
"",
f"Оградка в markdown: ```uml-{sid} или ```{sid}",
"",
]
ai = (getattr(scheme, "ai_prompt", None) or "").strip()
if ai:
lines.append(ai)
lines.append("")
docs = (getattr(scheme, "docs", None) or "").strip()
if docs:
lines.append("Макросы / шпаргалка:")
lines.append(docs)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def compose_prompt(base: str, schemes: list | None = None) -> str:
"""
Base prompt text plus optional scheme blocks (order preserved).
schemes: iterable of DiagramScheme (or objects with id/title/docs/ai_prompt).
"""
text = (base or "").rstrip()
if not schemes:
return text + ("\n" if text else "")
parts = [text, "", "---", "", "# Дополнение: выбранные схемы диаграмм", ""]
for scheme in schemes:
parts.append(scheme_prompt_block(scheme))
parts.append("")
return "\n".join(parts).rstrip() + "\n"
def docs_search_dirs() -> list[Path]:
dirs: list[Path] = []
here = Path(package_dir())
dirs.append(here / "docs")
dirs.append(here.parent / "docs")
if getattr(sys, "frozen", False):
mei = getattr(sys, "_MEIPASS", None)
if mei:
dirs.append(Path(mei) / "docs")
dirs.append(Path(sys.executable).resolve().parent / "docs")
seen: set[str] = set()
out: list[Path] = []
for path in dirs:
key = str(path.resolve()) if path.exists() else str(path)
if key in seen:
continue
seen.add(key)
out.append(path)
return out
def _doc_title_from_text(filename: str, text: str) -> str:
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip() or Path(filename).stem
return Path(filename).stem
def load_docs_catalog() -> list[tuple[str, str, str]]:
"""
Return list of (filename, title, text) from the first existing docs/ folder.
README.md first, then other *.md alphabetically.
"""
folder: Path | None = None
for path in docs_search_dirs():
if path.is_dir():
folder = path
break
if folder is None:
return []
files = sorted(p for p in folder.glob("*.md") if p.is_file())
if not files:
return []
readme = [p for p in files if p.name.lower() == "readme.md"]
rest = [p for p in files if p.name.lower() != "readme.md"]
ordered = readme + rest
catalog: list[tuple[str, str, str]] = []
for path in ordered:
try:
text = path.read_text(encoding="utf-8")
except OSError:
continue
catalog.append((path.name, _doc_title_from_text(path.name, text), text))
return catalog
+6 -4
View File
@@ -22,7 +22,6 @@ from .profiles import (
DEFAULT_TABLE_CONTINUATION,
DEFAULT_LISTING_CONTINUATION,
DEFAULT_TOC_MODE,
get_profile,
)
LogFn = Callable[[str], None]
@@ -52,6 +51,7 @@ class ConvertRequest:
diagram_format: str = "png"
diagram_scale: float = 2.0
schemes_path: str | None = None
styles_path: str | None = None
debug: bool = False
open_when_done: bool = False
check_pages: bool = False
@@ -219,6 +219,7 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
table_continuation=req.table_continuation,
listing_continuation=req.listing_continuation,
hr_pagebreak=req.hr_pagebreak,
styles_path=req.styles_path or None,
)
converter.convert()
document = converter.document
@@ -228,10 +229,11 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
from docxcompose.composer import Composer
except ImportError:
return fail(3, "Для титула/задания нужен пакет docxcompose", check_report)
from .styles import apply_document_styles
from .styles import apply_style_config
style_cfg = converter.style_config
shell = Document(template)
apply_document_styles(shell, get_profile(req.doc_type).style_preset)
apply_style_config(shell, style_cfg)
body = shell.element.body
for child in list(body):
if not child.tag.endswith("}sectPr"):
@@ -246,7 +248,7 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
shell.add_page_break()
composer.append(document)
document = composer.doc
apply_document_styles(document, get_profile(req.doc_type).style_preset)
apply_style_config(document, style_cfg)
_fix_front_matter_after_compose(
document,
had_title=bool(req.title),
+597
View File
@@ -0,0 +1,597 @@
"""JSON overlay for document styles (page margins + paragraph styles).
Layers (later wins, deep-merge of specified keys only):
1) built-in preset from DocProfile.style_preset (mirea | pis_custom)
2) md2gost.styles.json next to the .md (if present)
3) --styles path / GUI path
"""
from __future__ import annotations
import copy
import json
import logging
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any, Callable
_log = logging.getLogger(__name__)
STYLES_FILENAME = "md2gost.styles.json"
PAGE_KEYS = frozenset({"left_mm", "right_mm", "top_mm", "bottom_mm"})
STYLE_NAMES = frozenset({
"Normal",
"Heading 1",
"Heading 2",
"Heading 3",
"Caption Figure",
"Caption Table",
"Название таблицы",
"Caption Listing",
"Caption",
"Code",
"Table Text",
"Bibliography",
"Bibliography Heading",
"toc 1",
"toc 2",
"toc 3",
"Footer",
"Hyperlink",
"FollowedHyperlink",
"Space After Table",
})
ALIGNMENT_VALUES = frozenset({"left", "center", "justify"})
LINE_SPACING_VALUES = frozenset({1.0, 1.5})
STYLE_FIELD_KEYS = frozenset({
"font_name",
"size_pt",
"bold",
"italic",
"all_caps",
"underline",
"alignment",
"first_line_indent_cm",
"left_indent_cm",
"right_indent_cm",
"space_before_mm",
"space_after_mm",
"line_spacing",
"page_break_before",
"keep_with_next",
"widow_control",
})
TOP_LEVEL_KEYS = frozenset({"page", "styles"})
class StyleConfigError(ValueError):
"""Invalid styles JSON or unknown key/style name."""
@dataclass
class PageSpec:
left_mm: float | None = None
right_mm: float | None = None
top_mm: float | None = None
bottom_mm: float | None = None
def merge(self, overlay: "PageSpec | dict[str, Any] | None") -> "PageSpec":
if overlay is None:
return copy.copy(self)
if isinstance(overlay, PageSpec):
data = {
f.name: getattr(overlay, f.name)
for f in fields(overlay)
if getattr(overlay, f.name) is not None
}
else:
data = dict(overlay)
out = copy.copy(self)
for key, val in data.items():
if key not in PAGE_KEYS:
raise StyleConfigError(f"Unknown page key: {key!r}")
setattr(out, key, float(val))
return out
def to_dict(self) -> dict[str, float]:
out: dict[str, float] = {}
for f in fields(self):
val = getattr(self, f.name)
if val is not None:
out[f.name] = val
return out
def require_complete(self) -> "PageSpec":
"""Ensure all margins are set (for apply). Missing → GOST defaults."""
return PageSpec(
left_mm=30.0 if self.left_mm is None else self.left_mm,
right_mm=10.0 if self.right_mm is None else self.right_mm,
top_mm=20.0 if self.top_mm is None else self.top_mm,
bottom_mm=20.0 if self.bottom_mm is None else self.bottom_mm,
)
@dataclass
class ParagraphStyleSpec:
font_name: str | None = None
size_pt: float | None = None
bold: bool | None = None
italic: bool | None = None
all_caps: bool | None = None
underline: bool | None = None
alignment: str | None = None # left | center | justify
first_line_indent_cm: float | None = None
left_indent_cm: float | None = None
right_indent_cm: float | None = None
space_before_mm: float | None = None
space_after_mm: float | None = None
line_spacing: float | None = None # 1.0 | 1.5
page_break_before: bool | None = None
keep_with_next: bool | None = None
widow_control: bool | None = None
def merge(self, overlay: "ParagraphStyleSpec | dict[str, Any] | None") -> "ParagraphStyleSpec":
if overlay is None:
return copy.copy(self)
if isinstance(overlay, ParagraphStyleSpec):
data = {
f.name: getattr(overlay, f.name)
for f in fields(overlay)
if getattr(overlay, f.name) is not None
}
else:
data = dict(overlay)
out = copy.copy(self)
for key, val in data.items():
if key not in STYLE_FIELD_KEYS:
raise StyleConfigError(f"Unknown style field: {key!r}")
if val is None:
continue
if key == "alignment":
s = str(val).lower()
if s not in ALIGNMENT_VALUES:
raise StyleConfigError(
f"Invalid alignment {val!r}; expected one of {sorted(ALIGNMENT_VALUES)}"
)
setattr(out, key, s)
elif key == "line_spacing":
fval = float(val)
if fval not in LINE_SPACING_VALUES:
raise StyleConfigError(
f"Invalid line_spacing {val!r}; expected 1.0 or 1.5"
)
setattr(out, key, fval)
elif key in ("font_name",):
setattr(out, key, str(val))
elif key in ("size_pt", "first_line_indent_cm", "left_indent_cm",
"right_indent_cm", "space_before_mm", "space_after_mm"):
setattr(out, key, float(val))
elif key in ("bold", "italic", "all_caps", "underline",
"page_break_before", "keep_with_next", "widow_control"):
setattr(out, key, bool(val))
else:
setattr(out, key, val)
return out
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {}
for f in fields(self):
val = getattr(self, f.name)
if val is not None:
out[f.name] = val
return out
@dataclass
class StyleConfig:
page: PageSpec = field(default_factory=PageSpec)
styles: dict[str, ParagraphStyleSpec] = field(default_factory=dict)
def merge(self, overlay: "StyleConfig | None") -> "StyleConfig":
if overlay is None:
return copy.deepcopy(self)
page = self.page.merge(overlay.page)
styles = {name: copy.copy(spec) for name, spec in self.styles.items()}
for name, spec in overlay.styles.items():
if name not in STYLE_NAMES:
raise StyleConfigError(f"Unknown style name: {name!r}")
if name in styles:
styles[name] = styles[name].merge(spec)
else:
styles[name] = copy.copy(spec)
return StyleConfig(page=page, styles=styles)
def to_dict(self) -> dict[str, Any]:
return {
"page": self.page.to_dict(),
"styles": {name: spec.to_dict() for name, spec in self.styles.items()},
}
def _ps(**kwargs) -> ParagraphStyleSpec:
return ParagraphStyleSpec(**kwargs)
def _common_body_and_misc() -> dict[str, ParagraphStyleSpec]:
"""Shared Normal + captions/misc (identical for mirea and pis_custom)."""
return {
"Normal": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
italic=False,
alignment="justify",
first_line_indent_cm=1.25,
left_indent_cm=0,
right_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.5,
widow_control=True,
),
"Caption Figure": _ps(
font_name="Times New Roman",
size_pt=12,
bold=True,
italic=False,
alignment="center",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=0,
space_after_mm=6,
line_spacing=1.0,
widow_control=True,
),
"Caption Table": _ps(
font_name="Times New Roman",
size_pt=12,
bold=False,
italic=True,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=6,
space_after_mm=0,
line_spacing=1.0,
keep_with_next=True,
widow_control=True,
),
"Название таблицы": _ps(
font_name="Times New Roman",
size_pt=12,
bold=False,
italic=True,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=6,
space_after_mm=0,
line_spacing=1.0,
keep_with_next=True,
widow_control=True,
),
"Caption Listing": _ps(
font_name="Times New Roman",
size_pt=12,
bold=False,
italic=True,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=6,
space_after_mm=0,
line_spacing=1.0,
keep_with_next=True,
widow_control=True,
),
"Caption": _ps(
font_name="Times New Roman",
size_pt=12,
bold=True,
italic=False,
alignment="center",
first_line_indent_cm=0,
space_before_mm=0,
space_after_mm=6,
line_spacing=1.0,
),
"Code": _ps(
font_name="Courier New",
size_pt=10,
bold=False,
italic=False,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.0,
),
"Table Text": _ps(
font_name="Times New Roman",
size_pt=12,
bold=False,
italic=False,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.0,
),
"Bibliography": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
italic=False,
alignment="justify",
first_line_indent_cm=1.25,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.5,
),
"Bibliography Heading": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
italic=False,
all_caps=True,
alignment="center",
first_line_indent_cm=0,
left_indent_cm=1.25,
space_before_mm=6,
space_after_mm=6,
line_spacing=1.5,
keep_with_next=True,
),
"toc 1": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
all_caps=True,
alignment="left",
first_line_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.5,
),
"toc 2": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
all_caps=False,
alignment="left",
first_line_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.5,
),
"toc 3": _ps(
font_name="Times New Roman",
size_pt=14,
bold=False,
all_caps=False,
alignment="left",
first_line_indent_cm=0,
space_before_mm=0,
space_after_mm=0,
line_spacing=1.5,
),
"Footer": _ps(
font_name="Times New Roman",
size_pt=12,
alignment="center",
first_line_indent_cm=0,
),
"Hyperlink": _ps(
font_name="Times New Roman",
size_pt=14,
underline=False,
),
"FollowedHyperlink": _ps(
font_name="Times New Roman",
size_pt=14,
underline=False,
),
"Space After Table": _ps(
first_line_indent_cm=1.25,
space_before_mm=6,
space_after_mm=0,
line_spacing=1.5,
),
}
def preset_mirea() -> StyleConfig:
styles = _common_body_and_misc()
styles["Heading 1"] = _ps(
font_name="Times New Roman",
size_pt=18,
bold=True,
all_caps=True,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=1.25,
right_indent_cm=0,
space_before_mm=0,
space_after_mm=10,
line_spacing=1.5,
page_break_before=True,
keep_with_next=True,
widow_control=True,
)
styles["Heading 2"] = _ps(
font_name="Times New Roman",
size_pt=16,
bold=True,
all_caps=False,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=1.25,
right_indent_cm=0,
space_before_mm=15,
space_after_mm=10,
line_spacing=1.5,
page_break_before=False,
keep_with_next=True,
widow_control=True,
)
styles["Heading 3"] = _ps(
font_name="Times New Roman",
size_pt=14,
bold=True,
all_caps=False,
alignment="left",
first_line_indent_cm=0,
left_indent_cm=1.25,
right_indent_cm=0,
space_before_mm=15,
space_after_mm=10,
line_spacing=1.5,
page_break_before=False,
keep_with_next=True,
widow_control=True,
)
return StyleConfig(
page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20),
styles=styles,
)
def preset_pis_custom() -> StyleConfig:
styles = _common_body_and_misc()
styles["Heading 1"] = _ps(
font_name="Times New Roman",
size_pt=14,
bold=True,
all_caps=True,
alignment="center",
first_line_indent_cm=0,
left_indent_cm=0,
right_indent_cm=0,
space_before_mm=0,
space_after_mm=10,
line_spacing=1.5,
page_break_before=True,
keep_with_next=True,
widow_control=True,
)
styles["Heading 2"] = _ps(
font_name="Times New Roman",
size_pt=14,
bold=True,
all_caps=False,
alignment="justify",
first_line_indent_cm=1.25,
left_indent_cm=0,
right_indent_cm=0,
space_before_mm=15,
space_after_mm=10,
line_spacing=1.5,
page_break_before=False,
keep_with_next=True,
widow_control=True,
)
styles["Heading 3"] = _ps(
font_name="Times New Roman",
size_pt=14,
bold=True,
all_caps=False,
alignment="justify",
first_line_indent_cm=1.25,
left_indent_cm=0,
right_indent_cm=0,
space_before_mm=10,
space_after_mm=10,
line_spacing=1.5,
page_break_before=False,
keep_with_next=True,
widow_control=True,
)
return StyleConfig(
page=PageSpec(left_mm=30, right_mm=10, top_mm=20, bottom_mm=20),
styles=styles,
)
PRESETS: dict[str, Callable[[], StyleConfig]] = {
"mirea": preset_mirea,
"pis_custom": preset_pis_custom,
}
def get_preset(name: str) -> StyleConfig:
factory = PRESETS.get(name, preset_mirea)
return factory()
def style_config_from_dict(data: dict[str, Any]) -> StyleConfig:
if not isinstance(data, dict):
raise StyleConfigError("Styles JSON root must be an object")
unknown = set(data.keys()) - TOP_LEVEL_KEYS
if unknown:
raise StyleConfigError(f"Unknown top-level key(s): {sorted(unknown)}")
page = PageSpec()
if "page" in data:
raw_page = data["page"]
if not isinstance(raw_page, dict):
raise StyleConfigError("'page' must be an object")
page = PageSpec().merge(raw_page)
styles: dict[str, ParagraphStyleSpec] = {}
if "styles" in data:
raw_styles = data["styles"]
if not isinstance(raw_styles, dict):
raise StyleConfigError("'styles' must be an object")
for name, raw_spec in raw_styles.items():
if name not in STYLE_NAMES:
raise StyleConfigError(f"Unknown style name: {name!r}")
if not isinstance(raw_spec, dict):
raise StyleConfigError(f"Style {name!r} must be an object")
styles[name] = ParagraphStyleSpec().merge(raw_spec)
return StyleConfig(page=page, styles=styles)
def load_styles_file(path: Path) -> StyleConfig:
if not path.is_file():
raise StyleConfigError(f"Styles file not found: {path}")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise StyleConfigError(f"Cannot read styles {path}: {exc}") from exc
if not isinstance(raw, dict):
raise StyleConfigError(f"Styles JSON root must be an object: {path}")
return style_config_from_dict(raw)
def resolve_style_config(
style_preset: str,
*,
md_dir: str | Path | None = None,
styles_path: str | Path | None = None,
) -> StyleConfig:
"""Merge preset ← near-md file ← explicit --styles path."""
config = get_preset(style_preset)
if md_dir:
near = Path(md_dir) / STYLES_FILENAME
if near.is_file():
try:
config = config.merge(load_styles_file(near))
_log.info("Applied styles overlay: %s", near)
except StyleConfigError:
raise
if styles_path:
path = Path(styles_path)
config = config.merge(load_styles_file(path))
_log.info("Applied styles overlay: %s", path)
return config
+156 -230
View File
@@ -1,5 +1,7 @@
"""Apply MIREA TZ (GOST 7.32 / методичка 2022) paragraph styles to a Document."""
from __future__ import annotations
from docx.document import Document
from docx.enum.style import WD_STYLE_TYPE
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING, WD_TAB_ALIGNMENT, WD_TAB_LEADER
@@ -7,6 +9,32 @@ from docx.oxml.ns import qn
from docx.shared import Cm, Mm, Pt
from docx.styles.style import _ParagraphStyle as ParagraphStyle
from .style_config import (
ParagraphStyleSpec,
StyleConfig,
get_preset,
resolve_style_config,
)
_ALIGN = {
"left": WD_ALIGN_PARAGRAPH.LEFT,
"center": WD_ALIGN_PARAGRAPH.CENTER,
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
}
# Styles that may be missing from Template.docx and need ensure + base
_ENSURE_BASE = {
"Caption Figure": "Caption",
"Caption Table": "Caption",
"Название таблицы": "Caption Table",
"Caption Listing": "Caption",
"Code": "Normal",
"Table Text": "Normal",
"Bibliography": "Normal",
"Bibliography Heading": "Normal",
"Space After Table": "Normal",
}
def _set_run_font(style: ParagraphStyle, name: str, size_pt: float, bold: bool = False, italic: bool = False):
"""Lock typeface/size/color so Word theme (Calibri + accent blue) cannot leak."""
@@ -55,6 +83,18 @@ def _ensure_style(document: Document, name: str, base: str = "Normal") -> Paragr
return style
def _get_or_ensure(document: Document, name: str) -> ParagraphStyle | None:
base = _ENSURE_BASE.get(name)
if base is not None:
return _ensure_style(document, name, base)
try:
return document.styles[name]
except KeyError:
if name.startswith("toc "):
return _ensure_style(document, name, "Normal")
return None
def _clear_tab_stops(style: ParagraphStyle) -> None:
pPr = style.element.get_or_add_pPr()
tabs = pPr.find(qn("w:tabs"))
@@ -88,249 +128,135 @@ def _fix_toc_tab_stops(document: Document) -> None:
)
def apply_mirea_styles(document: Document) -> None:
"""Mutate section margins and key paragraph styles to match the MIREA method guide."""
_apply_common_page_and_body(document)
def _apply_paragraph_spec(style, spec: ParagraphStyleSpec) -> None:
need_font = (
spec.font_name is not None
or spec.size_pt is not None
or spec.bold is not None
or spec.italic is not None
)
if need_font:
name = spec.font_name if spec.font_name is not None else (style.font.name or "Times New Roman")
size = spec.size_pt if spec.size_pt is not None else (
style.font.size.pt if style.font.size else 14
)
bold = bool(spec.bold) if spec.bold is not None else bool(style.font.bold)
italic = bool(spec.italic) if spec.italic is not None else bool(style.font.italic)
_set_run_font(style, name, size, bold=bold, italic=italic)
# --- Headings (табл. 2.1): слева с отступом 1,25 см ---
heading_specs = [
(1, 18, Mm(0), Mm(10), True),
(2, 16, Mm(15), Mm(10), False),
(3, 14, Mm(15), Mm(10), False),
]
for level, size, before, after, page_break in heading_specs:
style: ParagraphStyle = document.styles[f"Heading {level}"]
_set_run_font(style, "Times New Roman", size, bold=True)
if level == 1:
style.font.all_caps = True
if spec.all_caps is not None:
style.font.all_caps = spec.all_caps
if spec.underline is not None:
style.font.underline = spec.underline
# Character styles (Hyperlink, …) have no paragraph_format
if style.type != WD_STYLE_TYPE.PARAGRAPH:
return
pf = style.paragraph_format
if spec.alignment is not None:
pf.alignment = _ALIGN[spec.alignment]
if spec.first_line_indent_cm is not None:
pf.first_line_indent = Cm(spec.first_line_indent_cm)
if spec.left_indent_cm is not None:
pf.left_indent = Cm(spec.left_indent_cm)
if spec.right_indent_cm is not None:
pf.right_indent = Cm(spec.right_indent_cm)
if spec.space_before_mm is not None:
pf.space_before = Mm(spec.space_before_mm)
if spec.space_after_mm is not None:
pf.space_after = Mm(spec.space_after_mm)
if spec.line_spacing is not None:
if spec.line_spacing == 1.5:
pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
else:
style.font.all_caps = False
hpf = style.paragraph_format
hpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
hpf.first_line_indent = Cm(0)
hpf.left_indent = Cm(1.25)
hpf.right_indent = Cm(0)
hpf.space_before = before
hpf.space_after = after
hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
hpf.page_break_before = page_break
hpf.keep_with_next = True
hpf.widow_control = True
_apply_common_captions_and_misc(document)
pf.line_spacing_rule = WD_LINE_SPACING.SINGLE
if spec.page_break_before is not None:
pf.page_break_before = spec.page_break_before
if spec.keep_with_next is not None:
pf.keep_with_next = spec.keep_with_next
if spec.widow_control is not None:
pf.widow_control = spec.widow_control
def apply_pis_custom_styles(document: Document) -> None:
"""Styles for PIS_custom: итоговый отчёт по практическим работам.
def _apply_page_margins(config: StyleConfig) -> None:
from . import page_geometry as pg
* H1 (разделы / практические работы): по центру, ПРОПИСНЫЕ, без точки.
* H2 (подразделы): с абзацного отступа 1,25 см, с прописной буквы.
* Поля / шрифт / интервал / красная строка как в чек-листе ПИС (= ГОСТ поля).
"""
_apply_common_page_and_body(document)
# H1 — раздел: центр, caps, с новой страницы
h1: ParagraphStyle = document.styles["Heading 1"]
_set_run_font(h1, "Times New Roman", 14, bold=True)
h1.font.all_caps = True
h1pf = h1.paragraph_format
h1pf.alignment = WD_ALIGN_PARAGRAPH.CENTER
h1pf.first_line_indent = Cm(0)
h1pf.left_indent = Cm(0)
h1pf.right_indent = Cm(0)
h1pf.space_before = Mm(0)
h1pf.space_after = Mm(10)
h1pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
h1pf.page_break_before = True
h1pf.keep_with_next = True
h1pf.widow_control = True
# H2 / H3 — подразделы: абзацный отступ, не caps
for level, size, before in ((2, 14, Mm(15)), (3, 14, Mm(10))):
style = document.styles[f"Heading {level}"]
_set_run_font(style, "Times New Roman", size, bold=True)
style.font.all_caps = False
hpf = style.paragraph_format
hpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
hpf.first_line_indent = Cm(1.25)
hpf.left_indent = Cm(0)
hpf.right_indent = Cm(0)
hpf.space_before = before
hpf.space_after = Mm(10)
hpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
hpf.page_break_before = False
hpf.keep_with_next = True
hpf.widow_control = True
_apply_common_captions_and_misc(document)
page = config.page.require_complete()
pg.MARGIN_LEFT = Mm(page.left_mm)
pg.MARGIN_RIGHT = Mm(page.right_mm)
pg.MARGIN_TOP = Mm(page.top_mm)
pg.MARGIN_BOTTOM = Mm(page.bottom_mm)
def apply_document_styles(document: Document, style_preset: str = "mirea") -> None:
if style_preset == "pis_custom":
apply_pis_custom_styles(document)
else:
apply_mirea_styles(document)
def _apply_common_page_and_body(document: Document) -> None:
def apply_style_config(document: Document, config: StyleConfig) -> None:
"""Apply full StyleConfig (page margins + paragraph styles) to document."""
from .page_geometry import apply_section_geometry, is_landscape_section
_apply_page_margins(config)
for section in document.sections:
# Do not wipe landscape sections created for +landscape figures/tables.
apply_section_geometry(section, landscape=is_landscape_section(section))
_fix_toc_tab_stops(document)
normal: ParagraphStyle = document.styles["Normal"]
_set_run_font(normal, "Times New Roman", 14)
pf = normal.paragraph_format
pf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
pf.first_line_indent = Cm(1.25)
pf.left_indent = Cm(0)
pf.right_indent = Cm(0)
pf.space_before = Pt(0)
pf.space_after = Pt(0)
pf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
pf.widow_control = True
def _apply_common_captions_and_misc(document: Document) -> None:
# --- Caption Figure: 12pt bold, center, under figure ---
caption_fig = _ensure_style(document, "Caption Figure", "Caption")
_set_run_font(caption_fig, "Times New Roman", 12, bold=True)
cpf = caption_fig.paragraph_format
cpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
cpf.first_line_indent = Cm(0)
cpf.left_indent = Cm(0)
cpf.space_before = Mm(0)
cpf.space_after = Mm(6)
cpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
cpf.widow_control = True
# --- Caption Table (legacy EN name) + «Название таблицы» (основной) ---
caption_tbl = _ensure_style(document, "Caption Table", "Caption")
_set_run_font(caption_tbl, "Times New Roman", 12, italic=True)
tpf = caption_tbl.paragraph_format
tpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
tpf.first_line_indent = Cm(0)
tpf.left_indent = Cm(0)
tpf.space_before = Mm(6)
tpf.space_after = Mm(0)
tpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
tpf.keep_with_next = True
tpf.widow_control = True
# ГОСТ-имя стиля подписи таблицы (те же параметры, что Caption Table)
caption_tbl_ru = _ensure_style(document, "Название таблицы", "Caption Table")
_set_run_font(caption_tbl_ru, "Times New Roman", 12, italic=True)
tpf_ru = caption_tbl_ru.paragraph_format
tpf_ru.alignment = WD_ALIGN_PARAGRAPH.LEFT
tpf_ru.first_line_indent = Cm(0)
tpf_ru.left_indent = Cm(0)
tpf_ru.space_before = Mm(6)
tpf_ru.space_after = Mm(0)
tpf_ru.line_spacing_rule = WD_LINE_SPACING.SINGLE
tpf_ru.keep_with_next = True
tpf_ru.widow_control = True
# --- Caption Listing (как таблицы) ---
caption_lst = _ensure_style(document, "Caption Listing", "Caption")
_set_run_font(caption_lst, "Times New Roman", 12, italic=True)
lpf = caption_lst.paragraph_format
lpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
lpf.first_line_indent = Cm(0)
lpf.left_indent = Cm(0)
lpf.space_before = Mm(6)
lpf.space_after = Mm(0)
lpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
lpf.keep_with_next = True
lpf.widow_control = True
caption = document.styles["Caption"]
_set_run_font(caption, "Times New Roman", 12, bold=True)
capf = caption.paragraph_format
capf.alignment = WD_ALIGN_PARAGRAPH.CENTER
capf.first_line_indent = Cm(0)
capf.space_before = Mm(0)
capf.space_after = Mm(6)
capf.line_spacing_rule = WD_LINE_SPACING.SINGLE
code = _ensure_style(document, "Code", "Normal")
_set_run_font(code, "Courier New", 10)
cdpf = code.paragraph_format
cdpf.alignment = WD_ALIGN_PARAGRAPH.LEFT
cdpf.first_line_indent = Cm(0)
cdpf.left_indent = Cm(0)
cdpf.space_before = Mm(0)
cdpf.space_after = Mm(0)
cdpf.line_spacing_rule = WD_LINE_SPACING.SINGLE
table_text = _ensure_style(document, "Table Text", "Normal")
_set_run_font(table_text, "Times New Roman", 12)
ttf = table_text.paragraph_format
ttf.alignment = WD_ALIGN_PARAGRAPH.LEFT
ttf.first_line_indent = Cm(0)
ttf.left_indent = Cm(0)
ttf.space_before = Mm(0)
ttf.space_after = Mm(0)
ttf.line_spacing_rule = WD_LINE_SPACING.SINGLE
biblio = _ensure_style(document, "Bibliography", "Normal")
_set_run_font(biblio, "Times New Roman", 14)
bpf = biblio.paragraph_format
bpf.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
bpf.first_line_indent = Cm(1.25)
bpf.space_before = Pt(0)
bpf.space_after = Pt(0)
bpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
biblio_h = _ensure_style(document, "Bibliography Heading", "Normal")
_set_run_font(biblio_h, "Times New Roman", 14)
biblio_h.font.all_caps = True
bhpf = biblio_h.paragraph_format
bhpf.alignment = WD_ALIGN_PARAGRAPH.CENTER
bhpf.first_line_indent = Cm(0)
bhpf.left_indent = Cm(1.25) # табл. 5.1
bhpf.space_before = Mm(6)
bhpf.space_after = Mm(6)
bhpf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
bhpf.keep_with_next = True
# TOC styles: TNR 14, 1.5, no bold, no first-line indent; toc 1 = ALL CAPS
for toc_name, all_caps in (("toc 1", True), ("toc 2", False), ("toc 3", False)):
try:
toc_style = document.styles[toc_name]
except KeyError:
toc_style = _ensure_style(document, toc_name, "Normal")
_set_run_font(toc_style, "Times New Roman", 14, bold=False)
toc_style.font.all_caps = all_caps
toc_style.font.bold = False
tpf_toc = toc_style.paragraph_format
tpf_toc.alignment = WD_ALIGN_PARAGRAPH.LEFT
tpf_toc.first_line_indent = Cm(0)
tpf_toc.space_before = Pt(0)
tpf_toc.space_after = Pt(0)
tpf_toc.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
try:
footer_style = document.styles["Footer"]
_set_run_font(footer_style, "Times New Roman", 12)
footer_style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
footer_style.paragraph_format.first_line_indent = Cm(0)
except KeyError:
pass
for hyper_name in ("Hyperlink", "FollowedHyperlink"):
try:
hyper = document.styles[hyper_name]
except KeyError:
# Apply in a stable order: Normal first, then headings, then the rest
order = [
"Normal",
"Heading 1", "Heading 2", "Heading 3",
"Caption Figure", "Caption Table", "Название таблицы", "Caption Listing", "Caption",
"Code", "Table Text",
"Bibliography", "Bibliography Heading",
"toc 1", "toc 2", "toc 3",
"Footer", "Hyperlink", "FollowedHyperlink",
"Space After Table",
]
applied = set()
for name in order:
spec = config.styles.get(name)
if spec is None:
continue
_set_run_font(hyper, "Times New Roman", 14)
hyper.font.underline = False
style = _get_or_ensure(document, name)
if style is None:
continue
_apply_paragraph_spec(style, spec)
applied.add(name)
after = _ensure_style(document, "Space After Table", "Normal")
apf = after.paragraph_format
apf.space_before = Mm(6)
apf.space_after = Pt(0)
apf.first_line_indent = Cm(1.25)
apf.line_spacing_rule = WD_LINE_SPACING.ONE_POINT_FIVE
for name, spec in config.styles.items():
if name in applied:
continue
style = _get_or_ensure(document, name)
if style is None:
continue
_apply_paragraph_spec(style, spec)
def apply_mirea_styles(document: Document) -> None:
"""Mutate section margins and key paragraph styles to match the MIREA method guide."""
apply_style_config(document, get_preset("mirea"))
def apply_pis_custom_styles(document: Document) -> None:
"""Styles for PIS_custom: итоговый отчёт по практическим работам."""
apply_style_config(document, get_preset("pis_custom"))
def apply_document_styles(
document: Document,
style_preset: str = "mirea",
*,
overlay: StyleConfig | None = None,
md_dir: str | None = None,
styles_path: str | None = None,
) -> StyleConfig:
"""Apply preset (+ optional JSON overlays). Returns the resolved StyleConfig."""
if overlay is not None:
config = get_preset(style_preset).merge(overlay)
else:
config = resolve_style_config(
style_preset,
md_dir=md_dir,
styles_path=styles_path,
)
apply_style_config(document, config)
return config