v0.5.6
This commit is contained in:
+8
-3
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user