b38661f588
Python application / build (push) Has been cancelled
- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
304 lines
10 KiB
Python
304 lines
10 KiB
Python
"""Shared Markdown → DOCX pipeline for CLI and GUI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import traceback
|
|
from dataclasses import dataclass
|
|
from getpass import getuser
|
|
from typing import Callable
|
|
|
|
from docx import Document
|
|
|
|
from . import package_dir
|
|
from .checker import check_markdown, format_report
|
|
from .converter import Converter
|
|
from .profiles import (
|
|
DEFAULT_HEADING_NUMBERING,
|
|
DEFAULT_TABLE_CONTINUATION,
|
|
DEFAULT_LISTING_CONTINUATION,
|
|
DEFAULT_TOC_MODE,
|
|
get_profile,
|
|
)
|
|
|
|
LogFn = Callable[[str], None]
|
|
|
|
|
|
@dataclass
|
|
class ConvertRequest:
|
|
filename: str = ""
|
|
output: str | None = None
|
|
template: str | None = None
|
|
doc_type: str = "practice"
|
|
heading_numbering: str = DEFAULT_HEADING_NUMBERING
|
|
toc_mode: str = DEFAULT_TOC_MODE
|
|
table_continuation: str = DEFAULT_TABLE_CONTINUATION
|
|
listing_continuation: str = DEFAULT_LISTING_CONTINUATION
|
|
emdash_to_hyphen: bool = False
|
|
hr_pagebreak: bool = False
|
|
title: str | None = None
|
|
assignment: str | None = None
|
|
check: bool = False
|
|
check_only: bool = False
|
|
strict: bool = False
|
|
syntax_highlighting: bool = False
|
|
plantuml_jar: str | None = None
|
|
kroki_url: str | None = None
|
|
diagram_fallback: str = "remote"
|
|
diagram_format: str = "png"
|
|
diagram_scale: float = 2.0
|
|
schemes_path: str | None = None
|
|
debug: bool = False
|
|
open_when_done: bool = False
|
|
check_pages: bool = False
|
|
table_repeat_header: bool = False
|
|
|
|
|
|
@dataclass
|
|
class ConvertResult:
|
|
ok: bool
|
|
exit_code: int = 0
|
|
output_path: str | None = None
|
|
check_report: str = ""
|
|
message: str = ""
|
|
|
|
|
|
def default_output_path(filename: str) -> str:
|
|
base = os.path.basename(filename)
|
|
if base.lower().endswith(".md"):
|
|
base = base[:-3]
|
|
return os.path.join(os.path.dirname(os.path.abspath(filename)), base + ".docx")
|
|
|
|
|
|
def default_template_path() -> str:
|
|
return os.path.join(package_dir(), "Template.docx")
|
|
|
|
|
|
def open_document(path: str) -> None:
|
|
system = platform.system()
|
|
if system == "Darwin":
|
|
subprocess.call(("open", path))
|
|
elif system == "Windows":
|
|
os.startfile(path) # type: ignore[attr-defined]
|
|
else:
|
|
subprocess.call(("xdg-open", path))
|
|
|
|
|
|
def _fix_front_matter_after_compose(document, *, had_title: bool, had_assignment: bool) -> None:
|
|
"""Clear PAGE on title/assignment sections; keep continuous page numbers."""
|
|
from .page_geometry import clear_section_footer, ensure_continuous_page_numbers
|
|
|
|
ensure_continuous_page_numbers(document)
|
|
n_front = int(bool(had_title)) + int(bool(had_assignment))
|
|
for i, section in enumerate(document.sections):
|
|
if i < n_front:
|
|
clear_section_footer(section)
|
|
else:
|
|
break
|
|
ensure_continuous_page_numbers(document)
|
|
|
|
|
|
def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
|
|
"""Run check and/or conversion. Does not re-raise; errors go into ConvertResult."""
|
|
emit: LogFn = log if log is not None else print
|
|
|
|
def fail(code: int, message: str, check_report: str = "") -> ConvertResult:
|
|
emit(message)
|
|
return ConvertResult(False, code, check_report=check_report, message=message)
|
|
|
|
filename = (req.filename or "").strip()
|
|
# Standalone page-fill check on an existing DOCX
|
|
if req.check_pages and filename.lower().endswith(".docx") and not filename.lower().endswith(".md"):
|
|
from .page_fill_check import check_docx_page_fill, format_page_fill_report
|
|
issues, status = check_docx_page_fill(filename)
|
|
report = format_page_fill_report(issues, status)
|
|
emit(report)
|
|
return ConvertResult(True, 0, check_report=report, message=report)
|
|
|
|
if not filename:
|
|
return fail(2, "Укажите исходный markdown-файл")
|
|
if not filename.lower().endswith(".md"):
|
|
return fail(1, "Исходный файл должен быть в формате .md")
|
|
if not os.path.isfile(filename):
|
|
return fail(2, f"Файл не найден: {filename}")
|
|
|
|
output = (req.output or "").strip() or None
|
|
if output and not output.lower().endswith(".docx"):
|
|
return fail(1, "Выходной файл должен быть в формате .docx")
|
|
|
|
for label, path in (("титул", req.title), ("задание", req.assignment), ("шаблон", req.template)):
|
|
if path and not os.path.isfile(path):
|
|
return fail(2, f"Файл ({label}) не найден: {path}")
|
|
|
|
if req.syntax_highlighting:
|
|
os.environ["SYNTAX_HIGHLIGHTING"] = "1"
|
|
else:
|
|
os.environ.pop("SYNTAX_HIGHLIGHTING", None)
|
|
|
|
from .diagram_renderer import configure_diagrams
|
|
|
|
md_dir = os.path.dirname(os.path.abspath(filename)) or "."
|
|
configure_diagrams(
|
|
plantuml_jar=req.plantuml_jar or None,
|
|
kroki_url=req.kroki_url or None,
|
|
fallback=req.diagram_fallback,
|
|
schemes_path=req.schemes_path or None,
|
|
md_dir=md_dir,
|
|
diagram_format=req.diagram_format if req.diagram_format in ("png", "svg") else "png",
|
|
diagram_scale=req.diagram_scale,
|
|
)
|
|
|
|
os.environ["WORKING_DIR"] = md_dir
|
|
|
|
with open(filename, encoding="utf-8") as f:
|
|
md_text = f.read()
|
|
|
|
check_report = ""
|
|
if req.check or req.check_only:
|
|
issues = check_markdown(
|
|
md_text,
|
|
req.doc_type,
|
|
table_continuation=req.table_continuation,
|
|
listing_continuation=req.listing_continuation,
|
|
)
|
|
check_report = format_report(issues)
|
|
emit(check_report)
|
|
errors = [i for i in issues if i.severity == "error"]
|
|
if req.strict and errors:
|
|
return fail(1, "Проверка ТЗ: есть ошибки (--strict)", check_report)
|
|
if req.check_only:
|
|
return ConvertResult(
|
|
True, 0, check_report=check_report,
|
|
message=check_report,
|
|
)
|
|
|
|
if not output:
|
|
output = default_output_path(filename)
|
|
|
|
template = (req.template or "").strip() or default_template_path()
|
|
|
|
handler = _CallbackLogHandler(emit)
|
|
handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
|
|
root_log = logging.getLogger("md2gost")
|
|
root_log.addHandler(handler)
|
|
attached_diag = logging.getLogger("md2gost.diagram_renderer")
|
|
prev_diag_level = attached_diag.level
|
|
attached_diag.setLevel(logging.INFO)
|
|
attached_diag.addHandler(handler)
|
|
|
|
try:
|
|
converter = Converter(
|
|
filename, output, template, req.debug,
|
|
doc_type=req.doc_type,
|
|
heading_numbering=req.heading_numbering,
|
|
emdash_to_hyphen=req.emdash_to_hyphen,
|
|
toc_mode=req.toc_mode,
|
|
table_continuation=req.table_continuation,
|
|
listing_continuation=req.listing_continuation,
|
|
hr_pagebreak=req.hr_pagebreak,
|
|
)
|
|
converter.convert()
|
|
document = converter.document
|
|
|
|
if req.title or req.assignment:
|
|
try:
|
|
from docxcompose.composer import Composer
|
|
except ImportError:
|
|
return fail(3, "Для титула/задания нужен пакет docxcompose", check_report)
|
|
from .styles import apply_document_styles
|
|
|
|
shell = Document(template)
|
|
apply_document_styles(shell, get_profile(req.doc_type).style_preset)
|
|
body = shell.element.body
|
|
for child in list(body):
|
|
if not child.tag.endswith("}sectPr"):
|
|
body.remove(child)
|
|
|
|
composer = Composer(shell)
|
|
if req.title:
|
|
composer.append(Document(req.title))
|
|
shell.add_page_break()
|
|
if req.assignment:
|
|
composer.append(Document(req.assignment))
|
|
shell.add_page_break()
|
|
composer.append(document)
|
|
document = composer.doc
|
|
apply_document_styles(document, get_profile(req.doc_type).style_preset)
|
|
_fix_front_matter_after_compose(
|
|
document,
|
|
had_title=bool(req.title),
|
|
had_assignment=bool(req.assignment),
|
|
)
|
|
|
|
document.core_properties.author = getuser()
|
|
document.core_properties.comments = "Создано при помощи md2gost (ТЗ МИРЭА)"
|
|
document.save(output)
|
|
except Exception as exc:
|
|
emit(traceback.format_exc())
|
|
return ConvertResult(
|
|
False, 1, check_report=check_report,
|
|
message=f"Ошибка конвертации: {exc}",
|
|
)
|
|
finally:
|
|
root_log.removeHandler(handler)
|
|
attached_diag.removeHandler(handler)
|
|
attached_diag.setLevel(prev_diag_level)
|
|
|
|
abs_out = os.path.abspath(output)
|
|
emit(f"Generated document: {abs_out}")
|
|
|
|
if req.table_continuation == "word" or req.listing_continuation == "word":
|
|
from .word_fix import fix_continuations
|
|
fix = fix_continuations(
|
|
abs_out,
|
|
tables=(req.table_continuation == "word"),
|
|
listings=(req.listing_continuation == "word"),
|
|
repeat_header=bool(req.table_repeat_header),
|
|
)
|
|
emit(fix.message)
|
|
if fix.details:
|
|
for line in fix.details:
|
|
emit(line)
|
|
if not fix.ok:
|
|
check_report = (
|
|
(check_report + "\n" + fix.message).strip() if check_report else fix.message
|
|
)
|
|
|
|
if req.check_pages:
|
|
from .page_fill_check import check_docx_page_fill, format_page_fill_report
|
|
issues, status = check_docx_page_fill(abs_out)
|
|
page_report = format_page_fill_report(issues, status)
|
|
emit(page_report)
|
|
check_report = (check_report + "\n" + page_report).strip() if check_report else page_report
|
|
|
|
if req.debug or req.open_when_done:
|
|
try:
|
|
open_document(abs_out)
|
|
except Exception as exc:
|
|
emit(f"Не удалось открыть файл: {exc}")
|
|
|
|
return ConvertResult(
|
|
True, 0, output_path=abs_out, check_report=check_report,
|
|
message=f"Generated document: {abs_out}",
|
|
)
|
|
|
|
|
|
class _CallbackLogHandler(logging.Handler):
|
|
def __init__(self, emit: LogFn):
|
|
super().__init__()
|
|
self._emit = emit
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
try:
|
|
self._emit(self.format(record))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def should_launch_gui(filename: str | None, gui_flag: bool) -> bool:
|
|
"""GUI if --gui, or if no input file was given (interactive default)."""
|
|
return bool(gui_flag or not filename)
|