v0.5.6
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user