v0.5.6
Example document / build (push) Has been cancelled
Python application / build (push) Has been cancelled

This commit is contained in:
Igor20264
2026-09-22 18:49:09 +03:00
parent 7fc112e2ce
commit daa84f8bce
21 changed files with 2761 additions and 23 deletions
+343
View File
@@ -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)