Files
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

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)