"""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() # System Python's PYTHONHOME/PYTHONPATH confuse LO's embedded interpreter # ("Could not find platform independent libraries "). for key in ( "PYTHONHOME", "PYTHONPATH", "PYTHONUSERBASE", "VIRTUAL_ENV", "CONDA_PREFIX", ): env.pop(key, None) program_dir = _office_program_dir(install) path_parts = [str(worker_dir)] if program_dir is not None: path_parts.append(str(program_dir)) 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)