b38661f588
Python application / build (push) Has been cancelled
- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
1261 lines
55 KiB
Python
1261 lines
55 KiB
Python
"""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,
|
||
load_schemes_from_path,
|
||
reload_schemes,
|
||
save_schemes,
|
||
user_schemes_path,
|
||
)
|
||
from .dnd import enable_file_drop, first_markdown
|
||
from .help_content import SCHEMES_HELP, USAGE_HELP, load_prompt_catalog
|
||
from .pipeline import ConvertRequest, convert, default_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("<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_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.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("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, 200)
|
||
win.geometry("620x240")
|
||
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 бланка задания, вставляется перед телом отчёта")
|
||
hint = ttk.Label(
|
||
frame,
|
||
text="Пустой шаблон — встроенный Template.docx. Титул и задание вставляются перед телом отчёта.",
|
||
style="Hint.TLabel",
|
||
wraplength=560,
|
||
)
|
||
hint.grid(row=3, 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": "Справка — Инструкция",
|
||
"schemes": "Справка — Схемы",
|
||
"prompts": "Справка — Промпт для ИИ",
|
||
}
|
||
win = tk.Toplevel(self.root)
|
||
win.title(titles.get(kind, "Справка"))
|
||
win.configure(bg=PAPER)
|
||
win.minsize(560, 400)
|
||
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 == "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_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. Следующим сообщением — тип, тема, черновик.",
|
||
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._show_selected_prompt())
|
||
btn = ttk.Button(row, text="Скопировать", command=self._copy_selected_prompt)
|
||
btn.pack(side=tk.LEFT, padx=(8, 0))
|
||
_tip(btn, "Скопировать выбранный промпт в буфер обмена")
|
||
|
||
self._prompt_view = self._readonly_text(parent, self._prompt_texts[titles[0]])
|
||
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))
|
||
|
||
def _show_selected_prompt(self) -> None:
|
||
text = self._prompt_texts.get(self._prompt_choice.get(), "")
|
||
self._prompt_view.configure(state=tk.NORMAL)
|
||
self._prompt_view.delete("1.0", tk.END)
|
||
self._prompt_view.insert("1.0", text)
|
||
self._prompt_view.configure(state=tk.DISABLED)
|
||
self._copy_status.configure(text="")
|
||
|
||
def _copy_selected_prompt(self) -> None:
|
||
text = self._prompt_texts.get(self._prompt_choice.get(), "")
|
||
if not text:
|
||
return
|
||
self.root.clipboard_clear()
|
||
self.root.clipboard_append(text)
|
||
self.root.update_idletasks()
|
||
self._copy_status.configure(text="Скопировано в буфер обмена — можно вставлять в ИИ.")
|
||
|
||
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("<Button-1>", 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-<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
|
||
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_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.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(),
|
||
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 and req.output and os.path.isfile(req.output):
|
||
if not messagebox.askyesno("md2gost", f"Файл уже есть:\n{req.output}\n\nПерезаписать?"):
|
||
return
|
||
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._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()
|