Files
md_to_gost/md2gost/pipeline.py
T
Igor20264 b38661f588
Python application / build (push) Has been cancelled
Update 0.4.0
- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
2026-09-04 22:28:39 +03:00

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)