1353 lines
43 KiB
Python
1353 lines
43 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""UNO post-process worker - run with LibreOffice/OpenOffice program/python.exe."""
|
|
|
|
from __future__ import print_function, unicode_literals
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
from uno_helpers import (
|
|
PASS_SAFETY_CAP,
|
|
continuation_pass_limit,
|
|
CaptionInfo,
|
|
as_unicode,
|
|
caption_style_name,
|
|
continuation_label,
|
|
docx_filter_candidates,
|
|
docx_save_filter_specs,
|
|
BREAK_NONE,
|
|
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,
|
|
path_to_file_url,
|
|
uno_call as _uno_call,
|
|
uno_error_text,
|
|
)
|
|
|
|
|
|
def _document_text(doc):
|
|
return _uno_call(doc, "Text", default=None)
|
|
|
|
|
|
def _document_controller(doc):
|
|
return _uno_call(doc, "CurrentController", default=None)
|
|
|
|
|
|
def _text_tables(doc):
|
|
return _uno_call(doc, "TextTables")
|
|
|
|
|
|
def _format_exc(exc):
|
|
name = type(exc).__name__
|
|
try:
|
|
if getattr(exc, "args", None):
|
|
text = as_unicode(exc.args[0])
|
|
else:
|
|
text = as_unicode(exc)
|
|
except Exception:
|
|
text = as_unicode(repr(exc))
|
|
if text:
|
|
return "{0}: {1}".format(name, text)
|
|
return name
|
|
|
|
|
|
def _fs_path(value):
|
|
"""Normalize argv path to unicode on Python 2.7 (Cyrillic paths on Windows)."""
|
|
if sys.version_info[0] >= 3:
|
|
return value
|
|
try:
|
|
text_type = unicode
|
|
except NameError:
|
|
return value
|
|
if isinstance(value, text_type):
|
|
return value
|
|
for encoding in (sys.getfilesystemencoding(), "utf-8", "mbcs"):
|
|
if not encoding:
|
|
continue
|
|
try:
|
|
return value.decode(encoding)
|
|
except (UnicodeDecodeError, LookupError):
|
|
continue
|
|
return value.decode("utf-8", "replace")
|
|
|
|
|
|
def _emit_result(result):
|
|
if sys.version_info[0] < 3:
|
|
result = _unicode_result(result)
|
|
payload = json.dumps(result, ensure_ascii=False)
|
|
if sys.version_info[0] < 3:
|
|
try:
|
|
if isinstance(payload, unicode):
|
|
payload = payload.encode("utf-8")
|
|
except NameError:
|
|
pass
|
|
if sys.version_info[0] >= 3:
|
|
sys.stdout.write(payload + "\n")
|
|
else:
|
|
sys.stdout.write(payload)
|
|
if not payload.endswith(b"\n"):
|
|
sys.stdout.write(b"\n")
|
|
sys.stdout.flush()
|
|
|
|
|
|
def _unicode_result(value):
|
|
if isinstance(value, dict):
|
|
return dict((as_unicode(k), _unicode_result(v)) for k, v in value.items())
|
|
if isinstance(value, list):
|
|
return [_unicode_result(item) for item in value]
|
|
if isinstance(value, tuple):
|
|
return tuple(_unicode_result(item) for item in value)
|
|
if isinstance(value, (int, float, bool)) or value is None:
|
|
return value
|
|
return as_unicode(value)
|
|
|
|
|
|
def _connect(port):
|
|
import uno
|
|
|
|
local_ctx = uno.getComponentContext()
|
|
resolver = local_ctx.ServiceManager.createInstanceWithContext(
|
|
"com.sun.star.bridge.UnoUrlResolver", local_ctx
|
|
)
|
|
last_exc = None
|
|
for _ in range(int(45 / 0.2)):
|
|
try:
|
|
ctx = resolver.resolve(
|
|
"uno:socket,host=127.0.0.1,port={0};urp;StarOffice.ComponentContext".format(
|
|
port
|
|
)
|
|
)
|
|
return uno, ctx
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
time.sleep(0.2)
|
|
raise RuntimeError(
|
|
"cannot connect to soffice on port {0}: {1}".format(port, last_exc)
|
|
)
|
|
|
|
|
|
def _property(name, value):
|
|
from com.sun.star.beans import PropertyValue
|
|
|
|
return PropertyValue(name, 0, value, 0)
|
|
|
|
|
|
def _is_writer_document(doc):
|
|
return _document_text(doc) is not None
|
|
|
|
|
|
def _load_document(uno, ctx, path, engine):
|
|
smgr = ctx.ServiceManager
|
|
desktop = smgr.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
|
|
file_url = path_to_file_url(uno, path)
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext == ".docx":
|
|
filter_names = docx_filter_candidates(engine) + (None,)
|
|
else:
|
|
filter_names = (None,)
|
|
|
|
errors = []
|
|
for filter_name in filter_names:
|
|
props = [
|
|
_property("Hidden", True),
|
|
_property("ReadOnly", False),
|
|
_property("MacroExecutionMode", 4),
|
|
]
|
|
if filter_name:
|
|
props.append(_property("FilterName", filter_name))
|
|
label = filter_name or "auto"
|
|
try:
|
|
doc = desktop.loadComponentFromURL(file_url, "_blank", 0, tuple(props))
|
|
except Exception as exc:
|
|
errors.append("{0}: {1}".format(label, exc))
|
|
continue
|
|
if doc is None:
|
|
errors.append("{0}: loadComponentFromURL returned None".format(label))
|
|
continue
|
|
if not _is_writer_document(doc):
|
|
errors.append("{0}: not a Writer document".format(label))
|
|
try:
|
|
doc.close(True)
|
|
except Exception:
|
|
pass
|
|
continue
|
|
return doc
|
|
|
|
detail = "; ".join(errors) if errors else "unknown error"
|
|
raise RuntimeError("cannot open document: {0} ({1})".format(path, detail))
|
|
|
|
|
|
def _reload_document(uno, ctx, doc, path, engine):
|
|
"""Save and reopen so LO refreshes row page numbers after SplitTable."""
|
|
_save_document(uno, doc, path, engine)
|
|
try:
|
|
doc.close(True)
|
|
except Exception:
|
|
pass
|
|
return _load_document(uno, ctx, path, engine)
|
|
|
|
|
|
def _save_odt_temp(uno, doc):
|
|
"""Save to an ASCII-only temp ODT; host converts to DOCX (Py2 + Cyrillic paths)."""
|
|
import tempfile
|
|
|
|
fd, odt_path = tempfile.mkstemp(prefix="md2gost-", suffix=".odt")
|
|
os.close(fd)
|
|
odt_url = path_to_file_url(uno, odt_path)
|
|
doc.storeToURL(odt_url, (_property("Overwrite", True),))
|
|
if not os.path.isfile(odt_path):
|
|
raise RuntimeError("temp ODT not created: {0}".format(odt_path))
|
|
return odt_path
|
|
|
|
|
|
def _save_document(uno, doc, path, engine):
|
|
"""Save document. Returns temp ODT path when host must export DOCX."""
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext != ".docx":
|
|
doc.store()
|
|
return None
|
|
if engine == "openoffice":
|
|
return _save_odt_temp(uno, doc)
|
|
|
|
file_url = path_to_file_url(uno, path)
|
|
errors = []
|
|
for filter_name in docx_save_filter_specs(engine) + (None,):
|
|
props = [_property("Overwrite", True)]
|
|
if filter_name:
|
|
props.append(_property("FilterName", filter_name))
|
|
try:
|
|
doc.storeToURL(file_url, tuple(props))
|
|
return None
|
|
except Exception as exc:
|
|
errors.append(
|
|
"{0}: {1}".format(filter_name or "auto", uno_error_text(exc))
|
|
)
|
|
try:
|
|
doc.store()
|
|
return None
|
|
except Exception as exc:
|
|
errors.append("store: {0}".format(uno_error_text(exc)))
|
|
detail = "; ".join(errors) if errors else "unknown error"
|
|
raise RuntimeError("cannot save document: {0} ({1})".format(path, detail))
|
|
|
|
|
|
def _dispatch(uno, ctx, doc, command, args=()):
|
|
smgr = ctx.ServiceManager
|
|
dispatcher = smgr.createInstanceWithContext(
|
|
"com.sun.star.frame.DispatchHelper", ctx
|
|
)
|
|
controller = _document_controller(doc)
|
|
if controller is None:
|
|
raise RuntimeError("no document controller")
|
|
frame = _uno_call(controller, "Frame")
|
|
dispatcher.executeDispatch(frame, command, "", 0, args)
|
|
|
|
|
|
def _repaginate(uno, ctx, doc):
|
|
for command in (".uno:UpdateAll", ".uno:Repaginate"):
|
|
try:
|
|
_dispatch(uno, ctx, doc, command)
|
|
return
|
|
except Exception:
|
|
continue
|
|
try:
|
|
controller = _document_controller(doc)
|
|
if controller is not None:
|
|
view = _uno_call(controller, "View", default=None)
|
|
if view is not None:
|
|
view.Invalidate()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _page_at_range(doc, text_range):
|
|
try:
|
|
controller = _document_controller(doc)
|
|
if controller is None:
|
|
return None
|
|
view_cursor = _uno_call(controller, "ViewCursor")
|
|
view_cursor.gotoRange(text_range, False)
|
|
page = _view_cursor_page(view_cursor)
|
|
if page is None:
|
|
return None
|
|
return int(page)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _view_cursor_page(view_cursor):
|
|
try:
|
|
import uno
|
|
|
|
from com.sun.star.text import XPageCursor
|
|
|
|
page_cursor = uno.queryInterface(XPageCursor, view_cursor)
|
|
if page_cursor is not None:
|
|
return int(page_cursor.getPage())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return int(_uno_call(view_cursor, "Page"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _ensure_pagination(uno, ctx, doc):
|
|
for command in (".uno:UpdateAll", ".uno:Repaginate", ".uno:ZoomPageWidth"):
|
|
try:
|
|
_dispatch(uno, ctx, doc, command)
|
|
except Exception:
|
|
continue
|
|
try:
|
|
_uno_call(doc, "PageCount")
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.3)
|
|
|
|
|
|
def _diagnose_pagination(doc, details):
|
|
try:
|
|
collection = _text_tables(doc)
|
|
count = int(_uno_call(collection, "Count"))
|
|
details.append("diag: tables={0}".format(count))
|
|
for idx in range(min(count, 4)):
|
|
table = collection.getByIndex(idx)
|
|
caption = _caption_before_table(doc, table)
|
|
pages = _row_page_numbers(doc, table)
|
|
prev = _paragraph_text_before_table(doc, table)
|
|
if caption:
|
|
label = "{0} {1}".format(caption.kind, caption.number)
|
|
else:
|
|
preview = prev[:48] + ("..." if len(prev) > 48 else "")
|
|
label = "no-caption prev={0!r}".format(preview)
|
|
if pages:
|
|
page_info = "{0}-{1} (n={2})".format(
|
|
min(pages), max(pages), len(pages)
|
|
)
|
|
else:
|
|
page_info = "none"
|
|
details.append(
|
|
"diag: table[{0}] {1} pages={2}".format(idx, label, page_info)
|
|
)
|
|
except Exception as exc:
|
|
details.append("diag: {0}".format(uno_error_text(exc)))
|
|
|
|
|
|
def _supports_service(obj, name):
|
|
try:
|
|
return bool(obj.supportsService(name))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _element_text(elem):
|
|
try:
|
|
return (_uno_call(elem, "String", default="") or "").replace("\r", "").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _same_text_table(a, b):
|
|
try:
|
|
if a == b:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return _uno_call(a, "Anchor") == _uno_call(b, "Anchor")
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _is_target_table(elem, table):
|
|
try:
|
|
if elem == table:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return _same_text_table(elem, table)
|
|
|
|
|
|
def _body_tables(doc):
|
|
"""All body tables in document order (LO ``getByIndex`` can alias split fragments)."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return []
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return []
|
|
tables = []
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
tables.append(elem)
|
|
return tables
|
|
|
|
|
|
def _next_table_in_body(doc, table):
|
|
"""Return the next body table after *table* (LO ``getByIndex(i+1)`` can alias)."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return None
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return None
|
|
|
|
seen = False
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if not _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
continue
|
|
if not seen:
|
|
if _is_target_table(elem, table):
|
|
seen = True
|
|
continue
|
|
return elem
|
|
return None
|
|
|
|
|
|
def _paragraphs_before_table(doc, table, max_scan=6):
|
|
"""Walk body content in order; LO/OO table anchors are not navigable."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return []
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return []
|
|
recent = []
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
if _is_target_table(elem, table):
|
|
return recent[-max_scan:]
|
|
recent = []
|
|
continue
|
|
if _supports_service(elem, "com.sun.star.text.Paragraph"):
|
|
para_text = _element_text(elem)
|
|
if para_text:
|
|
recent.append(para_text)
|
|
return []
|
|
|
|
|
|
def _paragraph_text_before_table(doc, table):
|
|
paras = _paragraphs_before_table(doc, table, max_scan=1)
|
|
return paras[-1] if paras else ""
|
|
|
|
|
|
def _prev_body_element_before_table(doc, table):
|
|
"""Body element immediately before *table* (paragraph or table)."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return None
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return None
|
|
prev_elem = None
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable") and _is_target_table(
|
|
elem, table
|
|
):
|
|
return prev_elem
|
|
prev_elem = elem
|
|
return None
|
|
|
|
|
|
def _caption_before_table(doc, table):
|
|
for para_text in reversed(_paragraphs_before_table(doc, table, max_scan=6)):
|
|
caption = parse_caption_text(para_text)
|
|
if caption is not None:
|
|
return caption
|
|
return None
|
|
|
|
|
|
def _caption_for_split(doc, table):
|
|
"""Caption for split decisions, including orphan continuation fragments."""
|
|
caption = _caption_before_table(doc, table)
|
|
if caption is not None:
|
|
return caption
|
|
prev_table = _first_table_fragment_before(doc, table)
|
|
if prev_table is None:
|
|
return None
|
|
upstream = _caption_before_table(doc, prev_table)
|
|
if upstream is None:
|
|
return None
|
|
return CaptionInfo(
|
|
kind=upstream.kind,
|
|
number=upstream.number,
|
|
is_continuation=True,
|
|
)
|
|
|
|
|
|
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")
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return None
|
|
cursor = text.createTextCursorByRange(anchor)
|
|
if not cursor.goLeft(1, False):
|
|
return None
|
|
cursor.gotoRange(anchor, False)
|
|
cursor.goLeft(1, False)
|
|
cursor.gotoStartOfParagraph(False)
|
|
return _page_at_range(doc, cursor)
|
|
except Exception:
|
|
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:
|
|
nrows = int(table.getRows().getCount())
|
|
except Exception:
|
|
return pages
|
|
for row in range(nrows):
|
|
page = None
|
|
for col in range(max(1, int(table.getColumns().getCount()))):
|
|
try:
|
|
cell = table.getCellByPosition(col, row)
|
|
page = _page_at_range(doc, _uno_call(cell, "Start"))
|
|
if page is not None:
|
|
break
|
|
except Exception:
|
|
continue
|
|
if page is None:
|
|
page = pages[-1] if pages else 1
|
|
pages.append(page)
|
|
return pages
|
|
|
|
|
|
def _table_has_header_row(table):
|
|
try:
|
|
if bool(table.getPropertyValue("RepeatHeadline")):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
row = table.getRows().getByIndex(0)
|
|
return bool(row.getPropertyValue("IsRepeatHeading"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _page_break_before_table_caption(doc, table):
|
|
try:
|
|
anchor = _uno_call(table, "Anchor")
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return False
|
|
cursor = text.createTextCursorByRange(anchor)
|
|
if not cursor.goLeft(1, False):
|
|
return False
|
|
cursor.gotoRange(anchor, False)
|
|
cursor.goLeft(1, False)
|
|
cursor.gotoStartOfParagraph(False)
|
|
info = parse_caption_text((_uno_call(cursor, "String", default="") or "").strip())
|
|
if info is None or info.is_continuation:
|
|
return False
|
|
cursor.setPropertyValue("BreakType", BREAK_PAGE_BEFORE)
|
|
cursor.setPropertyValue("ParaKeepTogether", True)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _cursor_after_element(text, elem, cursor):
|
|
"""Move *cursor* to the end of a body-text element (paragraph or table)."""
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
try:
|
|
nrows = int(elem.getRows().getCount())
|
|
ncols = int(elem.getColumns().getCount())
|
|
if nrows > 0 and ncols > 0:
|
|
cell = elem.getCellByPosition(ncols - 1, nrows - 1)
|
|
cursor.gotoRange(_uno_call(cell, "End"), False)
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
cursor.gotoRange(_uno_call(elem, "Anchor"), False)
|
|
else:
|
|
cursor.gotoRange(elem, False)
|
|
cursor.collapseToEnd()
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
cursor.gotoRange(_uno_call(elem, "Anchor"), False)
|
|
cursor.gotoEndOfParagraph(False)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _apply_caption_style(cursor, caption):
|
|
style_name = caption_style_name(caption.kind)
|
|
for name in (style_name, "Caption Table", "Caption"):
|
|
try:
|
|
cursor.setPropertyValue("ParaStyleName", name)
|
|
break
|
|
except Exception:
|
|
continue
|
|
try:
|
|
cursor.setPropertyValue("BreakType", BREAK_NONE)
|
|
cursor.setPropertyValue("ParaKeepTogether", True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _first_table_fragment_before(doc, table):
|
|
"""Return the first table fragment before *table* (skip empty paragraphs)."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return None
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return None
|
|
|
|
seen = []
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable") and _is_target_table(
|
|
elem, table
|
|
):
|
|
for prev in reversed(seen):
|
|
if _supports_service(prev, "com.sun.star.text.TextTable"):
|
|
return prev
|
|
if _supports_service(prev, "com.sun.star.text.Paragraph"):
|
|
para_text = _element_text(prev)
|
|
if not para_text:
|
|
continue
|
|
info = parse_caption_text(para_text)
|
|
if info is not None and not info.is_continuation:
|
|
return None
|
|
return None
|
|
seen.append(elem)
|
|
return None
|
|
|
|
|
|
def _write_continuation_paragraph(text, paragraph, label, caption):
|
|
"""Fill or replace a body paragraph with a continuation caption."""
|
|
start = _uno_call(paragraph, "Start")
|
|
end = _uno_call(paragraph, "End")
|
|
cursor = text.createTextCursorByRange(start)
|
|
cursor.gotoRange(end, True)
|
|
cursor.setString(label)
|
|
_apply_caption_style(cursor, caption)
|
|
|
|
|
|
def _insert_paragraph_before_table(uno, ctx, doc, table, label, caption):
|
|
"""Insert a caption paragraph before *table* via dispatch (anchor range is often invalid)."""
|
|
try:
|
|
cell = table.getCellByPosition(0, 0)
|
|
start = _uno_call(cell, "Start")
|
|
except Exception as exc:
|
|
raise RuntimeError("cannot reach continuation table cell: {0}".format(exc))
|
|
|
|
controller = _document_controller(doc)
|
|
if controller is None:
|
|
raise RuntimeError("no document controller")
|
|
view_cursor = _uno_call(controller, "ViewCursor")
|
|
view_cursor.gotoRange(start, False)
|
|
_dispatch(uno, ctx, doc, ".uno:InsertPara", ())
|
|
try:
|
|
view_cursor.goLeft(1, False)
|
|
except Exception:
|
|
pass
|
|
view_cursor.setString(label)
|
|
_apply_caption_style(view_cursor, caption)
|
|
|
|
|
|
def _insert_continuation_at(doc, prev_elem, cont_table, label, caption, uno=None, ctx=None):
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
raise RuntimeError("no document text")
|
|
if prev_elem is None:
|
|
raise RuntimeError("continuation table at document start")
|
|
|
|
if _supports_service(prev_elem, "com.sun.star.text.Paragraph"):
|
|
prev_text = _element_text(prev_elem)
|
|
if prev_text.strip():
|
|
info = parse_caption_text(prev_text)
|
|
if info is not None and info.is_continuation:
|
|
return
|
|
raise RuntimeError("non-empty paragraph before continuation table")
|
|
_write_continuation_paragraph(text, prev_elem, label, caption)
|
|
return
|
|
if _supports_service(prev_elem, "com.sun.star.text.TextTable"):
|
|
if uno is None or ctx is None:
|
|
raise RuntimeError("dispatch insert requires UNO context")
|
|
_insert_paragraph_before_table(uno, ctx, doc, cont_table, label, caption)
|
|
return
|
|
raise RuntimeError("unsupported block before continuation table")
|
|
|
|
|
|
def _insert_continuation_after_fragment(doc, first_table, caption, uno=None, ctx=None):
|
|
"""Insert «Продолжение…» before the table fragment that follows *first_table*."""
|
|
label = continuation_label(caption.kind, caption.number)
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
raise RuntimeError("no document text")
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception as exc:
|
|
raise RuntimeError("cannot enumerate body text: {0}".format(exc))
|
|
|
|
prev_elem = None
|
|
seen_first = False
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
if seen_first:
|
|
return _insert_continuation_at(
|
|
doc, prev_elem, elem, label, caption, uno, ctx
|
|
)
|
|
if _is_target_table(elem, first_table):
|
|
seen_first = True
|
|
prev_elem = elem
|
|
continue
|
|
prev_elem = elem
|
|
raise RuntimeError("continuation fragment not found after split")
|
|
|
|
|
|
def _insert_continuation_before(doc, table, caption, uno=None, ctx=None):
|
|
"""Insert «Продолжение…» before an already-split continuation fragment."""
|
|
label = continuation_label(caption.kind, caption.number)
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
raise RuntimeError("no document text")
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception as exc:
|
|
raise RuntimeError("cannot enumerate body text: {0}".format(exc))
|
|
|
|
prev_elem = None
|
|
match = None
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
if elem == table:
|
|
match = (prev_elem, elem)
|
|
elif match is None and _same_text_table(elem, table):
|
|
match = (prev_elem, elem)
|
|
prev_elem = elem
|
|
continue
|
|
prev_elem = elem
|
|
if match is None:
|
|
raise RuntimeError("continuation table not found in body text")
|
|
prev_elem, cont_table = match
|
|
if not _needs_continuation_caption(doc, cont_table):
|
|
return
|
|
return _insert_continuation_at(
|
|
doc, prev_elem, cont_table, label, caption, uno, ctx
|
|
)
|
|
|
|
|
|
def _needs_continuation_caption(doc, cont_table):
|
|
prev_elem = _prev_body_element_before_table(doc, cont_table)
|
|
if prev_elem is None:
|
|
return False
|
|
if _supports_service(prev_elem, "com.sun.star.text.TextTable"):
|
|
return True
|
|
if _supports_service(prev_elem, "com.sun.star.text.Paragraph"):
|
|
prev_text = _element_text(prev_elem)
|
|
if not prev_text.strip():
|
|
return True
|
|
info = parse_caption_text(prev_text)
|
|
return info is None or not info.is_continuation
|
|
return True
|
|
|
|
|
|
def _has_continuation_before_table(doc, cont_table):
|
|
return not _needs_continuation_caption(doc, cont_table)
|
|
|
|
|
|
def _repair_split_table_borders(doc, tables, listings, details):
|
|
"""Clear bottom borders on first fragments of already-split tables."""
|
|
if not tables:
|
|
return 0
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return 0
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return 0
|
|
|
|
fixed = 0
|
|
last_table = None
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
if last_table is not None and _has_continuation_before_table(doc, elem):
|
|
caption = _caption_for_split(doc, elem) or _caption_before_table(
|
|
doc, last_table
|
|
)
|
|
if caption is not None and caption.kind == "table":
|
|
try:
|
|
_clear_table_bottom_border(last_table)
|
|
fixed += 1
|
|
details.append(
|
|
"repair: clear bottom border table {0}".format(
|
|
caption.number
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
details.append(
|
|
"skip: repair bottom border table {0}: {1}".format(
|
|
caption.number, exc
|
|
)
|
|
)
|
|
last_table = elem
|
|
continue
|
|
|
|
if _supports_service(elem, "com.sun.star.text.Paragraph"):
|
|
para_text = _element_text(elem)
|
|
if not para_text:
|
|
continue
|
|
info = parse_caption_text(para_text)
|
|
if info is not None and not info.is_continuation:
|
|
last_table = None
|
|
return fixed
|
|
|
|
|
|
def _repair_missing_continuations(doc, uno, ctx, tables, listings, details):
|
|
"""Insert captions between already-split table pairs (re-run after failed insert)."""
|
|
text = _document_text(doc)
|
|
if text is None:
|
|
return 0
|
|
try:
|
|
enum = text.createEnumeration()
|
|
except Exception:
|
|
return 0
|
|
|
|
repaired = 0
|
|
last_table = None
|
|
while enum.hasMoreElements():
|
|
elem = enum.nextElement()
|
|
if _supports_service(elem, "com.sun.star.text.TextTable"):
|
|
if last_table is not None and _needs_continuation_caption(doc, elem):
|
|
caption = _caption_for_split(doc, elem)
|
|
if caption is not None:
|
|
if caption.kind == "table" and not tables:
|
|
caption = None
|
|
elif caption.kind == "listing" and not listings:
|
|
caption = None
|
|
if caption is not None:
|
|
try:
|
|
_insert_continuation_before(doc, elem, caption, uno, ctx)
|
|
repaired += 1
|
|
details.append(
|
|
"repair: {0} {1} continuation caption".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
details.append(
|
|
"skip: repair continuation {0} {1}: {2}".format(
|
|
caption.kind, caption.number, exc
|
|
)
|
|
)
|
|
last_table = elem
|
|
continue
|
|
|
|
if _supports_service(elem, "com.sun.star.text.Paragraph"):
|
|
para_text = _element_text(elem)
|
|
if not para_text:
|
|
continue
|
|
info = parse_caption_text(para_text)
|
|
if info is not None and not info.is_continuation:
|
|
last_table = None
|
|
return repaired
|
|
|
|
|
|
def _refresh_table_layout(doc, table):
|
|
"""Touch each row so LO recalculates page numbers after SplitTable."""
|
|
controller = _document_controller(doc)
|
|
if controller is None:
|
|
return
|
|
try:
|
|
view_cursor = _uno_call(controller, "ViewCursor")
|
|
except Exception:
|
|
return
|
|
try:
|
|
nrows = int(table.getRows().getCount())
|
|
ncols = max(1, int(table.getColumns().getCount()))
|
|
except Exception:
|
|
return
|
|
for row in range(nrows):
|
|
try:
|
|
cell = table.getCellByPosition(0, row)
|
|
view_cursor.gotoRange(_uno_call(cell, "Start"), False)
|
|
_view_cursor_page(view_cursor)
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
def _split_table_at_row(uno, ctx, doc, table, break_at_1based):
|
|
from com.sun.star.beans import PropertyValue
|
|
|
|
# Match Word ``SplitBeforeRow``: split before 1-based *break_at_1based*.
|
|
row_0based = break_at_1based - 1
|
|
if row_0based < 1:
|
|
return False
|
|
try:
|
|
cell = table.getCellByPosition(0, row_0based)
|
|
except Exception:
|
|
return False
|
|
controller = _document_controller(doc)
|
|
if controller is None:
|
|
return False
|
|
view_cursor = _uno_call(controller, "ViewCursor")
|
|
view_cursor.gotoRange(_uno_call(cell, "Start"), False)
|
|
arg = PropertyValue("Type", 0, 3, 0)
|
|
_dispatch(uno, ctx, doc, ".uno:SplitTable", (arg,))
|
|
return True
|
|
|
|
|
|
def _border_line_style_none():
|
|
try:
|
|
from com.sun.star.table import BorderLineStyle
|
|
|
|
return BorderLineStyle.NONE
|
|
except Exception:
|
|
return 32767
|
|
|
|
|
|
def _none_border_line():
|
|
"""BorderLine2 with LineStyle NONE (matches Word wdLineStyleNone for DOCX export)."""
|
|
try:
|
|
from com.sun.star.table import BorderLine2
|
|
|
|
border = BorderLine2()
|
|
except Exception:
|
|
import uno
|
|
|
|
border = uno.createUnoStruct("com.sun.star.table.BorderLine2")
|
|
border.LineStyle = _border_line_style_none()
|
|
border.LineWidth = 0
|
|
border.OuterLineWidth = 0
|
|
border.InnerLineWidth = 0
|
|
border.LineDistance = 0
|
|
return border
|
|
|
|
|
|
def _clear_border_line(border):
|
|
try:
|
|
border.LineStyle = _border_line_style_none()
|
|
except Exception:
|
|
pass
|
|
border.LineWidth = 0
|
|
border.OuterLineWidth = 0
|
|
border.InnerLineWidth = 0
|
|
border.LineDistance = 0
|
|
return border
|
|
|
|
|
|
def _clear_table_bottom_border(table):
|
|
"""Remove the visible bottom edge of a table fragment (LO keeps it on last-row cells)."""
|
|
touched = False
|
|
|
|
try:
|
|
nrows = int(table.getRows().getCount())
|
|
ncols = int(table.getColumns().getCount())
|
|
except Exception:
|
|
nrows = 0
|
|
ncols = 0
|
|
|
|
if nrows > 0 and ncols > 0:
|
|
last_row = nrows - 1
|
|
try:
|
|
row = table.getRows().getByIndex(last_row)
|
|
row.setPropertyValue("BottomBorder", _none_border_line())
|
|
touched = True
|
|
except Exception:
|
|
pass
|
|
for col in range(ncols):
|
|
try:
|
|
cell = table.getCellByPosition(col, last_row)
|
|
cell.setPropertyValue("BottomBorder", _none_border_line())
|
|
touched = True
|
|
except Exception:
|
|
continue
|
|
|
|
for prop in ("BottomBorder",):
|
|
try:
|
|
border = table.getPropertyValue(prop)
|
|
table.setPropertyValue(prop, _clear_border_line(border))
|
|
touched = True
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
table_border = table.getPropertyValue("TableBorder")
|
|
table_border.BottomLine = _clear_border_line(table_border.BottomLine)
|
|
table.setPropertyValue("TableBorder", table_border)
|
|
touched = True
|
|
except Exception:
|
|
pass
|
|
|
|
if not touched:
|
|
raise RuntimeError("no bottom border property accessible")
|
|
|
|
|
|
def _apply_listing_box_borders(table):
|
|
for edge in ("TopBorder", "BottomBorder", "LeftBorder", "RightBorder"):
|
|
try:
|
|
border = table.getPropertyValue(edge)
|
|
border.LineWidth = 1
|
|
table.setPropertyValue(edge, border)
|
|
except Exception:
|
|
pass
|
|
for edge in ("HBorder", "VBorder"):
|
|
try:
|
|
border = table.getPropertyValue(edge)
|
|
border.LineWidth = 0
|
|
table.setPropertyValue(edge, border)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _copy_header_to_continuation(first_table, cont_table):
|
|
try:
|
|
src_row = first_table.getRows().getByIndex(0)
|
|
rows = cont_table.getRows()
|
|
if int(rows.getCount()) < 1:
|
|
return
|
|
try:
|
|
if bool(rows.getByIndex(0).getPropertyValue("IsRepeatHeading")):
|
|
return
|
|
except Exception:
|
|
pass
|
|
rows.insertByIndex(0, 1)
|
|
new_row = rows.getByIndex(0)
|
|
ncols = int(cont_table.getColumns().getCount())
|
|
for col in range(ncols):
|
|
try:
|
|
src_cell = src_row.getCellByPosition(col)
|
|
dst_cell = new_row.getCellByPosition(col)
|
|
dst_cell.setString((src_cell.getString() or "").strip())
|
|
except Exception:
|
|
continue
|
|
try:
|
|
new_row.setPropertyValue("IsRepeatHeading", True)
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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 body_tables:
|
|
caption = _caption_for_split(doc, table)
|
|
if caption is None:
|
|
continue
|
|
if caption.kind == "table" and not tables:
|
|
continue
|
|
if caption.kind == "listing" and not listings:
|
|
continue
|
|
|
|
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(
|
|
"orphan-caption: {0} {1} -> page break before caption".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
_repaginate(uno, ctx, doc)
|
|
else:
|
|
details.append(
|
|
"skip: orphan-caption {0} {1}: no caption para".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
details.append("skip: orphan-caption page break: {0}".format(exc))
|
|
continue
|
|
|
|
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 > row_count:
|
|
continue
|
|
|
|
had_header = _table_has_header_row(table)
|
|
if is_orphan_header_break(break_at, had_header):
|
|
try:
|
|
if _page_break_before_table_caption(doc, table):
|
|
details.append(
|
|
"orphan-header: {0} {1} -> page break before caption".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
_repaginate(uno, ctx, doc)
|
|
else:
|
|
details.append(
|
|
"skip: orphan-header {0} {1}: no caption para".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
details.append("skip: orphan-header page break: {0}".format(exc))
|
|
continue
|
|
|
|
try:
|
|
if not _split_table_at_row(uno, ctx, doc, table, break_at):
|
|
details.append(
|
|
"skip: split {0} {1} row {2}: dispatch failed".format(
|
|
caption.kind, caption.number, break_at
|
|
)
|
|
)
|
|
continue
|
|
except Exception as exc:
|
|
details.append(
|
|
"skip: split {0} {1} row {2}: {3}".format(
|
|
caption.kind, caption.number, break_at, exc
|
|
)
|
|
)
|
|
continue
|
|
|
|
first_table = table
|
|
cont_table = _next_table_in_body(doc, first_table)
|
|
if cont_table is None:
|
|
details.append(
|
|
"skip: after split {0} {1}: no continuation fragment".format(
|
|
caption.kind, caption.number
|
|
)
|
|
)
|
|
made += 1
|
|
continue
|
|
|
|
try:
|
|
_insert_continuation_after_fragment(
|
|
doc, first_table, caption, uno, ctx
|
|
)
|
|
except Exception as exc:
|
|
details.append("skip: insert caption after split: {0}".format(exc))
|
|
|
|
if caption.kind == "table":
|
|
try:
|
|
_clear_table_bottom_border(first_table)
|
|
except Exception as exc:
|
|
details.append("skip: clear bottom border: {0}".format(exc))
|
|
|
|
if caption.kind == "listing":
|
|
try:
|
|
_apply_listing_box_borders(first_table)
|
|
_apply_listing_box_borders(cont_table)
|
|
except Exception as exc:
|
|
details.append("skip: listing borders: {0}".format(exc))
|
|
|
|
if repeat_header and had_header and caption.kind == "table":
|
|
try:
|
|
_copy_header_to_continuation(first_table, cont_table)
|
|
except Exception as exc:
|
|
details.append("skip: header copy: {0}".format(exc))
|
|
|
|
_refresh_table_layout(doc, first_table)
|
|
_refresh_table_layout(doc, cont_table)
|
|
_repaginate(uno, ctx, doc)
|
|
_ensure_pagination(uno, ctx, doc)
|
|
made += 1
|
|
details.append(
|
|
"split: {0} {1} @row {2}".format(caption.kind, caption.number, break_at)
|
|
)
|
|
return made, cut_page
|
|
|
|
return made, cut_page
|
|
|
|
|
|
def run_worker(port, path, engine, tables, listings, repeat_header):
|
|
label = "LibreOffice" if engine == "libre" else "OpenOffice"
|
|
uno, ctx = _connect(port)
|
|
doc = _load_document(uno, ctx, path, engine)
|
|
splits = 0
|
|
details = []
|
|
odt_temp = None
|
|
try:
|
|
_ensure_pagination(uno, ctx, doc)
|
|
repaired = _repair_missing_continuations(
|
|
doc, uno, ctx, tables, listings, details
|
|
)
|
|
_repair_split_table_borders(doc, tables, listings, details)
|
|
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, 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(
|
|
doc, uno, ctx, tables, listings, details
|
|
)
|
|
if splits == 0 and repaired == 0:
|
|
_diagnose_pagination(doc, details)
|
|
elif repaired:
|
|
_ensure_pagination(uno, ctx, doc)
|
|
odt_temp = _save_document(uno, doc, path, engine)
|
|
finally:
|
|
try:
|
|
doc.close(True)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
smgr = ctx.ServiceManager
|
|
desktop = smgr.createInstanceWithContext(
|
|
"com.sun.star.frame.Desktop", ctx
|
|
)
|
|
desktop.terminate()
|
|
except Exception:
|
|
pass
|
|
|
|
skipped = sum(1 for line in details if line.startswith("skip:"))
|
|
repaired = sum(1 for line in details if line.startswith("repair:"))
|
|
msg = "Пост-разрыв {0}: разрезов {1}".format(label, splits)
|
|
if repaired:
|
|
msg += ", подписей {0}".format(repaired)
|
|
if skipped:
|
|
msg += ", пропусков {0}".format(skipped)
|
|
msg += "."
|
|
result = {
|
|
"ok": True,
|
|
"splits": splits,
|
|
"skipped": skipped,
|
|
"message": msg,
|
|
"details": details,
|
|
}
|
|
if odt_temp:
|
|
result["odt_temp"] = odt_temp
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="md2gost UNO table split worker")
|
|
parser.add_argument("--port", type=int, required=True)
|
|
parser.add_argument("--path", required=True)
|
|
parser.add_argument("--engine", choices=("libre", "openoffice"), default="libre")
|
|
parser.add_argument("--tables", choices=("0", "1"), default="1")
|
|
parser.add_argument("--listings", choices=("0", "1"), default="1")
|
|
parser.add_argument("--repeat-header", choices=("0", "1"), default="0")
|
|
args = parser.parse_args()
|
|
doc_path = _fs_path(args.path)
|
|
try:
|
|
result = run_worker(
|
|
args.port,
|
|
doc_path,
|
|
args.engine,
|
|
args.tables == "1",
|
|
args.listings == "1",
|
|
args.repeat_header == "1",
|
|
)
|
|
_emit_result(result)
|
|
return 0
|
|
except Exception as exc:
|
|
label = "LibreOffice" if args.engine == "libre" else "OpenOffice"
|
|
_emit_result(
|
|
{
|
|
"ok": False,
|
|
"splits": 0,
|
|
"skipped": 0,
|
|
"message": "Пост-разрыв ({0}) не удался: {1}".format(
|
|
label, _format_exc(exc)
|
|
),
|
|
"details": [],
|
|
}
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|