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
+19
View File
@@ -0,0 +1,19 @@
"""Office-suite backends for post-save table/listing continuation splits."""
from .detect import (
DEFAULT_OFFICE_ENGINE,
OFFICE_ENGINE_MODES,
OfficeInstall,
detect_office_install,
resolve_office_engine,
)
from .dispatch import fix_continuations_with_engine
__all__ = [
"DEFAULT_OFFICE_ENGINE",
"OFFICE_ENGINE_MODES",
"OfficeInstall",
"detect_office_install",
"resolve_office_engine",
"fix_continuations_with_engine",
]
+176
View File
@@ -0,0 +1,176 @@
"""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
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 _detect_word() -> OfficeInstall | None:
if sys.platform != "win32":
return None
try:
import win32com.client # type: ignore
except ImportError:
return None
word = None
try:
word = win32com.client.DispatchEx("Word.Application")
return OfficeInstall(engine="word", label="Microsoft Word")
except Exception:
return None
finally:
if word is not None:
try:
word.Quit()
except Exception:
pass
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.
"""
if mode not in OFFICE_ENGINE_MODES:
mode = DEFAULT_OFFICE_ENGINE
if mode == "auto":
for candidate in ("word", "libre", "openoffice"):
install = detect_office_install(candidate)
if install is not None:
return candidate, install
return None, None
install = detect_office_install(mode)
if install is None:
return None, None
return mode, install
+59
View File
@@ -0,0 +1,59 @@
"""Route table/listing continuation post-processing to Word or UNO backends."""
from __future__ import annotations
from ..word_fix import FixResult, fix_continuations
from .detect import DEFAULT_OFFICE_ENGINE, resolve_office_engine
from .uno_fix import fix_continuations_uno
def fix_continuations_with_engine(
path: str,
*,
engine: str = DEFAULT_OFFICE_ENGINE,
tables: bool = True,
listings: bool = True,
repeat_header: bool = False,
) -> FixResult:
"""
Split cross-page tables/listings using the selected office engine.
``engine`` is ``auto|word|libre|openoffice``. On missing backend returns
``ok=False`` with a reason; conversion should continue.
"""
resolved, install = resolve_office_engine(engine)
if resolved is None or install is None:
if engine == "auto":
return FixResult(
False,
message=(
"Пост-разрыв таблиц пропущен: не найден Word, LibreOffice "
"или OpenOffice."
),
)
labels = {
"word": "Microsoft Word (Windows + pywin32)",
"libre": "LibreOffice (soffice + program/python)",
"openoffice": "OpenOffice (soffice + program/python)",
}
return FixResult(
False,
message=f"Пост-разрыв таблиц пропущен: нужен {labels.get(engine, engine)}.",
)
if resolved == "word":
return fix_continuations(
path,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
assert install.soffice_path and install.python_path
return fix_continuations_uno(
path,
install=install,
tables=tables,
listings=listings,
repeat_header=repeat_header,
)
+70
View File
@@ -0,0 +1,70 @@
"""Open a finished DOCX with the same office engine used for post-split."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from .detect import OfficeInstall, resolve_office_engine
from .uno_cli import soffice_cli_path
def open_document_with_engine(
path: str,
*,
engine: str,
install: OfficeInstall | None = None,
) -> None:
"""Open *path* in Word / LibreOffice / OpenOffice instead of the default app."""
abs_path = os.path.abspath(path)
resolved, detected = resolve_office_engine(engine) if install is None else (engine, install)
if resolved == "word" and sys.platform == "win32":
if _open_in_word(abs_path):
return
if resolved in ("libre", "openoffice") and detected and detected.soffice_path:
if _open_in_soffice(detected.soffice_path, abs_path):
return
_open_with_os_default(abs_path)
def _open_in_word(path: str) -> bool:
try:
import win32com.client # type: ignore
except ImportError:
return False
word = None
try:
word = win32com.client.DispatchEx("Word.Application")
word.Visible = True
word.Documents.Open(path)
return True
except Exception:
return False
finally:
pass # keep Word open for the user
def _open_in_soffice(soffice_path: str, path: str) -> bool:
try:
cli = soffice_cli_path(soffice_path)
subprocess.Popen(
[cli, os.path.abspath(path)],
cwd=str(Path(cli).parent),
)
return True
except Exception:
return False
def _open_with_os_default(path: str) -> None:
import platform
system = platform.system()
if system == "Darwin":
subprocess.call(("open", path))
elif system == "Windows":
os.startfile(path) # type: ignore[attr-defined]
else:
subprocess.call(("xdg-open", path))
+125
View File
@@ -0,0 +1,125 @@
"""Headless soffice CLI helpers (convert/import) for UNO post-process."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from .uno_helpers import file_url_from_path
from .uno_profile import ensure_profile_seeded, profile_dir
def soffice_cli_path(soffice_path: str) -> str:
"""On Windows prefer ``soffice.com`` — ``soffice.exe`` opens the Start Center GUI."""
path = Path(soffice_path)
if sys.platform == "win32":
com = path.with_name("soffice.com")
if com.is_file():
return str(com)
return str(path)
def _base_args(soffice_path: str, profile_root: Path) -> list[str]:
ensure_profile_seeded(profile_root)
return [
soffice_cli_path(soffice_path),
f"-env:UserInstallation={file_url_from_path(str(profile_root))}",
"--headless",
"--nologo",
"--norestore",
"--nodefault",
"--nofirststartwizard",
"--nolockcheck",
]
def convert_file(
soffice_path: str,
*,
engine: str,
src: str,
out_dir: str,
convert_to: str,
timeout_s: float = 300.0,
) -> None:
"""Run ``soffice --convert-to …`` and wait until it finishes."""
profile_root = profile_dir(engine)
cmd = [
*_base_args(soffice_path, profile_root),
"--convert-to",
convert_to,
"--outdir",
os.path.abspath(out_dir),
os.path.abspath(src),
]
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_s,
cwd=str(Path(soffice_path).parent),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0,
)
if completed.returncode != 0:
tail = (completed.stderr or completed.stdout or "").strip()
if len(tail) > 500:
tail = tail[-500:]
raise RuntimeError(
f"convert-to {convert_to} failed (code {completed.returncode})"
+ (f": {tail}" if tail else "")
)
stem = Path(src).stem
expected = Path(out_dir) / f"{stem}.docx"
if convert_to.startswith("docx") and not expected.is_file():
found = sorted(Path(out_dir).glob("*.docx"))
if len(found) == 1:
return
raise RuntimeError(
f"convert-to {convert_to}: output missing ({expected.name})"
+ (f", got {[p.name for p in found]}" if found else "")
)
def import_docx_to_odt(soffice_path: str, *, engine: str, docx_path: str, out_dir: str) -> Path:
"""OpenOffice imports DOCX more reliably via native ODT round-trip."""
for spec in ("odt", 'odt:"writer8"'):
try:
convert_file(
soffice_path,
engine=engine,
src=docx_path,
out_dir=out_dir,
convert_to=spec,
)
odt = Path(out_dir) / f"{Path(docx_path).stem}.odt"
if odt.is_file():
return odt
except RuntimeError:
continue
raise RuntimeError(f"OpenOffice: не удалось импортировать DOCX → ODT: {docx_path}")
def export_odt_to_docx(soffice_path: str, *, engine: str, odt_path: str, out_dir: str) -> Path:
# OpenOffice on Windows treats spaces in ``docx:MS Word 2007 XML`` as extra
# file arguments (Word / 2007 / XML). Use plain ``docx`` there.
if engine == "openoffice":
specs = ("docx",)
else:
specs = ("docx", "MS Word 2007 XML")
for spec in specs:
try:
convert_file(
soffice_path,
engine=engine,
src=odt_path,
out_dir=out_dir,
convert_to=spec,
)
docx = Path(out_dir) / f"{Path(odt_path).stem}.docx"
if docx.is_file():
return docx
except RuntimeError:
continue
raise RuntimeError(f"OpenOffice: не удалось экспортировать ODT → DOCX: {odt_path}")
+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)
+209
View File
@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
"""Pure table-split helpers for UNO worker (Py2.7 + Py3, standalone, no pywin32)."""
from __future__ import print_function, unicode_literals
import os
import re
import sys
try:
from urllib.parse import quote
except ImportError:
from urllib import quote as _urllib_quote
def quote(value, safe="/:@"):
if isinstance(value, bytes):
value = value.decode("utf-8")
return _urllib_quote(value.encode("utf-8"), safe)
DOCX_FILTER_CANDIDATES = {
"libre": ("Office Open XML Text", "MS Word 2007 XML", "Word 2007 XML"),
"openoffice": (
"MS Word 2007 XML",
"Office Open XML Text",
"Word 2007 XML",
),
}
DOCX_SAVE_FILTER_SPECS = {
"libre": (
"Office Open XML Text",
"MS Word 2007 XML",
),
"openoffice": (
"MS Word 2007 XML",
"Office Open XML Text",
),
}
MAX_PASSES = 20
# UNO enum numeric values (``from com.sun.star.*`` is unavailable in some LO/OO Pythons).
BREAK_NONE = 0
BREAK_PAGE_BEFORE = 3
CONTROL_PARAGRAPH_BREAK = 0
_UNO_MISSING = object()
try:
_TEXT_TYPE = unicode
except NameError:
_TEXT_TYPE = str
def as_unicode(value):
"""Coerce value to a Unicode string (safe on Python 2.7 + 3)."""
if value is None:
return _TEXT_TYPE()
if isinstance(value, _TEXT_TYPE):
return value
if isinstance(value, bytes):
for encoding in ("utf-8", sys.getfilesystemencoding(), "mbcs"):
if not encoding:
continue
try:
return value.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return value.decode("utf-8", "replace")
try:
return _TEXT_TYPE(value)
except Exception:
return _TEXT_TYPE(repr(value))
def uno_error_text(exc):
"""Readable UNO/Python exception text (Py2 often has empty str(exc))."""
for attr in ("Message", "Context", "Name"):
try:
val = getattr(exc, attr, None)
if val:
text = as_unicode(val)
if text:
return text
except Exception:
continue
text = as_unicode(exc)
if text:
return text
return repr(exc)
def uno_call(obj, base, default=_UNO_MISSING):
"""Invoke UNO getter ``getBase()`` or read attribute ``Base`` (OO Py2 vs LO Py3)."""
getter = "get" + base
for access in (getter, base):
try:
member = getattr(obj, access)
return member() if callable(member) else member
except Exception:
continue
if default is not _UNO_MISSING:
return default
raise AttributeError(base)
_RE_FLAGS = re.IGNORECASE
if sys.version_info[0] < 3:
_RE_FLAGS |= re.UNICODE
_CAPTION_RE = re.compile(
r"^(?:Продолжение\s+)?"
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
r"(?P<number>[\d.]+)",
_RE_FLAGS,
)
_CONTINUATION_RE = re.compile(
r"^Продолжение\s+(?:Таблицы|Листинга)\b", _RE_FLAGS
)
class CaptionInfo(object):
__slots__ = ("kind", "number", "is_continuation")
def __init__(self, kind, number, is_continuation=False):
self.kind = kind
self.number = number
self.is_continuation = is_continuation
def parse_caption_text(text):
raw = (text or "").replace("\r", "").replace("\x07", "").strip()
if not raw:
return None
first = raw.split("\n", 1)[0].strip()
m = _CAPTION_RE.match(first)
if not m:
return None
kind_raw = m.group("kind").lower()
if kind_raw.startswith("табл"):
kind = "table"
elif kind_raw.startswith("лист"):
kind = "listing"
else:
return None
return CaptionInfo(
kind=kind,
number=m.group("number"),
is_continuation=bool(_CONTINUATION_RE.match(first)),
)
def find_page_break_row(page_numbers):
if len(page_numbers) < 2:
return None
for i in range(1, len(page_numbers)):
if page_numbers[i] > page_numbers[i - 1]:
row_1based = i + 1
if row_1based <= 1:
return None
return row_1based
return None
def is_orphan_header_break(break_at, had_header):
return bool(had_header and break_at == 2)
def is_caption_orphaned(caption_page, first_row_page):
if caption_page is None or first_row_page is None:
return False
return caption_page < first_row_page
def continuation_label(kind, number):
if kind == "listing":
return "Продолжение Листинга {0}".format(number)
return "Продолжение Таблицы {0}".format(number)
def caption_style_name(kind):
return "Caption Listing" if kind == "listing" else "Название таблицы"
def file_url_from_path(path):
abs_path = os.path.abspath(path)
posix = abs_path.replace("\\", "/")
if len(posix) >= 2 and posix[1] == ":":
return "file:///" + quote(posix, safe="/:@")
return "file://" + quote(posix, safe="/:@")
def path_to_file_url(uno, path):
abs_path = os.path.abspath(path)
try:
url = uno.systemPathToFileUrl(abs_path)
if url:
return url
except Exception:
pass
return file_url_from_path(abs_path)
def docx_filter_candidates(engine):
return DOCX_FILTER_CANDIDATES.get(engine, DOCX_FILTER_CANDIDATES["libre"])
def docx_save_filter_specs(engine):
return DOCX_SAVE_FILTER_SPECS.get(engine, DOCX_SAVE_FILTER_SPECS["libre"])
+41
View File
@@ -0,0 +1,41 @@
"""Persistent LibreOffice/OpenOffice user profiles for headless UNO runs."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
# Marks first-start / registration wizard as done (OpenOffice + LibreOffice).
_REGISTRY_SEED = """<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" oor:name="Setup" oor:package="org.openoffice.Office">
<node oor:name="Office">
<prop oor:name="ooSetupCompleted" oor:type="xs:boolean">
<value>true</value>
</prop>
<prop oor:name="FirstStartWizardCompleted" oor:type="xs:boolean">
<value>true</value>
</prop>
</node>
</oor:component-data>
"""
def profile_dir(engine: str) -> Path:
"""Writable profile reused across conversions (avoids OO registration every run)."""
root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or tempfile.gettempdir()
path = Path(root) / "md2gost" / "office-profiles" / engine
path.mkdir(parents=True, exist_ok=True)
return path
def ensure_profile_seeded(profile_root: Path) -> None:
"""Pre-seed registry so OpenOffice does not show the registration wizard."""
marker = profile_root / ".md2gost-seeded"
user_dir = profile_root / "user"
user_dir.mkdir(parents=True, exist_ok=True)
xcu = user_dir / "registrymodifications.xcu"
if not xcu.is_file():
xcu.write_text(_REGISTRY_SEED, encoding="utf-8")
if not marker.is_file():
marker.write_text("1", encoding="utf-8")
File diff suppressed because it is too large Load Diff