"""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)