- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user