344 lines
11 KiB
Python
344 lines
11 KiB
Python
"""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)
|