Files
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

1889 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tkinter GUI for md2gost: drop a .md, pick options, convert."""
from __future__ import annotations
import os
import sys
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from .diagram_includes import cache_entry_count, reset_include_cache
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,
reload_schemes,
save_schemes,
user_schemes_path,
)
from .dnd import enable_file_drop, first_docx, first_markdown
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,
DEFAULT_TABLE_CONTINUATION,
DEFAULT_LISTING_CONTINUATION,
DEFAULT_TOC_MODE,
DOC_TYPES,
HEADING_NUMBERING_MODES,
TABLE_CONTINUATION_MODES,
LISTING_CONTINUATION_MODES,
TOC_MODES,
)
DOC_TYPE_LABELS = {
"coursework": "Курсовая (ГОСТ / МИРЭА)",
"practice": "Практика",
"vkr": "ВКР",
"PIS_custom": "Отчёт по практическим (ПИС)",
"APID_coursework": "Курсовая АПИД",
}
HEADING_LABELS = {
"manual": "Из markdown (цифры уже в заголовках)",
"auto": "Автонумерация Word",
}
TOC_LABELS = {
"native": "Поле Word (обновить при открытии)",
"manual": "Собрать в md2gost",
}
TABLE_LABELS = {
"off": "Не резать",
"soft": "Как «не резать»",
"word": "Через Word после сборки (по умолчанию)",
"legacy": "Резать + «Продолжение»",
"caption": "Резать + разрыв страницы + «Продолжение»",
}
FALLBACK_LABELS = {
"remote": "Авто: локально, иначе интернет (kroki.io)",
"local": "Только этот компьютер (без kroki.io)",
"off": "Не рисовать диаграммы",
}
FORMAT_LABELS = {
"png": "PNG (растр, совместимо везде)",
"svg": "SVG + PNG (вектор в Word 2016+)",
}
NAVY = "#1e3a5f"
NAVY_DIM = "#4a6583"
PAPER = "#f6f3ec"
ZONE = "#e8eef5"
ZONE_OK = "#dceee3"
INK = "#243044"
MUTED = "#5c6b7a"
TIP_BG = "#ffffe1"
def _make_root() -> tuple[tk.Tk, str]:
try:
from tkinterdnd2 import TkinterDnD
return TkinterDnD.Tk(), "tkdnd"
except ImportError:
return tk.Tk(), "tk"
def _combo_values(mapping: dict[str, str], keys: tuple[str, ...]) -> list[str]:
return [f"{key}{mapping.get(key, key)}" for key in keys]
def _combo_key(display: str, allowed: tuple[str, ...], default: str) -> str:
key = (display or "").split("", 1)[0].strip()
return key if key in allowed else default
def _display(mapping: dict[str, str], key: str) -> str:
return f"{key}{mapping.get(key, key)}"
class _Tooltip:
"""Simple balloon tip on hover (Tk has no built-in tooltip)."""
def __init__(self, widget, text: str, delay_ms: int = 400):
self.widget = widget
self.text = text
self.delay_ms = delay_ms
self._after_id: str | None = None
self._tip: tk.Toplevel | None = None
widget.bind("<Enter>", self._on_enter, add="+")
widget.bind("<Leave>", self._on_leave, add="+")
widget.bind("<ButtonPress>", self._on_leave, add="+")
def _on_enter(self, _event=None) -> None:
self._cancel()
self._after_id = self.widget.after(self.delay_ms, self._show)
def _on_leave(self, _event=None) -> None:
self._cancel()
self._hide()
def _cancel(self) -> None:
if self._after_id is not None:
try:
self.widget.after_cancel(self._after_id)
except tk.TclError:
pass
self._after_id = None
def _show(self) -> None:
self._after_id = None
if self._tip is not None or not self.text:
return
try:
x = self.widget.winfo_rootx() + 16
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
except tk.TclError:
return
tip = tk.Toplevel(self.widget)
tip.wm_overrideredirect(True)
tip.wm_geometry(f"+{x}+{y}")
try:
tip.attributes("-topmost", True)
except tk.TclError:
pass
label = tk.Label(
tip,
text=self.text,
justify=tk.LEFT,
background=TIP_BG,
foreground=INK,
relief=tk.SOLID,
borderwidth=1,
padx=6,
pady=4,
font=("Segoe UI", 9) if os.name == "nt" else ("Helvetica", 9),
wraplength=320,
)
label.pack()
self._tip = tip
def _hide(self) -> None:
if self._tip is not None:
try:
self._tip.destroy()
except tk.TclError:
pass
self._tip = None
def _tip(widget, text: str) -> None:
if text:
_Tooltip(widget, text)
class Md2GostApp:
def __init__(self, initial: ConvertRequest | None = None):
self.req = initial or ConvertRequest()
self.root, _ = _make_root()
self.root.title("md2gost — Markdown → DOCX")
self.root.minsize(680, 560)
self.root.geometry("780x700")
self.root.configure(bg=PAPER)
self._busy = False
self._auto_output = True
self._dnd_backend = "none"
self._last_md = ""
self._schemes: dict[str, DiagramScheme] = {}
self._scheme_ids: list[str] = []
self._scheme_selecting = False
self._debug_next = False
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()
self._build_style()
self._init_vars()
self._build_menu()
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))
def _build_style(self) -> None:
style = ttk.Style(self.root)
try:
style.theme_use("vista" if os.name == "nt" else "clam")
except tk.TclError:
pass
font = ("Segoe UI", 10) if os.name == "nt" else ("Helvetica", 10)
font_title = ("Segoe UI", 16, "bold") if os.name == "nt" else ("Helvetica", 16, "bold")
style.configure(".", background=PAPER, foreground=INK, font=font)
style.configure("TFrame", background=PAPER)
style.configure("TLabelframe", background=PAPER, foreground=NAVY)
style.configure("TLabelframe.Label", background=PAPER, foreground=NAVY, font=font)
style.configure("TLabel", background=PAPER, foreground=INK, font=font)
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)
def _init_vars(self) -> None:
self.output_var = tk.StringVar()
self.type_var = tk.StringVar()
self.heading_var = tk.StringVar()
self.toc_var = tk.StringVar()
self.table_var = tk.StringVar()
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)
self.strict_var = tk.BooleanVar(value=False)
self.check_pages_var = tk.BooleanVar(value=False)
self.table_repeat_header_var = tk.BooleanVar(value=False)
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()
self.kroki_var = tk.StringVar()
self.scale_var = tk.StringVar(value="2")
self.page_start_var = tk.StringVar(value="")
self.highlight_var = tk.BooleanVar(value=False)
self.debug_var = tk.BooleanVar(value=False)
self.output_var.trace_add("write", self._on_output_edited)
def _build_menu(self) -> None:
# tearoff=0 обязателен: иначе index 0 — пунктир, и «Дебаг» перезаписывает «Справку»
menubar = tk.Menu(self.root, tearoff=0)
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)
menubar.add_command(label="Шаблоны UML", command=self._open_schemes_window)
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)
menubar.add_command(label="Дебаг", command=self._toggle_debug)
self._debug_menu_index = int(menubar.index("end"))
self._menubar = menubar
self.root.config(menu=menubar)
def _raise_or_create(self, existing: tk.Toplevel | None, factory) -> tk.Toplevel:
if existing is not None:
try:
if existing.winfo_exists():
existing.deiconify()
existing.lift()
existing.focus_force()
return existing
except tk.TclError:
pass
return factory()
def _open_files_window(self) -> None:
self._win_files = self._raise_or_create(self._win_files, self._create_files_window)
def _open_diagrams_window(self) -> None:
self._win_diagrams = self._raise_or_create(self._win_diagrams, self._create_diagrams_window)
def _open_schemes_window(self) -> None:
self._win_schemes = self._raise_or_create(self._win_schemes, self._create_schemes_window)
def _open_help_window(self, kind: str) -> None:
win = self._win_help.get(kind)
if win is not None:
try:
if win.winfo_exists():
win.deiconify()
win.lift()
win.focus_force()
return
except tk.TclError:
pass
self._win_help[kind] = self._create_help_window(kind)
def _create_files_window(self) -> tk.Toplevel:
win = tk.Toplevel(self.root)
win.title("Настройки — Файлы")
win.configure(bg=PAPER)
win.minsize(520, 240)
win.geometry("620x280")
win.transient(self.root)
frame = ttk.Frame(win, padding=12)
frame.pack(fill=tk.BOTH, expand=True)
frame.columnconfigure(1, weight=1)
self._row_path(frame, 0, "Шаблон DOCX", self.template_var, self._browse_template, clearable=True,
tip="Пусто — встроенный Template.docx")
self._row_path(frame, 1, "Титульный лист", self.title_var, self._browse_title, clearable=True,
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. Титул и задание вставляются перед телом отчёта.\n"
"Автотитул (info_conv.yaml / ```title) — галочка «Автотитул» на главном окне "
"(по умолчанию выкл.; ФИО/группа — Настройки → Студент…).\n"
"Стили JSON — опционально; также подхватывается md2gost.styles.json рядом с .md.",
style="Hint.TLabel",
wraplength=560,
)
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
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("Настройки — Диаграммы")
win.configure(bg=PAPER)
win.minsize(560, 480)
win.geometry("700x600")
win.transient(self.root)
extra = ttk.Frame(win, padding=12)
extra.pack(fill=tk.BOTH, expand=True)
extra.columnconfigure(1, weight=1)
hint = ttk.Label(
extra,
text=(
"Блоки ```uml / ```uml-c4 / ```mermaid / ```idef0 / ```dfd сами станут рисунками.\n"
"Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n"
"UML: PlantUML + Java, иначе Kroki. Mermaid: системный браузер → "
"Chromium → QuickJS → Kroki. IDEF0 — Pillow. DFD — Graphviz / Kroki."
),
style="Hint.TLabel",
wraplength=640,
justify=tk.LEFT,
)
hint.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 8))
status_row = ttk.Frame(extra)
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=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=2, column=1, sticky="e", padx=(8, 0), pady=(4, 0))
_tip(
btn_cr,
"Скачать Chromium для Mermaid в %LOCALAPPDATA%\\md2gost "
"(если нет системного Edge/Chrome)",
)
self._combo(
extra, 2, "Как рисовать",
self.fallback_var,
_combo_values(FALLBACK_LABELS, ("remote", "local", "off")),
tip="Локально (PlantUML / браузер / QuickJS), через интернет или не рисовать",
)
self._combo(
extra, 3, "Формат схем",
self.format_var,
_combo_values(FORMAT_LABELS, ("png", "svg")),
tip="PNG везде; SVG — вектор в Word 2016+ (PNG рядом как запасной)",
)
ttk.Label(extra, text="Scale PNG").grid(row=4, column=0, sticky="w", padx=(0, 8), pady=3)
scale_row = ttk.Frame(extra)
scale_row.grid(row=4, column=1, sticky="w", pady=3)
scale_entry = ttk.Entry(scale_row, textvariable=self.scale_var, width=8)
scale_entry.pack(side=tk.LEFT)
_tip(scale_entry, "Качество растра PlantUML / локального Mermaid; на странице размер как при 1")
ttk.Label(
scale_row,
text=" качество; на странице размер как при 1",
style="Hint.TLabel",
).pack(side=tk.LEFT)
ttk.Label(
extra,
text="SVG — вектор в Word 2016+ (PNG всегда рядом как запасной). LibreOffice покажет растр.\n"
"+landscape у %подписи — альбомная A4 под широкий рисунок/таблицу.",
style="Hint.TLabel",
wraplength=640,
).grid(row=5, column=0, columnspan=2, sticky="w", pady=(2, 0))
ttk.Label(
extra,
text="Свой plantuml.jar — только если хотите другой файл. Пусто = вшитый или скачаем сами.",
style="Hint.TLabel",
wraplength=640,
).grid(row=6, column=0, columnspan=2, sticky="w", pady=(6, 0))
self._row_path(extra, 7, "Свой plantuml.jar", self.plantuml_var, self._browse_jar, clearable=True,
tip="Пусто — вшитый jar или скачанный автоматически")
ttk.Label(
extra,
text=(
"Свой Kroki — запасной путь (и для UML без Java). Пусто = localhost:8000, потом kroki.io. "
"Mermaid сначала рисуется локально (браузер / QuickJS)."
),
style="Hint.TLabel",
wraplength=640,
).grid(row=8, column=0, columnspan=2, sticky="w", pady=(6, 0))
ttk.Label(extra, text="Свой Kroki URL").grid(row=9, column=0, sticky="w", padx=(0, 8), pady=3)
kroki_entry = ttk.Entry(extra, textvariable=self.kroki_var)
kroki_entry.grid(row=9, column=1, sticky="ew", pady=3)
_tip(kroki_entry, "URL своего Kroki; пусто — localhost:8000, затем kroki.io")
extra_flags = ttk.Frame(extra)
extra_flags.grid(row=10, column=0, columnspan=2, sticky="w", pady=(8, 0))
cb_hl = ttk.Checkbutton(extra_flags, text="Подсветка синтаксиса в листингах", variable=self.highlight_var)
cb_hl.pack(side=tk.LEFT, padx=(0, 16))
_tip(cb_hl, "Цветная подсветка кода в листингах DOCX")
cache_row = ttk.Frame(extra)
cache_row.grid(row=11, column=0, columnspan=2, sticky="w", pady=(10, 0))
btn_open = ttk.Button(cache_row, text="Открыть файл схем", command=self._open_schemes_file)
btn_open.pack(side=tk.LEFT, padx=(0, 8))
_tip(btn_open, "Открыть md2gost.schemes.json во внешней программе")
btn_cache = ttk.Button(cache_row, text="Сбросить кэш includes", command=self._reset_include_cache)
btn_cache.pack(side=tk.LEFT)
_tip(btn_cache, "Удалить скачанные !include файлы; при следующем рендере скачаются снова")
self._refresh_diagram_status()
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_diagrams"))
return win
def _create_schemes_window(self) -> tk.Toplevel:
win = tk.Toplevel(self.root)
win.title("Шаблоны UML")
win.configure(bg=PAPER)
win.minsize(700, 480)
win.geometry("820x560")
win.transient(self.root)
parent = ttk.Frame(win, padding=12)
parent.pack(fill=tk.BOTH, expand=True)
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
self._build_schemes_tab(parent)
win.protocol("WM_DELETE_WINDOW", lambda: self._close_toplevel(win, "_win_schemes"))
return win
def _create_help_window(self, kind: str) -> tk.Toplevel:
titles = {
"usage": "Справка — Инструкция",
"docs": "Справка — Документация",
"schemes": "Справка — Схемы",
"prompts": "Справка — Промпт для ИИ",
}
win = tk.Toplevel(self.root)
win.title(titles.get(kind, "Справка"))
win.configure(bg=PAPER)
win.minsize(560, 400)
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:
self._build_prompts(frame)
def on_close():
self._win_help.pop(kind, None)
try:
win.destroy()
except tk.TclError:
pass
win.protocol("WM_DELETE_WINDOW", on_close)
return win
def _close_toplevel(self, win: tk.Toplevel, attr: str) -> None:
setattr(self, attr, None)
try:
win.destroy()
except tk.TclError:
pass
def _toggle_debug(self) -> None:
if self.debug_var.get():
self.debug_var.set(False)
self._debug_next = False
self._sync_debug_menu()
self._append_log("Дебаг выключен.")
return
self.debug_var.set(True)
self._debug_next = True
self._sync_debug_menu()
messagebox.showinfo(
"md2gost",
"Следующая сборка будет с отладкой.\n"
"В документ добавятся отладочные данные.\n"
"После этой конвертации режим сбросится.",
)
self._append_log("Дебаг: следующая конвертация с отладочными данными.")
def _sync_debug_menu(self) -> None:
label = "✓ Дебаг" if self.debug_var.get() else "Дебаг"
try:
idx = self._debug_menu_index
if self._menubar.type(idx) != "command":
return
self._menubar.entryconfigure(idx, label=label)
except tk.TclError:
pass
def _build(self) -> None:
outer = ttk.Frame(self.root, padding=12)
outer.pack(fill=tk.BOTH, expand=True)
self._build_convert(outer)
self.root.bind("<Return>", self._on_return)
def _on_return(self, event) -> str | None:
if isinstance(event.widget, tk.Text):
return None
self._convert()
return "break"
def _build_help(self, parent) -> None:
ttk.Label(
parent,
text="Как пользоваться и синтаксис markdown",
style="Hint.TLabel",
).pack(anchor=tk.W, pady=(0, 6))
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,
text="Настраиваемые схемы диаграмм (uml-c4, usecase, …)",
style="Hint.TLabel",
).pack(anchor=tk.W, pady=(0, 6))
box = self._readonly_text(parent, SCHEMES_HELP.strip() + "\n")
box.pack(fill=tk.BOTH, expand=True)
def _build_prompts(self, parent) -> None:
catalog = load_prompt_catalog()
ttk.Label(
parent,
text="Скопируйте промпт и вставьте в ChatGPT / Cursor / Copilot. "
"Кнопки схем дописывают макросы из md2gost.schemes.json — "
"когда ИИ должен знать C4/свои шаблоны. Следующим сообщением — тип, тема, черновик.",
style="Hint.TLabel",
wraplength=640,
).pack(anchor=tk.W, pady=(0, 8))
if not catalog:
ttk.Label(
parent,
text="Файлы промптов не найдены (папка prompts/ рядом с программой).",
style="Hint.TLabel",
).pack(anchor=tk.W)
return
self._prompt_texts = {title: text for _name, title, text in catalog}
titles = [title for _name, title, _text in catalog]
self._prompt_choice = tk.StringVar(value=titles[0])
row = ttk.Frame(parent)
row.pack(fill=tk.X, pady=(0, 6))
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._refresh_composed_prompt())
btn = ttk.Button(row, text="Скопировать", command=self._copy_selected_prompt)
btn.pack(side=tk.LEFT, padx=(8, 0))
_tip(btn, "Скопировать собранный промпт (база + выбранные схемы) в буфер")
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 _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)
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._composed_prompt_text()
if not text:
return
self.root.clipboard_clear()
self.root.clipboard_append(text)
self.root.update_idletasks()
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)
text = tk.Text(
wrap, wrap=tk.WORD, relief=tk.FLAT,
bg="#fffdf8", fg=INK, padx=10, pady=8,
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
)
scroll = ttk.Scrollbar(wrap, command=text.yview)
text.configure(yscrollcommand=scroll.set)
text.insert("1.0", content)
text.configure(state=tk.DISABLED)
text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
text._md2gost_wrap = wrap # type: ignore[attr-defined]
def pack(**kwargs):
wrap.pack(**kwargs)
text.pack = pack # type: ignore[method-assign]
return text
def _build_convert(self, outer) -> None:
self.zone = tk.Frame(
outer, bg=ZONE, highlightthickness=2,
highlightbackground=NAVY, highlightcolor=NAVY, cursor="hand2",
)
self.zone.pack(fill=tk.X, pady=(0, 8))
self.zone_label = tk.Label(
self.zone,
text="Перетащите .md или .docx сюда · или нажмите, чтобы выбрать файл",
bg=ZONE, fg=NAVY, font=("Segoe UI", 11) if os.name == "nt" else ("Helvetica", 11),
justify=tk.CENTER, pady=14,
)
self.zone_label.pack(fill=tk.X)
self.zone_path = tk.Label(
self.zone, text="", bg=ZONE, fg=NAVY_DIM,
font=("Segoe UI", 9) if os.name == "nt" else ("Helvetica", 9),
)
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 / .docx или кликните, чтобы выбрать файл")
_tip(self.zone_label, "Перетащите .md / .docx или кликните, чтобы выбрать файл")
grid = ttk.Frame(outer)
grid.pack(fill=tk.X)
grid.columnconfigure(1, weight=1)
self._row_path(grid, 0, "Выходной DOCX", self.output_var, self._browse_output,
tip="Куда сохранить документ; пусто — рядом с .md")
main = ttk.LabelFrame(outer, text="Основные", padding=8)
main.pack(fill=tk.X, pady=(10, 8))
main.columnconfigure(1, weight=1)
self._combo(main, 0, "Тип документа", self.type_var, _combo_values(DOC_TYPE_LABELS, DOC_TYPES),
tip="Профиль оформления: ГОСТ МИРЭА, ПИС, АПИД…")
self._combo(main, 1, "Нумерация заголовков", self.heading_var, _combo_values(HEADING_LABELS, HEADING_NUMBERING_MODES),
tip="Цифры уже в markdown или автонумерация Word")
self._combo(main, 2, "Содержание", self.toc_var, _combo_values(TOC_LABELS, TOC_MODES),
tip="Поле Word или собранное md2gost")
self._combo(main, 3, "Продолжение таблиц", self.table_var, _combo_values(TABLE_LABELS, TABLE_CONTINUATION_MODES),
tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)")
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
tip="То же для листингов; word требует Windows + Word + pywin32")
ttk.Label(main, text="Смещение страниц").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
page_row = ttk.Frame(main)
page_row.grid(row=5, column=1, sticky="w", pady=3)
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
page_entry.pack(side=tk.LEFT)
_tip(
page_entry,
"Начальный номер PAGE в колонтитуле тела. Пусто — сквозная нумерация Word; "
"например 1 — тело с 1 (титул/задание без номера)",
)
ttk.Label(
page_row,
text=" пусто = сквозной; 1 = тело с первой",
style="Hint.TLabel",
).pack(side=tk.LEFT)
flags = ttk.Frame(main)
flags.grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
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"),
("Строгий режим (код 1 при ошибках)", self.strict_var, "Ненулевой код выхода при ошибках проверки"),
("Проверить вёрстку в Word (эвристика)", self.check_pages_var, "Полупустые страницы; нужны Word + pywin32"),
(
"Повторять шапку таблицы при word-разрыве",
self.table_repeat_header_var,
"Только для «Продолжение таблиц = word»; по умолчанию выкл",
),
]
for i, (text, var, tip) in enumerate(checks):
r, c = divmod(i, 3)
cb = ttk.Checkbutton(flags, text=text, variable=var)
span = 2 if c == 0 and i >= 6 else 1
cb.grid(row=r, column=c, columnspan=span, sticky="w", padx=(0, 16), pady=2)
_tip(cb, tip)
actions = ttk.Frame(outer)
actions.pack(fill=tk.X, pady=(4, 8))
self.convert_btn = tk.Button(
actions, text="Конвертировать",
command=self._convert,
bg=NAVY, fg="white", activebackground="#16304d", activeforeground="white",
disabledforeground="#d0d5dc",
font=("Segoe UI", 12, "bold") if os.name == "nt" else ("Helvetica", 12, "bold"),
relief=tk.FLAT, padx=22, pady=7, cursor="hand2",
)
self.convert_btn.pack(side=tk.LEFT)
_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)
log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4)
log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0))
self.log = tk.Text(
log_frame, height=7, wrap=tk.WORD, relief=tk.FLAT,
bg="#fffdf8", fg=INK, padx=8, pady=6,
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
)
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
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", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
self.log.configure(state=tk.DISABLED)
def _build_schemes_tab(self, parent) -> None:
ttk.Label(
parent,
text="В markdown: ```uml-<id> или ```<id>. Сохраняется в md2gost.schemes.json рядом с программой.",
style="Hint.TLabel",
wraplength=780,
).grid(row=0, column=0, sticky="w", pady=(0, 6))
body = ttk.Frame(parent)
body.grid(row=1, column=0, sticky="nsew")
body.columnconfigure(1, weight=1)
body.rowconfigure(0, weight=1)
left = ttk.Frame(body)
left.grid(row=0, column=0, sticky="nsw", padx=(0, 10))
self._scheme_list = tk.Listbox(left, height=14, width=22, exportselection=False)
self._scheme_list.pack(side=tk.TOP, fill=tk.Y)
self._scheme_list.bind("<<ListboxSelect>>", self._on_scheme_select)
_tip(self._scheme_list, "Список схем из md2gost.schemes.json")
btns = ttk.Frame(left)
btns.pack(fill=tk.X, pady=(6, 0))
for text, cmd, tip in (
("Добавить", self._scheme_add, "Новая схема с id customN"),
("Дублировать", self._scheme_dup, "Копия выбранной схемы"),
("Удалить", self._scheme_del, "Удалить схему из файла пользователя"),
("Сохранить", self._scheme_save_all, "Записать все схемы в md2gost.schemes.json"),
("Открыть JSON", self._open_schemes_file, "Открыть файл схем во внешней программе"),
("Сброс кэша", self._reset_include_cache, "Сбросить кэш скачанных includes"),
):
b = ttk.Button(btns, text=text, command=cmd)
b.pack(fill=tk.X, pady=1)
_tip(b, tip)
form = ttk.Frame(body)
form.grid(row=0, column=1, sticky="nsew")
form.columnconfigure(1, weight=1)
self._sch_id = tk.StringVar()
self._sch_title = tk.StringVar()
self._sch_version = tk.StringVar()
self._sch_author = tk.StringVar()
row = 0
for label, var, tip in (
("id", self._sch_id, "Латиница, цифры, _; начинается с буквы"),
("title", self._sch_title, "Подпись в списке схем"),
("version", self._sch_version, "Версия схемы"),
("author", self._sch_author, "Автор схемы"),
):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=2)
e = ttk.Entry(form, textvariable=var)
e.grid(row=row, column=1, sticky="ew", pady=2)
_tip(e, tip)
row += 1
for label, attr, height, tip in (
("docs", "_sch_docs", 4, "Шпаргалка синтаксиса / макросов"),
("ai-prompt", "_sch_ai", 3, "Заготовка промпта для ИИ"),
("includes", "_sch_includes", 3, "Файлы или URL .puml, по одному в строке"),
("prefix", "_sch_prefix", 3, "Текст перед телом (часто @startuml)"),
("postfix", "_sch_postfix", 2, "Текст после тела (часто @enduml)"),
):
ttk.Label(form, text=label).grid(row=row, column=0, sticky="nw", padx=(0, 8), pady=2)
w = tk.Text(form, height=height, wrap=tk.WORD, font=("Consolas", 9) if os.name == "nt" else ("monospace", 9))
w.grid(row=row, column=1, sticky="ew", pady=2)
setattr(self, attr, w)
_tip(w, tip)
row += 1
self._scheme_fence_hint = ttk.Label(parent, text="", style="Hint.TLabel")
self._scheme_fence_hint.grid(row=2, column=0, sticky="w", pady=(6, 0))
self._reload_schemes_ui()
def _text_set(self, widget: tk.Text, value: str) -> None:
widget.delete("1.0", tk.END)
if value:
widget.insert("1.0", value)
def _text_get(self, widget: tk.Text) -> str:
return widget.get("1.0", tk.END).rstrip("\n")
def _reload_schemes_ui(self, select_id: str | None = None) -> None:
if not hasattr(self, "_scheme_list"):
return
path = ensure_user_schemes()
self._schemes = load_schemes_from_path(path)
self._scheme_ids = sorted(self._schemes.keys())
self._scheme_list.delete(0, tk.END)
for sid in self._scheme_ids:
title = self._schemes[sid].title or sid
self._scheme_list.insert(tk.END, title)
if not self._scheme_ids:
self._clear_scheme_form()
return
target = select_id if select_id in self._scheme_ids else self._scheme_ids[0]
idx = self._scheme_ids.index(target)
self._scheme_list.selection_clear(0, tk.END)
self._scheme_list.selection_set(idx)
self._scheme_list.see(idx)
self._fill_scheme_form(self._schemes[target])
def _clear_scheme_form(self) -> None:
self._scheme_selecting = True
self._sch_id.set("")
self._sch_title.set("")
self._sch_version.set("")
self._sch_author.set("")
self._text_set(self._sch_docs, "")
self._text_set(self._sch_ai, "")
self._text_set(self._sch_includes, "")
self._text_set(self._sch_prefix, "")
self._text_set(self._sch_postfix, "")
self._scheme_fence_hint.configure(text="")
self._scheme_selecting = False
def _fill_scheme_form(self, scheme: DiagramScheme) -> None:
self._scheme_selecting = True
self._sch_id.set(scheme.id)
self._sch_title.set(scheme.title)
self._sch_version.set(scheme.version)
self._sch_author.set(scheme.author)
self._text_set(self._sch_docs, scheme.docs)
self._text_set(self._sch_ai, scheme.ai_prompt)
self._text_set(self._sch_includes, "\n".join(scheme.includes))
self._text_set(self._sch_prefix, scheme.prefix)
self._text_set(self._sch_postfix, scheme.postfix)
self._scheme_fence_hint.configure(text=f"Оградка: ```uml-{scheme.id} или ```{scheme.id}")
self._scheme_selecting = False
def _on_scheme_select(self, _event=None) -> None:
if self._scheme_selecting:
return
sel = self._scheme_list.curselection()
if not sel:
return
sid = self._scheme_ids[sel[0]]
self._fill_scheme_form(self._schemes[sid])
def _scheme_from_form(self, old_id: str | None = None) -> DiagramScheme | None:
sid = self._sch_id.get().strip().lower()
if not SCHEME_ID_RE.match(sid):
messagebox.showerror("md2gost", "id схемы: латиница, цифры, _; начинается с буквы.")
return None
includes_raw = self._text_get(self._sch_includes)
includes = [ln.strip() for ln in includes_raw.splitlines() if ln.strip()]
return DiagramScheme(
id=sid,
title=self._sch_title.get().strip() or sid,
version=self._sch_version.get().strip(),
author=self._sch_author.get().strip(),
docs=self._text_get(self._sch_docs),
ai_prompt=self._text_get(self._sch_ai),
includes=includes,
prefix=self._text_get(self._sch_prefix),
postfix=self._text_get(self._sch_postfix),
)
def _scheme_commit_current(self) -> str | None:
"""Write form into self._schemes. Returns new id or None on error."""
sel = self._scheme_list.curselection()
old_id = self._scheme_ids[sel[0]] if sel else None
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
return scheme.id
def _scheme_add(self) -> None:
n = 1
while f"custom{n}" in self._schemes:
n += 1
sid = f"custom{n}"
self._schemes[sid] = DiagramScheme(
id=sid,
title="Новая схема",
version="1.0.0",
author="",
docs="",
ai_prompt="",
includes=[],
prefix="@startuml\n",
postfix="\n@enduml",
)
self._reload_schemes_ui(select_id=sid)
def _scheme_dup(self) -> None:
sel = self._scheme_list.curselection()
if not sel:
return
src = self._schemes[self._scheme_ids[sel[0]]]
n = 1
while f"{src.id}_copy{n}" in self._schemes:
n += 1
sid = f"{src.id}_copy{n}"
self._schemes[sid] = DiagramScheme(
id=sid,
title=src.title,
version=src.version,
author=src.author,
docs=src.docs,
ai_prompt=src.ai_prompt,
includes=list(src.includes),
prefix=src.prefix,
postfix=src.postfix,
theme=src.theme,
)
self._reload_schemes_ui(select_id=sid)
def _scheme_del(self) -> None:
sel = self._scheme_list.curselection()
if not sel:
return
sid = self._scheme_ids[sel[0]]
if not messagebox.askyesno("md2gost", f"Удалить схему «{sid}» из файла пользователя?"):
return
del self._schemes[sid]
path = user_schemes_path()
save_schemes(self._schemes, path)
reload_schemes()
self._reload_schemes_ui()
self._append_log(f"Схема удалена: {sid}")
def _scheme_save_all(self) -> None:
sid = self._scheme_commit_current()
if sid is None and self._scheme_ids:
return
path = user_schemes_path()
save_schemes(self._schemes, path)
reload_schemes()
self._reload_schemes_ui(select_id=sid)
self._append_log(f"Схемы сохранены: {path}")
messagebox.showinfo("md2gost", f"Сохранено:\n{path}")
def _open_schemes_file(self) -> None:
path = ensure_user_schemes()
try:
if os.name == "nt":
os.startfile(str(path)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
import subprocess
subprocess.call(("open", str(path)))
else:
import subprocess
subprocess.call(("xdg-open", str(path)))
except OSError as exc:
messagebox.showerror("md2gost", f"Не удалось открыть:\n{path}\n{exc}")
def _reset_include_cache(self) -> None:
n = cache_entry_count()
if not messagebox.askyesno(
"md2gost",
f"Сбросить кэш includes ({n} записей)?\n"
"Удалятся индекс и только файлы из него.\n"
"При следующем рендере URL скачаются снова.",
):
return
removed = reset_include_cache()
self._append_log(f"Кэш includes сброшен, удалено файлов: {removed}")
messagebox.showinfo("md2gost", f"Кэш сброшен. Удалено файлов: {removed}")
def _combo(self, parent, row: int, label: str, var: tk.StringVar, values: list[str], tip: str = "") -> None:
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3)
box = ttk.Combobox(parent, textvariable=var, values=values, state="readonly")
box.grid(row=row, column=1, sticky="ew", pady=3)
if tip:
_tip(box, tip)
def _row_path(self, parent, row, label, var, browse, clearable=False, tip: str = ""):
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=3)
cell = ttk.Frame(parent)
cell.grid(row=row, column=1, sticky="ew", pady=3)
cell.columnconfigure(0, weight=1)
entry = ttk.Entry(cell, textvariable=var)
entry.grid(row=0, column=0, sticky="ew")
if tip:
_tip(entry, tip)
btn = ttk.Button(cell, text="Обзор…", command=browse)
btn.grid(row=0, column=1, padx=(6, 0))
_tip(btn, "Выбрать файл")
if clearable:
clear_btn = ttk.Button(cell, text="Сброс", command=lambda: var.set(""))
clear_btn.grid(row=0, column=2, padx=(4, 0))
_tip(clear_btn, "Очистить поле")
def _setup_dnd(self) -> None:
backend = enable_file_drop(self.zone, self._on_drop)
if backend == "none":
backend = enable_file_drop(self.root, self._on_drop)
self._dnd_backend = backend
if backend == "none":
self._append_log("Перетаскивание недоступно — выберите файл кликом по области.")
else:
self._append_log("Можно перетаскивать .md / .docx из проводника в окно.")
def _on_drop(self, paths: list[str]) -> None:
md = first_markdown(paths)
if md:
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 или .docx, а не: "
+ ", ".join(os.path.basename(p) for p in paths)
)
self._flash_zone("#f8d7da")
def _flash_zone(self, color: str) -> None:
self.zone.configure(bg=color)
self.zone_label.configure(bg=color)
self.zone_path.configure(bg=color)
self.root.after(700, lambda: self._set_zone_colors(ZONE if self._last_md else ZONE))
def _set_zone_colors(self, color: str) -> None:
self.zone.configure(bg=color)
self.zone_label.configure(bg=color)
self.zone_path.configure(bg=color)
def _browse_md(self) -> None:
path = filedialog.askopenfilename(
title="Исходный Markdown или Word",
filetypes=[
("Markdown / Word", "*.md;*.docx"),
("Markdown", "*.md"),
("Word", "*.docx"),
("Все файлы", "*.*"),
],
)
if not path:
return
if path.lower().endswith(".docx"):
self._set_docx(path)
else:
self._set_markdown(path)
def _browse_output(self) -> None:
initial = self.output_var.get() or (default_output_path(self._last_md) if self._last_md else "report.docx")
path = filedialog.asksaveasfilename(
title="Сохранить DOCX",
defaultextension=".docx",
initialfile=os.path.basename(initial),
initialdir=os.path.dirname(os.path.abspath(initial)) if initial else "",
filetypes=[("Word", "*.docx")],
)
if path:
self._auto_output = False
self.output_var.set(path)
def _browse_template(self) -> None:
self._pick_into(self.template_var, "Шаблон DOCX", [("Word", "*.docx")])
def _browse_title(self) -> None:
self._pick_into(self.title_var, "Титульный лист", [("Word", "*.docx")])
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()
def _refresh_diagram_status(self) -> None:
label = getattr(self, "diag_status", None)
if label is None:
return
try:
if not label.winfo_exists():
return
except tk.TclError:
return
explicit = self.plantuml_var.get().strip() or None
label.configure(text=diagram_engine_status(explicit))
def _download_plantuml(self) -> None:
from .diagram_renderer import cached_plantuml_path, fetch_plantuml_jar, vendor_plantuml_path
dest = vendor_plantuml_path()
try:
dest.parent.mkdir(parents=True, exist_ok=True)
probe = dest.parent / ".write_test"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
except OSError:
dest = cached_plantuml_path()
self._append_log(f"Скачиваю PlantUML → {dest}")
if fetch_plantuml_jar(dest):
self._refresh_diagram_status()
messagebox.showinfo("md2gost", f"PlantUML сохранён:\n{dest}\n\nДля локальной отрисовки нужна Java.")
else:
messagebox.showerror(
"md2gost",
"Не удалось скачать plantuml.jar.\n"
"Можно ничего не делать — диаграммы нарисует 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
if not playwright_available():
messagebox.showerror(
"md2gost",
"Пакет playwright не установлен.\n"
"Установите: pip install playwright\n"
"Mermaid всё равно может рисоваться через QuickJS (mermaidx) или Kroki.",
)
return
dest = playwright_browsers_dir()
self._append_log(f"Скачиваю headless Chromium → {dest}")
self.root.update_idletasks()
def work() -> None:
ok = install_playwright_chromium()
self.root.after(0, lambda: self._on_chromium_installed(ok, dest))
import threading
threading.Thread(target=work, daemon=True).start()
def _on_chromium_installed(self, ok: bool, dest) -> None:
self._refresh_diagram_status()
if ok:
messagebox.showinfo(
"md2gost",
f"Chromium установлен:\n{dest}\n\n"
"Будет использоваться для локального Mermaid, если нет системного Edge/Chrome.",
)
else:
messagebox.showerror(
"md2gost",
"Не удалось скачать Chromium.\n"
"Попробуйте: md2gost --install-chromium\n"
"Или оставьте как есть — Mermaid через QuickJS / kroki.io.",
)
def _pick_into(self, var: tk.StringVar, title: str, types) -> None:
path = filedialog.askopenfilename(title=title, filetypes=types)
if path:
var.set(path)
def _on_output_edited(self, *_args) -> None:
if not self._last_md:
return
current = self.output_var.get().strip()
auto = default_output_path(self._last_md)
self._auto_output = (not current) or os.path.normcase(os.path.abspath(current)) == os.path.normcase(auto)
def _set_markdown(self, path: str, from_user_output: bool = False) -> 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 not from_user_output and self._auto_output:
self.output_var.set(default_output_path(path))
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))
self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE))
self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION))
self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION))
self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote"))
self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png"))
scale = getattr(req, "diagram_scale", 2.0) or 2.0
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))
self.strict_var.set(bool(req.strict))
self.check_pages_var.set(bool(getattr(req, "check_pages", False)))
self.table_repeat_header_var.set(bool(getattr(req, "table_repeat_header", False)))
start = getattr(req, "page_number_start", None)
self.page_start_var.set("" if start is None else str(int(start)))
self.highlight_var.set(bool(req.syntax_highlighting))
self.debug_var.set(bool(req.debug))
self._debug_next = bool(req.debug)
self._sync_debug_menu()
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()
if req.output:
self.output_var.set(req.output)
self._auto_output = False
def _collect(self) -> ConvertRequest:
return ConvertRequest(
filename=self._last_md,
output=self.output_var.get().strip() or None,
template=self.template_var.get().strip() or None,
doc_type=_combo_key(self.type_var.get(), DOC_TYPES, "coursework"),
heading_numbering=_combo_key(self.heading_var.get(), HEADING_NUMBERING_MODES, DEFAULT_HEADING_NUMBERING),
toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE),
table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION),
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(),
check_only=self.check_only_var.get(),
check_pages=self.check_pages_var.get(),
table_repeat_header=self.table_repeat_header_var.get(),
page_number_start=self._parse_page_start(),
strict=self.strict_var.get(),
syntax_highlighting=self.highlight_var.get(),
plantuml_jar=self.plantuml_var.get().strip() or None,
kroki_url=self.kroki_var.get().strip() or None,
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(),
)
def _parse_page_start(self) -> int | None:
raw = (self.page_start_var.get() or "").strip()
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return value if value >= 1 else None
def _parse_diagram_scale(self) -> float:
raw = (self.scale_var.get() or "2").strip().replace(",", ".")
try:
value = float(raw)
except ValueError:
return 2.0
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",
"Сначала перетащите или выберите .md (сборка) или .docx (импорт).",
)
return
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 ""
)
if target and os.path.isfile(target):
if not messagebox.askyesno(
"md2gost", f"Файл уже есть:\n{target}\n\nПерезаписать?"
):
target = timestamped_output_path(target)
self._append_log(f"Не перезаписываем, пишем в {target}")
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)
def work():
result = convert(req, log=lambda m: self.root.after(0, self._append_log, m))
self.root.after(0, self._on_done, result)
threading.Thread(target=work, daemon=True).start()
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)
self._debug_next = False
self._sync_debug_menu()
self._append_log("Дебаг сброшен после этой сборки.")
if result.ok:
if result.output_path:
self.output_var.set(result.output_path)
self._append_log("Готово.")
elif result.check_report:
self._append_log("Проверка завершена.")
else:
self._append_log(result.message or "Ошибка")
messagebox.showerror("md2gost", result.message or "Ошибка конвертации")
def _append_log(self, text: str) -> None:
if not text:
return
if not hasattr(self, "log"):
return
self.log.configure(state=tk.NORMAL)
if not text.endswith("\n"):
text += "\n"
self.log.insert(tk.END, text)
self.log.see(tk.END)
self.log.configure(state=tk.DISABLED)
def run(self) -> None:
self.root.mainloop()
def run_gui(initial: ConvertRequest | None = None) -> None:
Md2GostApp(initial).run()
def main() -> None:
run_gui()
if __name__ == "__main__":
main()