v0.6.0 BigUpdate
This commit is contained in:
+48
-15
@@ -22,6 +22,11 @@ class OfficeInstall:
|
||||
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)"):
|
||||
@@ -133,41 +138,69 @@ def detect_office_install(engine: str) -> OfficeInstall | None:
|
||||
return None
|
||||
|
||||
|
||||
def _detect_word() -> OfficeInstall | None:
|
||||
def _word_com_available() -> bool:
|
||||
"""True when pywin32 is importable and Word.Application is registered."""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
return False
|
||||
try:
|
||||
import win32com.client # type: ignore
|
||||
import win32com.client # type: ignore # noqa: F401
|
||||
except ImportError:
|
||||
return None
|
||||
word = None
|
||||
return False
|
||||
try:
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
return OfficeInstall(engine="word", label="Microsoft Word")
|
||||
except Exception:
|
||||
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
|
||||
finally:
|
||||
if word is not None:
|
||||
try:
|
||||
word.Quit()
|
||||
except Exception:
|
||||
pass
|
||||
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.
|
||||
``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)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..word_fix import FixResult, fix_continuations
|
||||
from .detect import DEFAULT_OFFICE_ENGINE, resolve_office_engine
|
||||
from .detect import DEFAULT_OFFICE_ENGINE, OfficeInstall, resolve_office_engine
|
||||
from .uno_fix import fix_continuations_uno
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ def fix_continuations_with_engine(
|
||||
path: str,
|
||||
*,
|
||||
engine: str = DEFAULT_OFFICE_ENGINE,
|
||||
install: OfficeInstall | None = None,
|
||||
tables: bool = True,
|
||||
listings: bool = True,
|
||||
repeat_header: bool = False,
|
||||
@@ -18,10 +19,15 @@ def fix_continuations_with_engine(
|
||||
"""
|
||||
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.
|
||||
``engine`` is ``auto|word|libre|openoffice``. Pass a concrete ``engine`` plus
|
||||
``install`` from ``resolve_office_engine`` to skip a second resolve. On
|
||||
missing backend returns ``ok=False`` with a reason; conversion should
|
||||
continue.
|
||||
"""
|
||||
resolved, install = resolve_office_engine(engine)
|
||||
if install is not None and engine in ("word", "libre", "openoffice"):
|
||||
resolved = engine
|
||||
else:
|
||||
resolved, install = resolve_office_engine(engine)
|
||||
if resolved is None or install is None:
|
||||
if engine == "auto":
|
||||
return FixResult(
|
||||
|
||||
@@ -34,16 +34,19 @@ def _open_in_word(path: str) -> bool:
|
||||
import win32com.client # type: ignore
|
||||
except ImportError:
|
||||
return False
|
||||
word = None
|
||||
from ..word_fix import word_com_apartment
|
||||
|
||||
try:
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
word.Visible = True
|
||||
word.Documents.Open(path)
|
||||
return True
|
||||
with word_com_apartment():
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
try:
|
||||
word.Visible = True
|
||||
word.Documents.Open(path)
|
||||
return True
|
||||
finally:
|
||||
word = None
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
pass # keep Word open for the user
|
||||
|
||||
|
||||
def _open_in_soffice(soffice_path: str, path: str) -> bool:
|
||||
|
||||
@@ -37,13 +37,20 @@ def _office_program_dir(install: OfficeInstall) -> Path | 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()
|
||||
# System Python's PYTHONHOME/PYTHONPATH confuse LO's embedded interpreter
|
||||
# ("Could not find platform independent libraries <prefix>").
|
||||
for key in (
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PYTHONUSERBASE",
|
||||
"VIRTUAL_ENV",
|
||||
"CONDA_PREFIX",
|
||||
):
|
||||
env.pop(key, None)
|
||||
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:
|
||||
|
||||
@@ -39,6 +39,28 @@ DOCX_SAVE_FILTER_SPECS = {
|
||||
}
|
||||
|
||||
MAX_PASSES = 20
|
||||
# Stop if pagination never settles (each pass is one split, budget can grow).
|
||||
PASS_SAFETY_CAP = 200
|
||||
|
||||
|
||||
def continuation_pass_limit(object_count, splits_done=0):
|
||||
"""10 + (tables + listings) + 1 per split already made.
|
||||
|
||||
One pass performs one split.
|
||||
"""
|
||||
try:
|
||||
count = int(object_count)
|
||||
except (TypeError, ValueError):
|
||||
count = 0
|
||||
try:
|
||||
done = int(splits_done)
|
||||
except (TypeError, ValueError):
|
||||
done = 0
|
||||
if count < 0:
|
||||
count = 0
|
||||
if done < 0:
|
||||
done = 0
|
||||
return 10 + count + done
|
||||
|
||||
# UNO enum numeric values (``from com.sun.star.*`` is unavailable in some LO/OO Pythons).
|
||||
BREAK_NONE = 0
|
||||
@@ -162,6 +184,109 @@ def find_page_break_row(page_numbers):
|
||||
return None
|
||||
|
||||
|
||||
def resume_page_before_cut(cut_page):
|
||||
"""Page to scan from after a split: the page before the cut, not page 1.
|
||||
|
||||
Rows that move to the continuation start on ``cut_page``. Only that page
|
||||
and the pages after it can shift, so the next pass starts at ``cut_page - 1``.
|
||||
"""
|
||||
try:
|
||||
page = int(cut_page)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
if page <= 2:
|
||||
return 1
|
||||
return page - 1
|
||||
|
||||
|
||||
def locate_break_row(n_rows, page_at, min_page=1):
|
||||
"""Return ``(break_row_1based, cut_page)`` or ``(None, None)``.
|
||||
|
||||
``page_at(row_1based)`` returns a page number or None. Pages are treated as
|
||||
non-decreasing. The search skips a table that ends before ``min_page`` and
|
||||
binary-searches the first later jump, instead of reading every row from
|
||||
the start. A failed probe falls back to a forward scan.
|
||||
"""
|
||||
try:
|
||||
n_rows = int(n_rows)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
try:
|
||||
min_page = int(min_page)
|
||||
except (TypeError, ValueError):
|
||||
min_page = 1
|
||||
if n_rows < 2:
|
||||
return None, None
|
||||
if min_page < 1:
|
||||
min_page = 1
|
||||
|
||||
cache = {}
|
||||
|
||||
def probe(row):
|
||||
if row in cache:
|
||||
return cache[row]
|
||||
try:
|
||||
page = page_at(row)
|
||||
except Exception:
|
||||
page = None
|
||||
if page is not None:
|
||||
try:
|
||||
page = int(page)
|
||||
except (TypeError, ValueError):
|
||||
page = None
|
||||
cache[row] = page
|
||||
return page
|
||||
|
||||
first = probe(1)
|
||||
last = probe(n_rows)
|
||||
if first is None or last is None:
|
||||
return _linear_break_row(n_rows, probe, min_page)
|
||||
if last < min_page or first == last:
|
||||
return None, None
|
||||
threshold = (first + 1) if first >= min_page else min_page
|
||||
row = _first_row_at_or_after(n_rows, probe, threshold)
|
||||
if row is None or row <= 1:
|
||||
return _linear_break_row(n_rows, probe, min_page)
|
||||
cut = probe(row)
|
||||
prev = probe(row - 1)
|
||||
if cut is None or prev is None or prev >= cut or cut < threshold:
|
||||
return _linear_break_row(n_rows, probe, min_page)
|
||||
return row, cut
|
||||
|
||||
|
||||
def _first_row_at_or_after(n_rows, probe, threshold):
|
||||
lo = 1
|
||||
hi = n_rows
|
||||
found = None
|
||||
while lo <= hi:
|
||||
mid = (lo + hi) // 2
|
||||
page = probe(mid)
|
||||
if page is None:
|
||||
return None
|
||||
if page >= threshold:
|
||||
found = mid
|
||||
hi = mid - 1
|
||||
else:
|
||||
lo = mid + 1
|
||||
return found
|
||||
|
||||
|
||||
def _linear_break_row(n_rows, probe, min_page):
|
||||
prev = probe(1)
|
||||
if prev is None:
|
||||
return None, None
|
||||
row = 2
|
||||
while row <= n_rows:
|
||||
page = probe(row)
|
||||
if page is None:
|
||||
page = prev
|
||||
if page > prev and page >= min_page:
|
||||
return row, page
|
||||
prev = page
|
||||
row += 1
|
||||
return None, None
|
||||
|
||||
|
||||
def is_orphan_header_break(break_at, had_header):
|
||||
return bool(had_header and break_at == 2)
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import sys
|
||||
import time
|
||||
|
||||
from uno_helpers import (
|
||||
MAX_PASSES,
|
||||
PASS_SAFETY_CAP,
|
||||
continuation_pass_limit,
|
||||
CaptionInfo,
|
||||
as_unicode,
|
||||
caption_style_name,
|
||||
@@ -22,6 +23,8 @@ from uno_helpers import (
|
||||
BREAK_PAGE_BEFORE,
|
||||
CONTROL_PARAGRAPH_BREAK,
|
||||
find_page_break_row,
|
||||
locate_break_row,
|
||||
resume_page_before_cut,
|
||||
is_caption_orphaned,
|
||||
is_orphan_header_break,
|
||||
parse_caption_text,
|
||||
@@ -489,6 +492,20 @@ def _caption_for_split(doc, table):
|
||||
)
|
||||
|
||||
|
||||
def _count_split_targets(doc, tables, listings):
|
||||
"""Captioned tables and listings this pass is allowed to split."""
|
||||
found = 0
|
||||
for table in _body_tables(doc):
|
||||
caption = _caption_before_table(doc, table)
|
||||
if caption is None:
|
||||
continue
|
||||
if caption.kind == "table" and tables:
|
||||
found += 1
|
||||
elif caption.kind == "listing" and listings:
|
||||
found += 1
|
||||
return found
|
||||
|
||||
|
||||
def _caption_page_before_table(doc, table):
|
||||
try:
|
||||
anchor = _uno_call(table, "Anchor")
|
||||
@@ -506,6 +523,26 @@ def _caption_page_before_table(doc, table):
|
||||
return None
|
||||
|
||||
|
||||
def _page_at_row(doc, table, row_1based):
|
||||
"""Page number of one row, or None. Rows are 1-based, UNO cells are 0-based."""
|
||||
row = int(row_1based) - 1
|
||||
if row < 0:
|
||||
return None
|
||||
try:
|
||||
ncols = int(table.getColumns().getCount())
|
||||
except Exception:
|
||||
ncols = 1
|
||||
for col in range(max(1, ncols)):
|
||||
try:
|
||||
cell = table.getCellByPosition(col, row)
|
||||
page = _page_at_range(doc, _uno_call(cell, "Start"))
|
||||
if page is not None:
|
||||
return int(page)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _row_page_numbers(doc, table):
|
||||
pages = []
|
||||
try:
|
||||
@@ -1049,10 +1086,16 @@ def _copy_header_to_continuation(first_table, cont_table):
|
||||
pass
|
||||
|
||||
|
||||
def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
|
||||
def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details, start_page=1):
|
||||
"""Split the first crossing table. Returns (made, cut_page)."""
|
||||
made = 0
|
||||
cut_page = None
|
||||
try:
|
||||
start_page = int(start_page)
|
||||
except (TypeError, ValueError):
|
||||
start_page = 1
|
||||
body_tables = _body_tables(doc)
|
||||
for table in reversed(body_tables):
|
||||
for table in body_tables:
|
||||
caption = _caption_for_split(doc, table)
|
||||
if caption is None:
|
||||
continue
|
||||
@@ -1061,9 +1104,18 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
|
||||
if caption.kind == "listing" and not listings:
|
||||
continue
|
||||
|
||||
pages = _row_page_numbers(doc, table)
|
||||
if caption.kind == "table" and pages and not caption.is_continuation:
|
||||
if is_caption_orphaned(_caption_page_before_table(doc, table), pages[0]):
|
||||
try:
|
||||
row_count = int(table.getRows().getCount())
|
||||
except Exception:
|
||||
continue
|
||||
if start_page > 1 and row_count > 0:
|
||||
last_page = _page_at_row(doc, table, row_count)
|
||||
if last_page is not None and last_page < start_page:
|
||||
continue
|
||||
|
||||
first_page = _page_at_row(doc, table, 1) if row_count else None
|
||||
if caption.kind == "table" and first_page is not None and not caption.is_continuation:
|
||||
if is_caption_orphaned(_caption_page_before_table(doc, table), first_page):
|
||||
try:
|
||||
if _page_break_before_table_caption(doc, table):
|
||||
details.append(
|
||||
@@ -1082,10 +1134,14 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
|
||||
details.append("skip: orphan-caption page break: {0}".format(exc))
|
||||
continue
|
||||
|
||||
break_at = find_page_break_row(pages)
|
||||
break_at, cut_page = locate_break_row(
|
||||
row_count,
|
||||
lambda row, current=table: _page_at_row(doc, current, row),
|
||||
start_page,
|
||||
)
|
||||
if break_at is None:
|
||||
continue
|
||||
if break_at > int(table.getRows().getCount()):
|
||||
if break_at > row_count:
|
||||
continue
|
||||
|
||||
had_header = _table_has_header_row(table)
|
||||
@@ -1169,9 +1225,9 @@ def _fix_pass(uno, ctx, doc, tables, listings, repeat_header, details):
|
||||
details.append(
|
||||
"split: {0} {1} @row {2}".format(caption.kind, caption.number, break_at)
|
||||
)
|
||||
return made
|
||||
return made, cut_page
|
||||
|
||||
return made
|
||||
return made, cut_page
|
||||
|
||||
|
||||
def run_worker(port, path, engine, tables, listings, repeat_header):
|
||||
@@ -1187,14 +1243,30 @@ def run_worker(port, path, engine, tables, listings, repeat_header):
|
||||
doc, uno, ctx, tables, listings, details
|
||||
)
|
||||
_repair_split_table_borders(doc, tables, listings, details)
|
||||
for _ in range(MAX_PASSES):
|
||||
targets = _count_split_targets(doc, tables, listings)
|
||||
passes_done = 0
|
||||
start_page = 1
|
||||
while passes_done < continuation_pass_limit(targets, splits):
|
||||
if passes_done >= PASS_SAFETY_CAP:
|
||||
details.append("skip: pass safety cap")
|
||||
break
|
||||
_ensure_pagination(uno, ctx, doc)
|
||||
made = _fix_pass(
|
||||
uno, ctx, doc, tables, listings, repeat_header, details
|
||||
made, cut_page = _fix_pass(
|
||||
uno, ctx, doc, tables, listings, repeat_header, details, start_page
|
||||
)
|
||||
passes_done += 1
|
||||
if made == 0:
|
||||
if start_page > 1:
|
||||
start_page = 1
|
||||
details.append("recheck: from page 1")
|
||||
continue
|
||||
break
|
||||
splits += made
|
||||
if cut_page:
|
||||
start_page = resume_page_before_cut(cut_page)
|
||||
details.append(
|
||||
"resume: page {0} after cut at {1}".format(start_page, cut_page)
|
||||
)
|
||||
doc = _reload_document(uno, ctx, doc, path, engine)
|
||||
_ensure_pagination(uno, ctx, doc)
|
||||
repaired += _repair_missing_continuations(
|
||||
|
||||
Reference in New Issue
Block a user