v0.6.0 BigUpdate
This commit is contained in:
+157
-17
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# wdActiveEndPageNumber
|
||||
@@ -22,7 +23,28 @@ _CAPTION_RE = re.compile(
|
||||
)
|
||||
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
|
||||
|
||||
MAX_PASSES = 5
|
||||
# Stop if Word keeps reporting a page break after every single split.
|
||||
PASS_SAFETY_CAP = 200
|
||||
|
||||
|
||||
@contextmanager
|
||||
def word_com_apartment():
|
||||
"""Initialize COM on this thread and release that initialization afterwards.
|
||||
|
||||
Each GUI conversion runs on a new thread. Importing ``win32com`` initializes
|
||||
COM only on the first thread that imports it, so the next conversion raises
|
||||
``CO_E_NOTINITIALIZED`` (-2147221008) at ``DispatchEx``.
|
||||
"""
|
||||
import pythoncom # type: ignore
|
||||
|
||||
pythoncom.CoInitialize()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
pythoncom.CoUninitialize()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -65,6 +87,20 @@ def parse_caption_text(text: str) -> CaptionInfo | None:
|
||||
)
|
||||
|
||||
|
||||
def resume_page_before_cut(cut_page: int) -> int:
|
||||
"""Next scan starts on the page before the cut. See uno_helpers."""
|
||||
from .office.uno_helpers import resume_page_before_cut as impl
|
||||
|
||||
return impl(cut_page)
|
||||
|
||||
|
||||
def locate_break_row(n_rows: int, page_at, min_page: int = 1):
|
||||
"""Binary-search the next page jump. See uno_helpers.locate_break_row."""
|
||||
from .office.uno_helpers import locate_break_row as impl
|
||||
|
||||
return impl(n_rows, page_at, min_page)
|
||||
|
||||
|
||||
def find_page_break_row(page_numbers: list[int]) -> int | None:
|
||||
"""
|
||||
Return 1-based Word row index where a new page starts.
|
||||
@@ -90,6 +126,16 @@ def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
|
||||
return bool(had_header and break_at == 2)
|
||||
|
||||
|
||||
def continuation_pass_limit(object_count: int, splits_done: int = 0) -> int:
|
||||
"""How many passes to run. One pass performs one split.
|
||||
|
||||
``10 + (tables + listings) + 1`` for each split already made.
|
||||
"""
|
||||
count = max(0, int(object_count))
|
||||
extra = max(0, int(splits_done))
|
||||
return 10 + count + extra
|
||||
|
||||
|
||||
def is_caption_orphaned(caption_page: int | None, first_row_page: int | None) -> bool:
|
||||
"""True if «Таблица N — …» sits on an earlier page than the table body.
|
||||
|
||||
@@ -142,27 +188,68 @@ def fix_continuations(
|
||||
if not os.path.isfile(abs_path):
|
||||
return FixResult(False, message=f"Файл не найден: {abs_path}")
|
||||
|
||||
try:
|
||||
with word_com_apartment():
|
||||
return _fix_continuations_in_word(
|
||||
abs_path,
|
||||
win32com.client,
|
||||
tables=tables,
|
||||
listings=listings,
|
||||
repeat_header=repeat_header,
|
||||
)
|
||||
except Exception as exc:
|
||||
return FixResult(
|
||||
False,
|
||||
message=f"Пост-разрыв таблиц не удался: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def _fix_continuations_in_word(
|
||||
abs_path: str,
|
||||
win32com_client,
|
||||
*,
|
||||
tables: bool,
|
||||
listings: bool,
|
||||
repeat_header: bool,
|
||||
) -> FixResult:
|
||||
word = None
|
||||
doc = None
|
||||
splits = 0
|
||||
details: list[str] = []
|
||||
try:
|
||||
word = win32com.client.DispatchEx("Word.Application")
|
||||
word = win32com_client.DispatchEx("Word.Application")
|
||||
word.Visible = False
|
||||
word.DisplayAlerts = 0
|
||||
doc = word.Documents.Open(abs_path, ReadOnly=False)
|
||||
for _pass in range(MAX_PASSES):
|
||||
targets = _count_split_targets(doc, tables=tables, listings=listings)
|
||||
passes_done = 0
|
||||
# After a cut, pages above it stay put. The next pass starts on the
|
||||
# page before that cut; a final pass from page 1 checks nothing was missed.
|
||||
start_page = 1
|
||||
while passes_done < continuation_pass_limit(targets, splits):
|
||||
if passes_done >= PASS_SAFETY_CAP:
|
||||
details.append("skip: pass safety cap")
|
||||
break
|
||||
doc.Repaginate()
|
||||
made = _fix_pass(
|
||||
made, cut_page = _fix_pass(
|
||||
doc,
|
||||
tables=tables,
|
||||
listings=listings,
|
||||
repeat_header=repeat_header,
|
||||
details=details,
|
||||
start_page=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(f"resume: page {start_page} after cut at {cut_page}")
|
||||
|
||||
skipped = sum(1 for d in details if d.startswith("skip:"))
|
||||
_clear_section_page_restarts(doc)
|
||||
@@ -530,11 +617,18 @@ def _fix_pass(
|
||||
listings: bool,
|
||||
repeat_header: bool,
|
||||
details: list[str],
|
||||
) -> int:
|
||||
"""One pass over tables (bottom-up). Returns number of splits performed."""
|
||||
start_page: int = 1,
|
||||
) -> tuple[int, int | None]:
|
||||
"""One pass in document order. Splits the first table that crosses a page.
|
||||
|
||||
Tables that end before ``start_page`` are skipped. The page jump inside a
|
||||
table is binary-searched, so a long remainder is not read from row 1.
|
||||
Returns ``(splits_made, cut_page)``.
|
||||
"""
|
||||
made = 0
|
||||
cut_page: int | None = None
|
||||
count = int(doc.Tables.Count)
|
||||
for ti in range(count, 0, -1):
|
||||
for ti in range(1, count + 1):
|
||||
try:
|
||||
table = doc.Tables(ti)
|
||||
except Exception:
|
||||
@@ -554,18 +648,28 @@ def _fix_pass(
|
||||
if caption.kind == "listing" and not listings:
|
||||
continue
|
||||
|
||||
row_count = int(table.Rows.Count)
|
||||
# A one-cell listing can cross pages inside the cell; its row page is
|
||||
# only the start, so it must not be skipped on that alone.
|
||||
single_cell_listing = caption.kind == "listing" and row_count == 1
|
||||
if start_page > 1 and row_count > 0 and not single_cell_listing:
|
||||
last_page = _page_at_row(table, row_count)
|
||||
if last_page is not None and last_page < start_page:
|
||||
continue
|
||||
|
||||
# Legacy single-cell listings: expand to rows so Split works
|
||||
if caption.kind == "listing" and int(table.Rows.Count) == 1:
|
||||
if single_cell_listing:
|
||||
para_pages = _paragraph_page_numbers_in_cell(table)
|
||||
if find_page_break_row(para_pages) is not None:
|
||||
if _expand_single_cell_listing_to_rows(table):
|
||||
details.append(f"expand: listing {caption.number} → {table.Rows.Count} rows")
|
||||
doc.Repaginate()
|
||||
row_count = int(table.Rows.Count)
|
||||
|
||||
pages = _row_page_numbers(table)
|
||||
first_page = _page_at_row(table, 1) if row_count else None
|
||||
# Method: only the title («Таблица N») at page bottom is forbidden.
|
||||
if caption.kind == "table" and pages:
|
||||
if is_caption_orphaned(_caption_page_before_table(table), pages[0]):
|
||||
if caption.kind == "table" and first_page is not None:
|
||||
if is_caption_orphaned(_caption_page_before_table(table), first_page):
|
||||
try:
|
||||
if _page_break_before_table_caption(table):
|
||||
details.append(
|
||||
@@ -582,10 +686,14 @@ def _fix_pass(
|
||||
details.append(f"skip: orphan-caption page break: {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(current, row),
|
||||
start_page,
|
||||
)
|
||||
if break_at is None:
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
if break_at > row_count:
|
||||
continue
|
||||
|
||||
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
|
||||
@@ -600,8 +708,12 @@ def _fix_pass(
|
||||
)
|
||||
doc.Repaginate()
|
||||
table = doc.Tables(ti)
|
||||
pages = _row_page_numbers(table)
|
||||
break_at = find_page_break_row(pages)
|
||||
row_count = int(table.Rows.Count)
|
||||
break_at, cut_page = locate_break_row(
|
||||
row_count,
|
||||
lambda row, current=table: _page_at_row(current, row),
|
||||
start_page,
|
||||
)
|
||||
if break_at is None:
|
||||
details.append(
|
||||
f"skip: {caption.kind} {caption.number} "
|
||||
@@ -650,7 +762,7 @@ def _fix_pass(
|
||||
except Exception as exc:
|
||||
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
|
||||
made += 1
|
||||
continue
|
||||
return made, cut_page
|
||||
|
||||
if vmerge_spans:
|
||||
try:
|
||||
@@ -698,8 +810,36 @@ def _fix_pass(
|
||||
|
||||
made += 1
|
||||
details.append(f"split: {caption.kind} {caption.number} @row {break_at}")
|
||||
return made, cut_page
|
||||
|
||||
return made, None
|
||||
|
||||
def _count_split_targets(doc, *, tables: bool, listings: bool) -> int:
|
||||
"""Captioned tables and listings this pass is allowed to split."""
|
||||
found = 0
|
||||
try:
|
||||
count = int(doc.Tables.Count)
|
||||
except Exception:
|
||||
return 0
|
||||
for ti in range(1, count + 1):
|
||||
try:
|
||||
table = doc.Tables(ti)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if int(table.NestingLevel) > 1:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
caption = _caption_before_table(table)
|
||||
if caption is None:
|
||||
continue
|
||||
if caption.kind == "table" and tables:
|
||||
found += 1
|
||||
elif caption.kind == "listing" and listings:
|
||||
found += 1
|
||||
return found
|
||||
|
||||
return made
|
||||
|
||||
def _caption_before_table(table) -> CaptionInfo | None:
|
||||
text = _paragraph_text_before_table(table)
|
||||
|
||||
Reference in New Issue
Block a user