482 lines
17 KiB
Python
482 lines
17 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 datetime import datetime
|
|
from pathlib import Path
|
|
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,
|
|
)
|
|
|
|
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
|
|
student: str | None = None
|
|
group: str | None = None
|
|
# Auto-build title from info_conv.yaml / ```title (off by default).
|
|
auto_title: bool = False
|
|
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
|
|
styles_path: str | None = None
|
|
debug: bool = False
|
|
open_when_done: bool = False
|
|
check_pages: bool = False
|
|
table_repeat_header: bool = False
|
|
# PAGE field start on body section; None = continuous from document start.
|
|
page_number_start: int | None = None
|
|
# DOCX File → Info; None = take from md2gost.user.json (see doc_metadata).
|
|
doc_author_source: str | None = None
|
|
doc_author: str | None = None
|
|
doc_last_modified_by: str | None = None
|
|
doc_title: str | None = None
|
|
doc_subject: str | None = None
|
|
doc_keywords: str | None = None
|
|
doc_comments: str | None = None
|
|
doc_category: str | None = None
|
|
|
|
|
|
@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 timestamped_output_path(path: str) -> str:
|
|
"""report.docx → report_2026-09-05-10-05.docx (local time; seconds if taken)."""
|
|
directory, name = os.path.split(os.path.abspath(path))
|
|
stem, ext = os.path.splitext(name)
|
|
if ext.lower() != ".docx":
|
|
ext = ".docx"
|
|
stamp = datetime.now().strftime("%Y-%m-%d-%H-%M")
|
|
candidate = os.path.join(directory, f"{stem}_{stamp}{ext}")
|
|
if not os.path.exists(candidate):
|
|
return candidate
|
|
stamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
candidate = os.path.join(directory, f"{stem}_{stamp}{ext}")
|
|
n = 2
|
|
while os.path.exists(candidate):
|
|
candidate = os.path.join(directory, f"{stem}_{stamp}_{n}{ext}")
|
|
n += 1
|
|
return candidate
|
|
|
|
|
|
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 _apply_page_offset(document, req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> None:
|
|
from .page_geometry import apply_page_number_start
|
|
|
|
start = getattr(req, "page_number_start", None)
|
|
start_i: int | None
|
|
if start is None:
|
|
start_i = None
|
|
else:
|
|
try:
|
|
start_i = int(start)
|
|
except (TypeError, ValueError):
|
|
start_i = None
|
|
front = int(bool(had_title)) + int(bool(had_assignment))
|
|
# Always strip copied w:pgNumType/@w:start (landscape sections); optional body restart.
|
|
apply_page_number_start(document, start_i, front_sections=front)
|
|
|
|
|
|
def _page_offset_args(req: ConvertRequest, *, had_title: bool, had_assignment: bool) -> tuple[int | None, int]:
|
|
"""Return (page_number_start, front_sections) for post-Word patch."""
|
|
start = getattr(req, "page_number_start", None)
|
|
if start is None:
|
|
start_i = None
|
|
else:
|
|
try:
|
|
start_i = int(start)
|
|
except (TypeError, ValueError):
|
|
start_i = None
|
|
front = int(bool(had_title)) + int(bool(had_assignment))
|
|
return start_i, front
|
|
|
|
|
|
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)
|
|
|
|
# DOCX → Markdown (word2md) when input is .docx without --check-pages
|
|
if filename.lower().endswith(".docx"):
|
|
from word2md import ImportRequest, convert_docx
|
|
|
|
out = (req.output or "").strip() or None
|
|
if out and out.lower().endswith(".docx"):
|
|
out = str(Path(filename).with_suffix(".md"))
|
|
result = convert_docx(
|
|
ImportRequest(filename=filename, output=out),
|
|
log=emit,
|
|
)
|
|
return ConvertResult(
|
|
result.ok,
|
|
result.exit_code,
|
|
output_path=result.output_path,
|
|
message=result.message,
|
|
)
|
|
|
|
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()
|
|
|
|
from .title_page import (
|
|
build_title_docx,
|
|
load_info_conv,
|
|
merge_title_meta,
|
|
parse_and_strip_title_fence,
|
|
should_generate_title,
|
|
)
|
|
from .user_profile import load_user_profile
|
|
|
|
fence_fields, body_md = parse_and_strip_title_fence(md_text)
|
|
yaml_fields = load_info_conv(md_dir)
|
|
generated_title_path: str | None = None
|
|
title_path = (req.title or "").strip() or None
|
|
|
|
if not output:
|
|
output = default_output_path(filename)
|
|
|
|
if should_generate_title(
|
|
explicit_title=title_path,
|
|
fence_fields=fence_fields,
|
|
md_dir=md_dir,
|
|
enabled=bool(req.auto_title),
|
|
):
|
|
profile = load_user_profile()
|
|
student = (req.student or "").strip() or profile.student
|
|
group = (req.group or "").strip() or profile.group
|
|
if not student or not group:
|
|
return fail(
|
|
2,
|
|
"Для генерации титула нужны ФИО и группа студента "
|
|
"(GUI: Настройки → Студент…, или CLI: --student / --group).",
|
|
)
|
|
meta = merge_title_meta(
|
|
yaml_fields=yaml_fields,
|
|
fence_fields=fence_fields,
|
|
student=student,
|
|
group=group,
|
|
)
|
|
out_dir = os.path.dirname(os.path.abspath(output)) or md_dir
|
|
generated_title_path = os.path.join(out_dir, ".md2gost_title.docx")
|
|
try:
|
|
build_title_docx(meta, generated_title_path)
|
|
title_path = generated_title_path
|
|
emit(f"Титульный лист: {generated_title_path}")
|
|
except Exception as exc:
|
|
return fail(1, f"Не удалось сгенерировать титул: {exc}")
|
|
|
|
check_report = ""
|
|
if req.check or req.check_only:
|
|
issues = check_markdown(
|
|
body_md,
|
|
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,
|
|
)
|
|
|
|
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,
|
|
styles_path=req.styles_path or None,
|
|
markdown_text=body_md,
|
|
)
|
|
converter.convert()
|
|
document = converter.document
|
|
|
|
if title_path or req.assignment:
|
|
try:
|
|
from docxcompose.composer import Composer
|
|
except ImportError:
|
|
return fail(3, "Для титула/задания нужен пакет docxcompose", check_report)
|
|
from .styles import apply_style_config
|
|
|
|
style_cfg = converter.style_config
|
|
shell = Document(template)
|
|
apply_style_config(shell, style_cfg)
|
|
body = shell.element.body
|
|
for child in list(body):
|
|
if not child.tag.endswith("}sectPr"):
|
|
body.remove(child)
|
|
|
|
composer = Composer(shell)
|
|
if title_path:
|
|
composer.append(Document(title_path))
|
|
shell.add_page_break()
|
|
if req.assignment:
|
|
composer.append(Document(req.assignment))
|
|
shell.add_page_break()
|
|
composer.append(document)
|
|
document = composer.doc
|
|
apply_style_config(document, style_cfg)
|
|
_fix_front_matter_after_compose(
|
|
document,
|
|
had_title=bool(title_path),
|
|
had_assignment=bool(req.assignment),
|
|
)
|
|
|
|
_apply_page_offset(
|
|
document,
|
|
req,
|
|
had_title=bool(title_path),
|
|
had_assignment=bool(req.assignment),
|
|
)
|
|
|
|
from .doc_metadata import apply_document_metadata, resolve_document_metadata
|
|
from .user_profile import load_user_profile as _load_user_profile
|
|
|
|
profile = _load_user_profile()
|
|
apply_document_metadata(
|
|
document,
|
|
resolve_document_metadata(
|
|
stored=profile.metadata,
|
|
student=(req.student or "").strip() or profile.student,
|
|
author_source=req.doc_author_source,
|
|
author=req.doc_author,
|
|
last_modified_by=req.doc_last_modified_by,
|
|
title=req.doc_title,
|
|
subject=req.doc_subject,
|
|
keywords=req.doc_keywords,
|
|
comments=req.doc_comments,
|
|
category=req.doc_category,
|
|
),
|
|
)
|
|
try:
|
|
document.save(output)
|
|
except PermissionError:
|
|
alt = timestamped_output_path(output)
|
|
emit(f"Не удалось записать {output} (файл занят). Сохраняю как {alt}")
|
|
document.save(alt)
|
|
output = alt
|
|
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)
|
|
if generated_title_path and os.path.isfile(generated_title_path):
|
|
try:
|
|
os.remove(generated_title_path)
|
|
except OSError:
|
|
pass
|
|
|
|
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
|
|
from .page_geometry import patch_docx_page_starts
|
|
|
|
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
|
|
)
|
|
# Word Save often rewrites w:pgNumType/@w:start=\"1\" on every section — strip again.
|
|
start_i, front = _page_offset_args(
|
|
req,
|
|
had_title=bool(title_path),
|
|
had_assignment=bool(req.assignment),
|
|
)
|
|
try:
|
|
patch_docx_page_starts(abs_out, start_i, front_sections=front)
|
|
except Exception as exc:
|
|
emit(f"Не удалось восстановить сквозную нумерацию после Word: {exc}")
|
|
|
|
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)
|