"""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 # First successful ``auto`` resolution is reused for the process lifetime so a # second convert does not re-probe Word and fall through to LibreOffice. _cached_auto_resolve: tuple[str, OfficeInstall] | 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 _word_com_available() -> bool: """True when pywin32 is importable and Word.Application is registered.""" if sys.platform != "win32": return False try: import win32com.client # type: ignore # noqa: F401 except ImportError: return False try: import winreg with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Word.Application"): pass return True except OSError: return False def _detect_word() -> OfficeInstall | None: if not _word_com_available(): return None return OfficeInstall(engine="word", label="Microsoft Word") def _install_still_available(install: OfficeInstall) -> bool: if install.engine == "word": return _word_com_available() if install.soffice_path and install.python_path: return Path(install.soffice_path).is_file() and Path(install.python_path).is_file() return False def clear_office_engine_cache() -> None: """Drop cached ``auto`` resolution (tests / long-running hosts).""" global _cached_auto_resolve _cached_auto_resolve = None 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. The first successful ``auto`` pick is cached for subsequent calls in the same process. """ global _cached_auto_resolve if mode not in OFFICE_ENGINE_MODES: mode = DEFAULT_OFFICE_ENGINE if mode == "auto": if _cached_auto_resolve is not None: cached_engine, cached_install = _cached_auto_resolve if _install_still_available(cached_install): return _cached_auto_resolve _cached_auto_resolve = None for candidate in ("word", "libre", "openoffice"): install = detect_office_install(candidate) if install is not None: _cached_auto_resolve = (candidate, install) return candidate, install _cached_auto_resolve = None return None, None install = detect_office_install(mode) if install is None: return None, None return mode, install