v0.5.6
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-09-22 18:49:09 +03:00
parent 7fc112e2ce
commit daa84f8bce
21 changed files with 2761 additions and 23 deletions
+8 -3
View File
@@ -132,28 +132,33 @@ python -m md2gost report.md -o report.docx --toc manual
Наша оценка высоты строк ≠ вёрстка Word → если резать таблицу в скрипте, получается
mid-page «Продолжение…» (как было на 2.4). По умолчанию режим **`word`**: после save
Word COM режет по реальной пагинации. Без Word — укажите `off` или поставьте Word + pywin32.
живой движок вёрстки режет по реальной пагинации (`--office-engine`, по умолчанию `auto`:
Word COM → LibreOffice UNO → OpenOffice UNO). Без офиса — `off`.
| Режим | Поведение |
|--------|-----------|
| **`word`** (по умолчанию) | Как `off` при рендере; после save Word COM: `Split` + «Продолжение Таблицы N». Нужны Windows, Word, pywin32. Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). Таблицы с вертикальным merge (`^`): Word не умеет `Split` при vMerge — merge временно снимается, после разреза восстанавливается в обоих фрагментах |
| **`word`** (по умолчанию) | Как `off` при рендере; после save: `Split` + «Продолжение Таблицы N» через Word COM или LibreOffice/OpenOffice UNO (`--office-engine`). Шапка на продолжении не повторяется (вкл: `--table-repeat-header`). В Word при vMerge (`^`): merge временно снимается, после разреза восстанавливается в обоих фрагментах |
| **`off`** / **`soft`** | Одна таблица Word; перенос строк делает Word. Без автоподписи. Первая строка — повторяющаяся шапка (`tblHeader`). «Продолжение…» — вручную в markdown, если нужно |
| **`legacy`** | Режем по нашей оценке высоты + «Продолжение…» с `page_break_before` (могут быть дыры) |
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение…» (то же ограничение точности) |
```bash
python -m md2gost report.md -o report.docx --table-continuation word
python -m md2gost report.md -o report.docx --table-continuation word --office-engine libre
python -m md2gost report.md -o report.docx --table-continuation word --office-engine openoffice
python -m md2gost report.md -o report.docx --table-continuation off
python -m md2gost report.md -o report.docx --table-continuation caption
```
OpenOffice/LibreOffice UNO используют постоянный профиль `%LOCALAPPDATA%\md2gost\office-profiles\` (без мастера регистрации на каждый запуск). Если OpenOffice всё же просит регистрацию один раз — завершите её; дальше профиль сохранится.
### Продолжение листинга (`--listing-continuation`)
Те же режимы, что у таблиц. По умолчанию **`word`**.
| Режим | Поведение |
|--------|-----------|
| **`word`** (по умолчанию) | После save Word COM + «Продолжение Листинга N» (Windows + Word + pywin32) |
| **`word`** (по умолчанию) | После save + «Продолжение Листинга N» (Word COM или LibreOffice/OpenOffice UNO) |
| **`off`** / **`soft`** | Один блок кода; пагинацию делает Word. «Продолжение…» — вручную в markdown, если нужно |
| **`legacy`** | Режем по оценке высоты + «Продолжение Листинга N» с `page_break_before` |
| **`caption`** | Режем по оценке + явный PageBreak + «Продолжение Листинга N» |
+18 -4
View File
@@ -4,6 +4,7 @@ import sys
from .doc_metadata import AUTHOR_SOURCES, DEFAULT_DOC_COMMENTS
from .pipeline import ConvertRequest, convert, should_launch_gui
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
from .profiles import (
DEFAULT_HEADING_NUMBERING,
DEFAULT_TABLE_CONTINUATION,
@@ -69,8 +70,8 @@ def build_parser() -> ArgumentParser:
default=DEFAULT_TABLE_CONTINUATION,
help=(
"Таблицы длиннее страницы: "
"word — после сохранения Word COM режет по реальной пагинации + «Продолжение…» "
"(по умолчанию; нужны Windows, Word, pywin32); "
"word — после сохранения режет по реальной пагинации + «Продолжение…» "
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
"off/soft — одна таблица, пагинация Word, без авто«Продолжение»; "
"legacy/caption — режем по оценке высоты и вставляем «Продолжение…» "
"(оценка ≠ Word, возможны артефакты). "
@@ -83,8 +84,8 @@ def build_parser() -> ArgumentParser:
default=DEFAULT_LISTING_CONTINUATION,
help=(
"Листинги длиннее страницы: "
"word — после сохранения Word COM + «Продолжение Листинга…» "
"(по умолчанию; нужны Windows, Word, pywin32); "
"word — после сохранения + «Продолжение Листинга…» "
"(по умолчанию; движок: --office-engine auto|word|libre|openoffice); "
"off/soft — один блок, пагинация Word, без авто«Продолжение»; "
"legacy/caption — режем по оценке высоты и вставляем «Продолжение Листинга…» "
"(оценка ≠ Word, возможны артефакты). "
@@ -100,6 +101,18 @@ def build_parser() -> ArgumentParser:
"после разрыва. По умолчанию выкл."
),
)
parser.add_argument(
"--office-engine",
choices=OFFICE_ENGINE_MODES,
default=DEFAULT_OFFICE_ENGINE,
help=(
"Движок пост-разрыва таблиц/листингов при --table-continuation word: "
"auto — Word, иначе LibreOffice, иначе OpenOffice; "
"word — только Microsoft Word COM (Windows + pywin32); "
"libre / openoffice — UNO Writer (soffice + program/python). "
f"По умолчанию: {DEFAULT_OFFICE_ENGINE}."
),
)
parser.add_argument(
"--emdash-to-hyphen",
action=BooleanOptionalAction,
@@ -280,6 +293,7 @@ def request_from_args(args) -> ConvertRequest:
table_continuation=args.table_continuation,
listing_continuation=args.listing_continuation,
table_repeat_header=bool(getattr(args, "table_repeat_header", False)),
office_engine=getattr(args, "office_engine", DEFAULT_OFFICE_ENGINE),
emdash_to_hyphen=args.emdash_to_hyphen,
hr_pagebreak=args.hr_pagebreak,
title=args.title,
+2 -2
View File
@@ -423,8 +423,8 @@ def check_continuation_hints(
issues.append(Issue(
"table.continuation", "warning",
"Word сам не вставит «Продолжение Таблицы N» при переносе. "
"Варианты: --table-continuation word (точный разрыв через Word COM) "
"или caption (оценка высоты ≠ вёрстка Word, возможны артефакты).",
"Варианты: --table-continuation word (Word COM или LibreOffice/OpenOffice UNO; "
"см. --office-engine) или caption (оценка высоты ≠ вёрстка, возможны артефакты).",
))
# Long fenced code blocks (likely listings)
long_listing = False
+114 -9
View File
@@ -29,6 +29,7 @@ from .help_content import (
load_docs_catalog,
load_prompt_catalog,
)
from .office import DEFAULT_OFFICE_ENGINE, OFFICE_ENGINE_MODES
from .pipeline import ConvertRequest, convert, default_output_path, timestamped_output_path
from .profiles import (
DEFAULT_HEADING_NUMBERING,
@@ -64,6 +65,14 @@ TABLE_LABELS = {
"legacy": "Резать + «Продолжение»",
"caption": "Резать + разрыв страницы + «Продолжение»",
}
OFFICE_ENGINE_LABELS = {
"auto": "Авто: Word → LibreOffice",
"word": "Только Microsoft Word",
"libre": "LibreOffice Writer (UNO)",
}
GUI_OFFICE_ENGINE_MODES = tuple(
key for key in OFFICE_ENGINE_MODES if key != "openoffice"
)
FALLBACK_LABELS = {
"remote": "Авто: локально, иначе интернет (kroki.io)",
"local": "Только этот компьютер (без kroki.io)",
@@ -245,6 +254,7 @@ class Md2GostApp:
self.toc_var = tk.StringVar()
self.table_var = tk.StringVar()
self.listing_var = tk.StringVar()
self.office_engine_var = tk.StringVar()
self.emdash_var = tk.BooleanVar(value=False)
self.hr_pagebreak_var = tk.BooleanVar(value=False)
self.auto_title_var = tk.BooleanVar(value=False)
@@ -1067,10 +1077,18 @@ class Md2GostApp:
tip="off — Word сам переносит; word — Split + «Продолжение» после сборки (нужен Word)")
self._combo(main, 4, "Продолжение листингов", self.listing_var, _combo_values(TABLE_LABELS, LISTING_CONTINUATION_MODES),
tip="То же для листингов; word требует Windows + Word + pywin32")
self._combo(
main,
5,
"Движок пост-разрыва",
self.office_engine_var,
_combo_values(OFFICE_ENGINE_LABELS, GUI_OFFICE_ENGINE_MODES),
tip="При режиме word: Word COM или LibreOffice UNO (soffice)",
)
ttk.Label(main, text="Смещение страниц").grid(row=5, column=0, sticky="w", padx=(0, 8), pady=3)
ttk.Label(main, text="Смещение страниц").grid(row=6, column=0, sticky="w", padx=(0, 8), pady=3)
page_row = ttk.Frame(main)
page_row.grid(row=5, column=1, sticky="w", pady=3)
page_row.grid(row=6, column=1, sticky="w", pady=3)
page_entry = ttk.Entry(page_row, textvariable=self.page_start_var, width=8)
page_entry.pack(side=tk.LEFT)
_tip(
@@ -1085,7 +1103,7 @@ class Md2GostApp:
).pack(side=tk.LEFT)
flags = ttk.Frame(main)
flags.grid(row=6, column=0, columnspan=2, sticky="w", pady=(8, 0))
flags.grid(row=7, column=0, columnspan=2, sticky="w", pady=(8, 0))
checks = [
("Заменять «—» на «-»", self.emdash_var, "Типографское тире → дефис (методичка обычно требует «—»)"),
("--- → разрыв страницы", self.hr_pagebreak_var, "Строка --- станет page break в Word"),
@@ -1139,17 +1157,41 @@ class Md2GostApp:
log_frame = ttk.LabelFrame(outer, text="Журнал", padding=4)
log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 0))
log_toolbar = ttk.Frame(log_frame)
log_toolbar.pack(fill=tk.X, pady=(0, 4))
ttk.Button(
log_toolbar, text="Копировать выделение", command=self._log_copy_selection
).pack(side=tk.LEFT)
ttk.Button(
log_toolbar, text="Копировать весь журнал", command=self._log_copy_all
).pack(side=tk.LEFT, padx=(6, 0))
log_body = ttk.Frame(log_frame)
log_body.pack(fill=tk.BOTH, expand=True)
self.log = tk.Text(
log_frame, height=7, wrap=tk.WORD, relief=tk.FLAT,
log_body, height=7, wrap=tk.WORD, relief=tk.FLAT,
bg="#fffdf8", fg=INK, padx=8, pady=6,
font=("Consolas", 9) if os.name == "nt" else ("monospace", 9),
exportselection=True, undo=False,
)
scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
scroll = ttk.Scrollbar(log_body, command=self.log.yview)
self.log.configure(yscrollcommand=scroll.set)
self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.log.insert("1.0", "Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n")
self.log.configure(state=tk.DISABLED)
self.log.insert(
"1.0",
"Готово к работе. Перетащите .md (сборка DOCX) или .docx (импорт в Markdown).\n",
)
self._log_menu = tk.Menu(self.root, tearoff=0)
self._log_menu.add_command(
label="Копировать", command=self._log_copy_selection
)
self._log_menu.add_command(
label="Копировать весь журнал", command=self._log_copy_all
)
self.log.bind("<Button-3>", self._log_context_menu)
self.log.bind("<Control-Button-1>", self._log_context_menu)
self.log.bind("<Key>", self._log_keypress)
self.log.bind("<<Copy>>", self._log_copy_event)
def _build_schemes_tab(self, parent) -> None:
ttk.Label(
@@ -1697,6 +1739,10 @@ class Md2GostApp:
self.toc_var.set(_display(TOC_LABELS, req.toc_mode or DEFAULT_TOC_MODE))
self.table_var.set(_display(TABLE_LABELS, req.table_continuation or DEFAULT_TABLE_CONTINUATION))
self.listing_var.set(_display(TABLE_LABELS, req.listing_continuation or DEFAULT_LISTING_CONTINUATION))
office_engine = req.office_engine or DEFAULT_OFFICE_ENGINE
if office_engine not in GUI_OFFICE_ENGINE_MODES:
office_engine = DEFAULT_OFFICE_ENGINE
self.office_engine_var.set(_display(OFFICE_ENGINE_LABELS, office_engine))
self.fallback_var.set(_display(FALLBACK_LABELS, req.diagram_fallback or "remote"))
self.format_var.set(_display(FORMAT_LABELS, req.diagram_format or "png"))
scale = getattr(req, "diagram_scale", 2.0) or 2.0
@@ -1737,6 +1783,11 @@ class Md2GostApp:
toc_mode=_combo_key(self.toc_var.get(), TOC_MODES, DEFAULT_TOC_MODE),
table_continuation=_combo_key(self.table_var.get(), TABLE_CONTINUATION_MODES, DEFAULT_TABLE_CONTINUATION),
listing_continuation=_combo_key(self.listing_var.get(), LISTING_CONTINUATION_MODES, DEFAULT_LISTING_CONTINUATION),
office_engine=_combo_key(
self.office_engine_var.get(),
GUI_OFFICE_ENGINE_MODES,
DEFAULT_OFFICE_ENGINE,
),
emdash_to_hyphen=self.emdash_var.get(),
hr_pagebreak=self.hr_pagebreak_var.get(),
auto_title=self.auto_title_var.get(),
@@ -1860,17 +1911,71 @@ class Md2GostApp:
self._append_log(result.message or "Ошибка")
messagebox.showerror("md2gost", result.message or "Ошибка конвертации")
def _log_to_clipboard(self, text: str) -> None:
self.root.clipboard_clear()
self.root.clipboard_append(text)
self.root.update_idletasks()
def _log_copy_selection(self) -> None:
try:
if not self.log.tag_ranges(tk.SEL):
return
text = self.log.get(tk.SEL_FIRST, tk.SEL_LAST)
except tk.TclError:
return
if text:
self._log_to_clipboard(text)
def _log_copy_all(self) -> None:
text = self.log.get("1.0", tk.END).strip()
if text:
self._log_to_clipboard(text)
def _log_context_menu(self, event) -> str:
try:
self._log_menu.tk_popup(event.x_root, event.y_root)
finally:
self._log_menu.grab_release()
return "break"
def _log_copy_event(self, _event=None) -> str | None:
self._log_copy_selection()
return "break"
def _log_keypress(self, event) -> str | None:
"""Read-only log: allow selection/copy, block edits."""
if event.state & 0x4:
key = event.keysym.lower()
if key == "c":
self._log_copy_selection()
return "break"
if key == "a":
self.log.tag_add(tk.SEL, "1.0", tk.END)
self.log.mark_set(tk.INSERT, tk.END)
self.log.see(tk.INSERT)
return "break"
if key == "insert":
self._log_copy_selection()
return "break"
if event.keysym in (
"Left", "Right", "Up", "Down", "Home", "End",
"Prior", "Next", "Shift_L", "Shift_R", "Control_L", "Control_R",
"Tab", "ISO_Left_Tab",
):
return None
if not event.char:
return None
return "break"
def _append_log(self, text: str) -> None:
if not text:
return
if not hasattr(self, "log"):
return
self.log.configure(state=tk.NORMAL)
if not text.endswith("\n"):
text += "\n"
self.log.insert(tk.END, text)
self.log.see(tk.END)
self.log.configure(state=tk.DISABLED)
def run(self) -> None:
self.root.mainloop()
+19
View File
@@ -0,0 +1,19 @@
"""Office-suite backends for post-save table/listing continuation splits."""
from .detect import (
DEFAULT_OFFICE_ENGINE,
OFFICE_ENGINE_MODES,
OfficeInstall,
detect_office_install,
resolve_office_engine,
)
from .dispatch import fix_continuations_with_engine
__all__ = [
"DEFAULT_OFFICE_ENGINE",
"OFFICE_ENGINE_MODES",
"OfficeInstall",
"detect_office_install",
"resolve_office_engine",
"fix_continuations_with_engine",
]
+176
View File
@@ -0,0 +1,176 @@
"""Detect installed Word / LibreOffice / OpenOffice for post-save table splits."""
from __future__ import annotations
import os
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
from .uno_cli import soffice_cli_path
OFFICE_ENGINE_MODES = ("auto", "word", "libre", "openoffice")
DEFAULT_OFFICE_ENGINE = "auto"
@dataclass(frozen=True)
class OfficeInstall:
engine: str # "word" | "libre" | "openoffice"
label: str
soffice_path: str | None = None
python_path: str | None = None
def _program_dirs(kind: str) -> list[Path]:
roots: list[Path] = []
for env in ("ProgramFiles", "ProgramFiles(x86)"):
base = os.environ.get(env)
if base:
roots.append(Path(base))
if sys.platform != "win32":
roots.extend(
[
Path("/usr/lib/libreoffice"),
Path("/usr/lib64/libreoffice"),
Path("/opt/libreoffice"),
Path("/opt/openoffice4"),
Path("/Applications/LibreOffice.app/Contents"),
]
)
candidates: list[Path] = []
if kind == "libre":
for root in roots:
candidates.extend(
[
root / "LibreOffice" / "program",
root / "libreoffice" / "program",
root / "program",
root / "MacOS",
]
)
else:
for root in roots:
candidates.extend(
[
root / "OpenOffice 4" / "program",
root / "openoffice4" / "program",
]
)
return candidates
def _soffice_names() -> tuple[str, ...]:
if sys.platform == "win32":
# soffice.com = CLI/headless; soffice.exe opens the Start Center GUI.
return ("soffice.com", "soffice.exe")
return ("soffice", "soffice.bin")
def _python_names() -> tuple[str, ...]:
if sys.platform == "win32":
return ("python.exe",)
return ("python", "python3")
def _find_soffice_install(kind: str) -> OfficeInstall | None:
label = "LibreOffice" if kind == "libre" else "OpenOffice"
seen: set[str] = set()
for directory in _program_dirs(kind):
for soffice_name in _soffice_names():
soffice = directory / soffice_name
key = str(soffice.resolve()) if soffice.exists() else ""
if not key or key in seen:
continue
seen.add(key)
python_path = None
for py_name in _python_names():
candidate = directory / py_name
if candidate.is_file():
python_path = str(candidate)
break
if python_path is None:
continue
return OfficeInstall(
engine=kind,
label=label,
soffice_path=soffice_cli_path(str(soffice)),
python_path=python_path,
)
which = shutil.which("soffice") or shutil.which("soffice.bin")
if which:
program_dir = Path(which).resolve().parent
python_path = None
for py_name in _python_names():
candidate = program_dir / py_name
if candidate.is_file():
python_path = str(candidate)
break
if python_path:
lower = str(program_dir).lower()
detected = "openoffice" if "openoffice" in lower else "libre"
if kind != detected:
return None
return OfficeInstall(
engine=kind,
label=label,
soffice_path=soffice_cli_path(str(Path(which).resolve())),
python_path=python_path,
)
return None
def detect_office_install(engine: str) -> OfficeInstall | None:
"""Return install info for a concrete engine id."""
if engine == "word":
return _detect_word()
if engine == "libre":
return _find_soffice_install("libre")
if engine == "openoffice":
return _find_soffice_install("openoffice")
return None
def _detect_word() -> OfficeInstall | None:
if sys.platform != "win32":
return None
try:
import win32com.client # type: ignore
except ImportError:
return None
word = None
try:
word = win32com.client.DispatchEx("Word.Application")
return OfficeInstall(engine="word", label="Microsoft Word")
except Exception:
return None
finally:
if word is not None:
try:
word.Quit()
except Exception:
pass
def resolve_office_engine(mode: str) -> tuple[str | None, OfficeInstall | None]:
"""
Resolve ``mode`` to a concrete engine id and optional install record.
``auto`` tries Word, then LibreOffice, then OpenOffice.
"""
if mode not in OFFICE_ENGINE_MODES:
mode = DEFAULT_OFFICE_ENGINE
if mode == "auto":
for candidate in ("word", "libre", "openoffice"):
install = detect_office_install(candidate)
if install is not None:
return candidate, install
return None, None
install = detect_office_install(mode)
if install is None:
return None, None
return mode, install
+59
View File
@@ -0,0 +1,59 @@
"""Route table/listing continuation post-processing to Word or UNO backends."""
from __future__ import annotations
from ..word_fix import FixResult, fix_continuations
from .detect import DEFAULT_OFFICE_ENGINE, resolve_office_engine
from .uno_fix import fix_continuations_uno
def fix_continuations_with_engine(
path: str,
*,
engine: str = DEFAULT_OFFICE_ENGINE,
tables: bool = True,
listings: bool = True,
repeat_header: bool = False,
) -> FixResult:
"""
Split cross-page tables/listings using the selected office engine.
``engine`` is ``auto|word|libre|openoffice``. On missing backend returns
``ok=False`` with a reason; conversion should continue.
"""
resolved, install = resolve_office_engine(engine)
if resolved is None or install is None:
if engine == "auto":
return FixResult(
False,
message=(
"Пост-разрыв таблиц пропущен: не найден Word, LibreOffice "
"или OpenOffice."
),
)
labels = {
"word": "Microsoft Word (Windows + pywin32)",
"libre": "LibreOffice (soffice + program/python)",
"openoffice": "OpenOffice (soffice + program/python)",
}
return FixResult(
False,
message=f"Пост-разрыв таблиц пропущен: нужен {labels.get(engine, engine)}.",
)
if resolved == "word":
return fix_continuations(
path,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
assert install.soffice_path and install.python_path
return fix_continuations_uno(
path,
install=install,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
+70
View File
@@ -0,0 +1,70 @@
"""Open a finished DOCX with the same office engine used for post-split."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from .detect import OfficeInstall, resolve_office_engine
from .uno_cli import soffice_cli_path
def open_document_with_engine(
path: str,
*,
engine: str,
install: OfficeInstall | None = None,
) -> None:
"""Open *path* in Word / LibreOffice / OpenOffice instead of the default app."""
abs_path = os.path.abspath(path)
resolved, detected = resolve_office_engine(engine) if install is None else (engine, install)
if resolved == "word" and sys.platform == "win32":
if _open_in_word(abs_path):
return
if resolved in ("libre", "openoffice") and detected and detected.soffice_path:
if _open_in_soffice(detected.soffice_path, abs_path):
return
_open_with_os_default(abs_path)
def _open_in_word(path: str) -> bool:
try:
import win32com.client # type: ignore
except ImportError:
return False
word = None
try:
word = win32com.client.DispatchEx("Word.Application")
word.Visible = True
word.Documents.Open(path)
return True
except Exception:
return False
finally:
pass # keep Word open for the user
def _open_in_soffice(soffice_path: str, path: str) -> bool:
try:
cli = soffice_cli_path(soffice_path)
subprocess.Popen(
[cli, os.path.abspath(path)],
cwd=str(Path(cli).parent),
)
return True
except Exception:
return False
def _open_with_os_default(path: str) -> None:
import platform
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))
+125
View File
@@ -0,0 +1,125 @@
"""Headless soffice CLI helpers (convert/import) for UNO post-process."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from .uno_helpers import file_url_from_path
from .uno_profile import ensure_profile_seeded, profile_dir
def soffice_cli_path(soffice_path: str) -> str:
"""On Windows prefer ``soffice.com`` — ``soffice.exe`` opens the Start Center GUI."""
path = Path(soffice_path)
if sys.platform == "win32":
com = path.with_name("soffice.com")
if com.is_file():
return str(com)
return str(path)
def _base_args(soffice_path: str, profile_root: Path) -> list[str]:
ensure_profile_seeded(profile_root)
return [
soffice_cli_path(soffice_path),
f"-env:UserInstallation={file_url_from_path(str(profile_root))}",
"--headless",
"--nologo",
"--norestore",
"--nodefault",
"--nofirststartwizard",
"--nolockcheck",
]
def convert_file(
soffice_path: str,
*,
engine: str,
src: str,
out_dir: str,
convert_to: str,
timeout_s: float = 300.0,
) -> None:
"""Run ``soffice --convert-to …`` and wait until it finishes."""
profile_root = profile_dir(engine)
cmd = [
*_base_args(soffice_path, profile_root),
"--convert-to",
convert_to,
"--outdir",
os.path.abspath(out_dir),
os.path.abspath(src),
]
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_s,
cwd=str(Path(soffice_path).parent),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
)
if completed.returncode != 0:
tail = (completed.stderr or completed.stdout or "").strip()
if len(tail) > 500:
tail = tail[-500:]
raise RuntimeError(
f"convert-to {convert_to} failed (code {completed.returncode})"
+ (f": {tail}" if tail else "")
)
stem = Path(src).stem
expected = Path(out_dir) / f"{stem}.docx"
if convert_to.startswith("docx") and not expected.is_file():
found = sorted(Path(out_dir).glob("*.docx"))
if len(found) == 1:
return
raise RuntimeError(
f"convert-to {convert_to}: output missing ({expected.name})"
+ (f", got {[p.name for p in found]}" if found else "")
)
def import_docx_to_odt(soffice_path: str, *, engine: str, docx_path: str, out_dir: str) -> Path:
"""OpenOffice imports DOCX more reliably via native ODT round-trip."""
for spec in ("odt", 'odt:"writer8"'):
try:
convert_file(
soffice_path,
engine=engine,
src=docx_path,
out_dir=out_dir,
convert_to=spec,
)
odt = Path(out_dir) / f"{Path(docx_path).stem}.odt"
if odt.is_file():
return odt
except RuntimeError:
continue
raise RuntimeError(f"OpenOffice: не удалось импортировать DOCX → ODT: {docx_path}")
def export_odt_to_docx(soffice_path: str, *, engine: str, odt_path: str, out_dir: str) -> Path:
# OpenOffice on Windows treats spaces in ``docx:MS Word 2007 XML`` as extra
# file arguments (Word / 2007 / XML). Use plain ``docx`` there.
if engine == "openoffice":
specs = ("docx",)
else:
specs = ("docx", "MS Word 2007 XML")
for spec in specs:
try:
convert_file(
soffice_path,
engine=engine,
src=odt_path,
out_dir=out_dir,
convert_to=spec,
)
docx = Path(out_dir) / f"{Path(odt_path).stem}.docx"
if docx.is_file():
return docx
except RuntimeError:
continue
raise RuntimeError(f"OpenOffice: не удалось экспортировать ODT → DOCX: {odt_path}")
+343
View File
@@ -0,0 +1,343 @@
"""Host-side launcher for LibreOffice/OpenOffice UNO table-split worker."""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from ..word_fix import FixResult
from .detect import OfficeInstall
from .uno_cli import export_odt_to_docx, soffice_cli_path
from .uno_helpers import file_url_from_path
from .uno_profile import ensure_profile_seeded, profile_dir
_STARTUP_TIMEOUT_S = 90.0
_WORKER_TIMEOUT_S = 600.0
_WORKER_FILES = ("uno_worker.py", "uno_helpers.py")
def _office_program_dir(install: OfficeInstall) -> Path | None:
"""Directory with ``uno.py`` / ``pyuno`` (LibreOffice/OpenOffice ``program``)."""
for candidate in (install.python_path, install.soffice_path):
if not candidate:
continue
program = Path(candidate).resolve().parent
if (program / "uno.py").is_file() or (program / "pyuno.pyd").is_file():
return program
return None
def _uno_worker_env(worker_dir: Path, install: OfficeInstall) -> tuple[dict[str, str], Path]:
"""Build env + cwd so bundled ``program/python.exe`` can ``import uno``."""
env = os.environ.copy()
program_dir = _office_program_dir(install)
path_parts = [str(worker_dir)]
if program_dir is not None:
path_parts.append(str(program_dir))
existing = env.get("PYTHONPATH", "")
if existing:
path_parts.append(existing)
env["PYTHONPATH"] = os.pathsep.join(path_parts)
env.setdefault("PYTHONIOENCODING", "utf-8")
if install.soffice_path:
env["MD2GOST_SOFFICE"] = soffice_cli_path(install.soffice_path)
cwd = program_dir if program_dir is not None else worker_dir
return env, cwd
def _office_dir() -> Path:
return Path(__file__).resolve().parent
def _bundled_office_dir() -> Path | None:
if not getattr(sys, "frozen", False):
return None
meipass = getattr(sys, "_MEIPASS", None)
if not meipass:
return None
bundled = Path(meipass) / "md2gost" / "office"
if (bundled / "uno_worker.py").is_file():
return bundled
return None
def _prepare_worker_dir() -> Path:
"""Return a directory with uno_worker.py (+ helpers) on disk for LO python."""
bundled = _bundled_office_dir()
if bundled is not None:
target = Path(tempfile.mkdtemp(prefix="md2gost-uno-worker-"))
for name in _WORKER_FILES:
src = bundled / name
if not src.is_file():
raise FileNotFoundError(f"UNO worker bundle incomplete: missing {src}")
shutil.copy2(src, target / name)
return target
dev_dir = _office_dir()
worker = dev_dir / "uno_worker.py"
if not worker.is_file():
raise FileNotFoundError(f"UNO worker не найден: {worker}")
return dev_dir
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_for_port(port: int, _proc: subprocess.Popen[str], timeout_s: float) -> bool:
"""Wait until UNO port accepts connections.
On Windows ``soffice.exe`` is a launcher that exits after spawning
``soffice.bin``; do not treat wrapper exit as failure.
"""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
return True
except OSError:
time.sleep(0.15)
return False
def _parse_worker_output(stdout: str) -> FixResult | None:
for line in reversed(stdout.splitlines()):
line = line.strip()
if not line.startswith("{"):
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
return FixResult(
ok=bool(payload.get("ok")),
splits=int(payload.get("splits", 0)),
skipped=int(payload.get("skipped", 0)),
message=str(payload.get("message", "")),
details=list(payload.get("details", [])),
odt_temp=payload.get("odt_temp") or None,
)
return None
def _export_odt_temp_to_docx(
install: OfficeInstall, odt_temp: str, docx_path: str
) -> None:
"""Convert worker-side ODT (ASCII temp) back to target DOCX on the host (Py3)."""
time.sleep(0.5)
bridge_dir = Path(tempfile.mkdtemp(prefix="md2gost-oo-export-"))
local_odt = bridge_dir / "export.odt"
try:
shutil.copy2(odt_temp, local_odt)
engines = [install]
if install.engine == "openoffice":
from .detect import detect_office_install
libre = detect_office_install("libre")
if libre is not None and libre.soffice_path:
engines.append(libre)
last_exc: Exception | None = None
out_docx: Path | None = None
for eng in engines:
try:
out_docx = export_odt_to_docx(
eng.soffice_path or "",
engine=eng.engine,
odt_path=str(local_odt),
out_dir=str(bridge_dir),
)
break
except Exception as exc:
last_exc = exc
if out_docx is None:
raise RuntimeError(str(last_exc or "ODT export failed"))
shutil.copy2(out_docx, docx_path)
finally:
shutil.rmtree(bridge_dir, ignore_errors=True)
try:
os.remove(odt_temp)
except OSError:
pass
def _soffice_server_cmd(soffice_path: str, profile_root: Path, port: int) -> list[str]:
ensure_profile_seeded(profile_root)
return [
soffice_cli_path(soffice_path),
f"-env:UserInstallation={file_url_from_path(str(profile_root))}",
"-headless",
"-invisible",
"-nologo",
"-norestore",
"-nodefault",
"-nofirststartwizard",
"-nolockcheck",
f"-accept=socket,host=127.0.0.1,port={port};urp;",
]
def _run_uno_worker(
*,
install: OfficeInstall,
worker_dir: Path,
worker_path: str,
tables: bool,
listings: bool,
repeat_header: bool,
) -> FixResult:
soffice = install.soffice_path
python_exe = install.python_path
if not soffice or not python_exe:
return FixResult(
False,
message=f"Пост-разрыв ({install.label}): не задан путь к soffice/python.",
)
port = _free_port()
user_profile = profile_dir(install.engine)
proc: subprocess.Popen[str] | None = None
try:
proc = subprocess.Popen(
_soffice_server_cmd(soffice, user_profile, port),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
)
if not _wait_for_port(port, proc, _STARTUP_TIMEOUT_S):
hint = ""
if install.engine == "openoffice":
hint = (
" Закройте все окна OpenOffice (в т.ч. «Центр запуска») "
"и повторите."
)
return FixResult(
False,
message=(
f"Пост-разрыв ({install.label}): не удалось запустить soffice "
f"на порту {port}.{hint}"
),
)
env, worker_cwd = _uno_worker_env(worker_dir, install)
worker_cmd = [
python_exe,
str(worker_dir / "uno_worker.py"),
"--port",
str(port),
"--path",
worker_path,
"--engine",
install.engine,
"--tables",
"1" if tables else "0",
"--listings",
"1" if listings else "0",
"--repeat-header",
"1" if repeat_header else "0",
]
completed = subprocess.run(
worker_cmd,
capture_output=True,
timeout=_WORKER_TIMEOUT_S,
env=env,
cwd=str(worker_cwd),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
)
stdout = (completed.stdout or b"").decode("utf-8", errors="replace")
stderr = (completed.stderr or b"").decode("utf-8", errors="replace")
parsed = _parse_worker_output(stdout)
if parsed is not None:
if parsed.ok and parsed.odt_temp:
try:
_export_odt_temp_to_docx(install, parsed.odt_temp, worker_path)
parsed.details.append("bridge: ODT→DOCX (host)")
parsed.odt_temp = None
except Exception as exc:
if parsed.splits > 0:
msg = (
f"Пост-разрыв ({install.label}): разрезов {parsed.splits}, "
f"но экспорт DOCX не удался: {exc}"
)
else:
msg = (
f"Пост-разрыв ({install.label}): "
f"экспорт DOCX не удался: {exc}"
)
return FixResult(
False,
splits=parsed.splits,
skipped=parsed.skipped,
message=msg,
details=parsed.details,
)
return parsed
tail = (stderr or stdout or "").strip()
if len(tail) > 400:
tail = tail[-400:]
return FixResult(
False,
message=(
f"Пост-разрыв ({install.label}) не удался "
f"(код {completed.returncode})."
+ (f" {tail}" if tail else "")
),
)
except subprocess.TimeoutExpired:
return FixResult(
False,
message=f"Пост-разрыв ({install.label}): превышено время ожидания UNO worker.",
)
finally:
if proc is not None:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
def fix_continuations_uno(
path: str,
*,
install: OfficeInstall,
tables: bool = True,
listings: bool = True,
repeat_header: bool = False,
) -> FixResult:
"""Run UNO worker via LibreOffice/OpenOffice bundled Python."""
abs_path = os.path.abspath(path)
if not os.path.isfile(abs_path):
return FixResult(False, message=f"Файл не найден: {abs_path}")
worker_dir: Path | None = None
try:
worker_dir = _prepare_worker_dir()
except FileNotFoundError as exc:
return FixResult(False, message=str(exc))
try:
return _run_uno_worker(
install=install,
worker_dir=worker_dir,
worker_path=abs_path,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
finally:
if worker_dir is not None and _bundled_office_dir() is not None:
shutil.rmtree(worker_dir, ignore_errors=True)
+209
View File
@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""Pure table-split helpers for UNO worker (Py2.7 + Py3, standalone, no pywin32)."""
from __future__ import print_function, unicode_literals
import os
import re
import sys
try:
from urllib.parse import quote
except ImportError:
from urllib import quote as _urllib_quote
def quote(value, safe="/:@"):
if isinstance(value, bytes):
value = value.decode("utf-8")
return _urllib_quote(value.encode("utf-8"), safe)
DOCX_FILTER_CANDIDATES = {
"libre": ("Office Open XML Text", "MS Word 2007 XML", "Word 2007 XML"),
"openoffice": (
"MS Word 2007 XML",
"Office Open XML Text",
"Word 2007 XML",
),
}
DOCX_SAVE_FILTER_SPECS = {
"libre": (
"Office Open XML Text",
"MS Word 2007 XML",
),
"openoffice": (
"MS Word 2007 XML",
"Office Open XML Text",
),
}
MAX_PASSES = 20
# UNO enum numeric values (``from com.sun.star.*`` is unavailable in some LO/OO Pythons).
BREAK_NONE = 0
BREAK_PAGE_BEFORE = 3
CONTROL_PARAGRAPH_BREAK = 0
_UNO_MISSING = object()
try:
_TEXT_TYPE = unicode
except NameError:
_TEXT_TYPE = str
def as_unicode(value):
"""Coerce value to a Unicode string (safe on Python 2.7 + 3)."""
if value is None:
return _TEXT_TYPE()
if isinstance(value, _TEXT_TYPE):
return value
if isinstance(value, bytes):
for encoding in ("utf-8", sys.getfilesystemencoding(), "mbcs"):
if not encoding:
continue
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", "replace")
try:
return _TEXT_TYPE(value)
except Exception:
return _TEXT_TYPE(repr(value))
def uno_error_text(exc):
"""Readable UNO/Python exception text (Py2 often has empty str(exc))."""
for attr in ("Message", "Context", "Name"):
try:
val = getattr(exc, attr, None)
if val:
text = as_unicode(val)
if text:
return text
except Exception:
continue
text = as_unicode(exc)
if text:
return text
return repr(exc)
def uno_call(obj, base, default=_UNO_MISSING):
"""Invoke UNO getter ``getBase()`` or read attribute ``Base`` (OO Py2 vs LO Py3)."""
getter = "get" + base
for access in (getter, base):
try:
member = getattr(obj, access)
return member() if callable(member) else member
except Exception:
continue
if default is not _UNO_MISSING:
return default
raise AttributeError(base)
_RE_FLAGS = re.IGNORECASE
if sys.version_info[0] < 3:
_RE_FLAGS |= re.UNICODE
_CAPTION_RE = re.compile(
r"^(?:Продолжение\s+)?"
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
r"(?P<number>[\d.]+)",
_RE_FLAGS,
)
_CONTINUATION_RE = re.compile(
r"^Продолжение\s+(?:Таблицы|Листинга)\b", _RE_FLAGS
)
class CaptionInfo(object):
__slots__ = ("kind", "number", "is_continuation")
def __init__(self, kind, number, is_continuation=False):
self.kind = kind
self.number = number
self.is_continuation = is_continuation
def parse_caption_text(text):
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
if not raw:
return None
first = raw.split("\n", 1)[0].strip()
m = _CAPTION_RE.match(first)
if not m:
return None
kind_raw = m.group("kind").lower()
if kind_raw.startswith("табл"):
kind = "table"
elif kind_raw.startswith("лист"):
kind = "listing"
else:
return None
return CaptionInfo(
kind=kind,
number=m.group("number"),
is_continuation=bool(_CONTINUATION_RE.match(first)),
)
def find_page_break_row(page_numbers):
if len(page_numbers) < 2:
return None
for i in range(1, len(page_numbers)):
if page_numbers[i] > page_numbers[i - 1]:
row_1based = i + 1
if row_1based <= 1:
return None
return row_1based
return None
def is_orphan_header_break(break_at, had_header):
return bool(had_header and break_at == 2)
def is_caption_orphaned(caption_page, first_row_page):
if caption_page is None or first_row_page is None:
return False
return caption_page < first_row_page
def continuation_label(kind, number):
if kind == "listing":
return "Продолжение Листинга {0}".format(number)
return "Продолжение Таблицы {0}".format(number)
def caption_style_name(kind):
return "Caption Listing" if kind == "listing" else "Название таблицы"
def file_url_from_path(path):
abs_path = os.path.abspath(path)
posix = abs_path.replace("\\", "/")
if len(posix) >= 2 and posix[1] == ":":
return "file:///" + quote(posix, safe="/:@")
return "file://" + quote(posix, safe="/:@")
def path_to_file_url(uno, path):
abs_path = os.path.abspath(path)
try:
url = uno.systemPathToFileUrl(abs_path)
if url:
return url
except Exception:
pass
return file_url_from_path(abs_path)
def docx_filter_candidates(engine):
return DOCX_FILTER_CANDIDATES.get(engine, DOCX_FILTER_CANDIDATES["libre"])
def docx_save_filter_specs(engine):
return DOCX_SAVE_FILTER_SPECS.get(engine, DOCX_SAVE_FILTER_SPECS["libre"])
+41
View File
@@ -0,0 +1,41 @@
"""Persistent LibreOffice/OpenOffice user profiles for headless UNO runs."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
# Marks first-start / registration wizard as done (OpenOffice + LibreOffice).
_REGISTRY_SEED = """<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="Setup" oor:package="org.openoffice.Office">
<node oor:name="Office">
<prop oor:name="ooSetupCompleted" oor:type="xs:boolean">
<value>true</value>
</prop>
<prop oor:name="FirstStartWizardCompleted" oor:type="xs:boolean">
<value>true</value>
</prop>
</node>
</oor:component-data>
"""
def profile_dir(engine: str) -> Path:
"""Writable profile reused across conversions (avoids OO registration every run)."""
root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or tempfile.gettempdir()
path = Path(root) / "md2gost" / "office-profiles" / engine
path.mkdir(parents=True, exist_ok=True)
return path
def ensure_profile_seeded(profile_root: Path) -> None:
"""Pre-seed registry so OpenOffice does not show the registration wizard."""
marker = profile_root / ".md2gost-seeded"
user_dir = profile_root / "user"
user_dir.mkdir(parents=True, exist_ok=True)
xcu = user_dir / "registrymodifications.xcu"
if not xcu.is_file():
xcu.write_text(_REGISTRY_SEED, encoding="utf-8")
if not marker.is_file():
marker.write_text("1", encoding="utf-8")
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -17,6 +17,7 @@ from docx import Document
from . import package_dir
from .checker import check_markdown, format_report
from .converter import Converter
from .office import DEFAULT_OFFICE_ENGINE
from .profiles import (
DEFAULT_HEADING_NUMBERING,
DEFAULT_TABLE_CONTINUATION,
@@ -60,6 +61,7 @@ class ConvertRequest:
open_when_done: bool = False
check_pages: bool = False
table_repeat_header: bool = False
office_engine: str = DEFAULT_OFFICE_ENGINE
# 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).
@@ -425,12 +427,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
abs_out = os.path.abspath(output)
emit(f"Generated document: {abs_out}")
split_engine: str | None = None
split_install = None
if req.table_continuation == "word" or req.listing_continuation == "word":
from .word_fix import fix_continuations
from .office import fix_continuations_with_engine, resolve_office_engine
from .page_geometry import patch_docx_page_starts
fix = fix_continuations(
split_engine, split_install = resolve_office_engine(req.office_engine)
fix = fix_continuations_with_engine(
abs_out,
engine=req.office_engine,
tables=(req.table_continuation == "word"),
listings=(req.listing_continuation == "word"),
repeat_header=bool(req.table_repeat_header),
@@ -463,7 +469,16 @@ def convert(req: ConvertRequest, log: LogFn | None = None) -> ConvertResult:
if req.debug or req.open_when_done:
try:
open_document(abs_out)
if split_engine in ("word", "libre", "openoffice"):
from .office.open_doc import open_document_with_engine
open_document_with_engine(
abs_out,
engine=split_engine,
install=split_install,
)
else:
open_document(abs_out)
except Exception as exc:
emit(f"Не удалось открыть файл: {exc}")
+1
View File
@@ -39,6 +39,7 @@ class FixResult:
skipped: int = 0
message: str = ""
details: list[str] = field(default_factory=list)
odt_temp: str | None = None
def parse_caption_text(text: str) -> CaptionInfo | None: