Files
Igor20264 8223e0ac14
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled
v0.6.0 BigUpdate
2026-10-20 11:38:06 +03:00

210 lines
6.5 KiB
Python

"""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