"""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, 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, 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": "Только этот компьютер (нужна Java)", "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("", self._on_enter, add="+") widget.bind("", self._on_leave, add="+") widget.bind("", 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_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) 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("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.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.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_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" "Стили 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 _create_diagrams_window(self) -> tk.Toplevel: win = tk.Toplevel(self.root) win.title("Настройки — Диаграммы") win.configure(bg=PAPER) win.minsize(560, 420) win.geometry("680x520") 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 / ```bpmn / ```mermaid сами станут рисунками.\n" "Схемы редактируются в меню «Шаблоны UML» (файл md2gost.schemes.json).\n" "UML: сначала PlantUML + Java, иначе интернет (kroki.io). Mermaid — только через 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") 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)") self._combo( extra, 2, "Как рисовать", self.fallback_var, _combo_values(FALLBACK_LABELS, ("remote", "local", "off")), tip="Локально (PlantUML+Java), через интернет или не рисовать", ) 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 (PlantUML)").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; на странице размер как при 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 — только если у вас крутится свой сервер. Пусто = сначала localhost:8000, потом kroki.io.", 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("", 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("<>", 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, bpmn, …)", 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/BPMN/свои шаблоны. Следующим сообщением — тип, тема, черновик.", 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("<>", 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 сюда · или нажмите, чтобы выбрать файл", 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("", lambda _e: self._browse_md()) _tip(self.zone, "Перетащите .md или кликните, чтобы выбрать файл") _tip(self.zone_label, "Перетащите .md или кликните, чтобы выбрать файл") 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") flags = ttk.Frame(main) flags.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) checks = [ ("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"), ("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"), ("Открыть после конвертации", 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") 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", "Готово к работе. Перетащите markdown-файл в верхнюю область.\n") self.log.configure(state=tk.DISABLED) def _build_schemes_tab(self, parent) -> None: ttk.Label( parent, text="В markdown: ```uml- или ```. Сохраняется в 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("<>", 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 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 из проводника в окно.") def _on_drop(self, paths: list[str]) -> None: md = first_markdown(paths) if md: self._set_markdown(md) self._flash_zone(ZONE_OK) return if paths: self._append_log("Нужен файл .md, а не: " + ", ".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", filetypes=[("Markdown", "*.md"), ("Все файлы", "*.*")], ) if path: 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 _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 _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.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))) 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(), 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(), 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_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: if self._busy: return req = self._collect() if not req.filename: messagebox.showinfo("md2gost", "Сначала перетащите или выберите markdown-файл.") return if 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._busy = True self.convert_btn.configure(state=tk.DISABLED, bg=NAVY_DIM) self.progress.start(12) self._append_log("Конвертация…") 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) 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()