+396
-15
@@ -81,6 +81,24 @@ def find_page_break_row(page_numbers: list[int]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def is_orphan_header_break(break_at: int | None, *, had_header: bool) -> bool:
|
||||
"""True if a Word split would leave only the heading row on the previous page.
|
||||
|
||||
Method guide §3: do not leave «Таблица N» + column headers alone at page end.
|
||||
"""
|
||||
return bool(had_header and break_at == 2)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Method guide: do not leave only the table title at the bottom of a page.
|
||||
"""
|
||||
if caption_page is None or first_row_page is None:
|
||||
return False
|
||||
return caption_page < first_row_page
|
||||
|
||||
|
||||
def continuation_label(kind: str, number: str) -> str:
|
||||
if kind == "listing":
|
||||
return f"Продолжение Листинга {number}"
|
||||
@@ -146,6 +164,11 @@ def fix_continuations(
|
||||
splits += made
|
||||
|
||||
skipped = sum(1 for d in details if d.startswith("skip:"))
|
||||
_clear_section_page_restarts(doc)
|
||||
try:
|
||||
doc.Fields.Update()
|
||||
except Exception as exc:
|
||||
details.append(f"skip: fields update: {exc}")
|
||||
doc.Save()
|
||||
msg = (
|
||||
f"Пост-разрыв Word: разрезов {splits}"
|
||||
@@ -175,25 +198,260 @@ def fix_continuations(
|
||||
pass
|
||||
|
||||
|
||||
def _clear_section_page_restarts(doc) -> None:
|
||||
"""Turn off page-number restart on every section (continuous PAGE through landscape)."""
|
||||
# wdHeaderFooterPrimary = 1, wdHeaderFooterFirstPage = 2, wdHeaderFooterEvenPages = 3
|
||||
for i in range(1, int(doc.Sections.Count) + 1):
|
||||
sec = doc.Sections(i)
|
||||
for hf_kind in (1, 2, 3):
|
||||
for getter in (sec.Headers, sec.Footers):
|
||||
try:
|
||||
hf = getter(hf_kind)
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
pns = hf.PageNumbers
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
if int(pns.Count) < 1:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
pns.RestartNumberingAtSection = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _row_page_numbers(table) -> list[int]:
|
||||
"""Page number at the start of each row (more reliable than end-of-range)."""
|
||||
"""Page number at the start of each row (more reliable than end-of-range).
|
||||
|
||||
Tables with vertical merges (`^` in md2gost) often raise on ``Rows(i).Range``;
|
||||
fall back to ``Cell(i, j).Range`` per column.
|
||||
"""
|
||||
pages: list[int] = []
|
||||
n = int(table.Rows.Count)
|
||||
for i in range(1, n + 1):
|
||||
try:
|
||||
rng = table.Rows(i).Range
|
||||
# Collapse to start so a tall row reports where it begins
|
||||
start = int(rng.Start)
|
||||
page = int(rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
try:
|
||||
page = int(table.Rows(i).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
page = pages[-1] if pages else 1
|
||||
page = _page_at_row(table, i)
|
||||
if page is None:
|
||||
page = pages[-1] if pages else 1
|
||||
pages.append(page)
|
||||
return pages
|
||||
|
||||
|
||||
def _page_at_row(table, row_1based: int) -> int | None:
|
||||
"""Best-effort page number for a table row (handles vertical merges)."""
|
||||
# Prefer Cells — works when Rows(i) is blocked by vMerge.
|
||||
for j in range(1, 32):
|
||||
try:
|
||||
start = int(table.Cell(row_1based, j).Range.Start)
|
||||
return int(
|
||||
table.Range.Document.Range(start, start).Information(
|
||||
_WD_ACTIVE_END_PAGE_NUMBER
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Missing cell in this column (merge continue) or past last column
|
||||
if j == 1:
|
||||
continue
|
||||
# After first column, consecutive failures → stop scanning columns
|
||||
break
|
||||
try:
|
||||
rng = table.Rows(row_1based).Range
|
||||
start = int(rng.Start)
|
||||
return int(
|
||||
rng.Document.Range(start, start).Information(_WD_ACTIVE_END_PAGE_NUMBER)
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
return int(table.Rows(row_1based).Range.Information(_WD_ACTIVE_END_PAGE_NUMBER))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _probe_ncols(table) -> int:
|
||||
"""Max column index reachable from row 1 (and a few other rows)."""
|
||||
ncols = 1
|
||||
for row in (1, 2, 3):
|
||||
for probe in range(1, 64):
|
||||
try:
|
||||
table.Cell(row, probe)
|
||||
ncols = max(ncols, probe)
|
||||
except Exception:
|
||||
break
|
||||
return max(ncols, 1)
|
||||
|
||||
|
||||
def _rows_individually_accessible(table) -> bool:
|
||||
"""False when Word blocks ``Rows(i)`` due to vertical merges."""
|
||||
try:
|
||||
n = int(table.Rows.Count)
|
||||
if n < 1:
|
||||
return True
|
||||
_ = table.Rows(1).Range
|
||||
if n > 1:
|
||||
_ = table.Rows(n).Range
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _capture_vmerge_spans(table) -> list[tuple[int, int, int, str]]:
|
||||
"""Return ``(row, col, span, text)`` for each vertical merge (1-based, span>1)."""
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
return []
|
||||
ncols = _probe_ncols(table)
|
||||
spans: list[tuple[int, int, int, str]] = []
|
||||
r = 1
|
||||
while r <= nrows:
|
||||
for c in range(1, ncols + 1):
|
||||
try:
|
||||
cell = table.Cell(r, c)
|
||||
except Exception:
|
||||
continue
|
||||
span = 1
|
||||
while r + span <= nrows:
|
||||
try:
|
||||
table.Cell(r + span, c)
|
||||
break
|
||||
except Exception:
|
||||
row_exists = False
|
||||
for cc in range(1, ncols + 1):
|
||||
try:
|
||||
table.Cell(r + span, cc)
|
||||
row_exists = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not row_exists:
|
||||
break
|
||||
span += 1
|
||||
if span > 1:
|
||||
spans.append((r, c, span, _cell_plain_text(cell)))
|
||||
r += 1
|
||||
return spans
|
||||
|
||||
|
||||
def split_vmerge_spans_at_break(
|
||||
spans: list[tuple[int, int, int, str]],
|
||||
break_at: int,
|
||||
) -> tuple[list[tuple[int, int, int, str]], list[tuple[int, int, int, str]]]:
|
||||
"""Split captured merges across a 1-based ``Table.Split`` row index."""
|
||||
first: list[tuple[int, int, int, str]] = []
|
||||
cont: list[tuple[int, int, int, str]] = []
|
||||
for r, c, span, text in spans:
|
||||
if span <= 1:
|
||||
continue
|
||||
end = r + span - 1
|
||||
if end < break_at:
|
||||
first.append((r, c, span, text))
|
||||
elif r >= break_at:
|
||||
cont.append((r - break_at + 1, c, span, text))
|
||||
else:
|
||||
top = break_at - r
|
||||
if top > 1:
|
||||
first.append((r, c, top, text))
|
||||
bottom = end - break_at + 1
|
||||
if bottom >= 1:
|
||||
cont.append((1, c, bottom, text))
|
||||
return first, cont
|
||||
|
||||
|
||||
def _set_cell_plain_text(cell, text: str) -> None:
|
||||
"""Write plain text into a cell without eating the end-of-cell mark badly."""
|
||||
raw = (text or "").replace("\r", "").replace("\x07", "").replace("\a", "")
|
||||
try:
|
||||
rng = cell.Range.Duplicate
|
||||
# Exclude the cell's terminal marker characters
|
||||
if int(rng.End) > int(rng.Start) + 1:
|
||||
rng.End = int(rng.End) - 1
|
||||
rng.Text = raw
|
||||
except Exception:
|
||||
try:
|
||||
cell.Range.Text = raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_vmerge_spans(table, spans: list[tuple[int, int, int, str]]) -> int:
|
||||
"""Re-apply vertical merges after unmerge+Split. Returns merges restored."""
|
||||
made = 0
|
||||
for r, c, span, text in spans:
|
||||
if span <= 1:
|
||||
continue
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
if r < 1 or r > nrows or r + span - 1 > nrows:
|
||||
continue
|
||||
start = table.Cell(r, c)
|
||||
if text and not _cell_plain_text(start):
|
||||
_set_cell_plain_text(start, text)
|
||||
if span == 1:
|
||||
made += 1
|
||||
continue
|
||||
end = table.Cell(r + span - 1, c)
|
||||
start.Merge(end)
|
||||
made += 1
|
||||
except Exception:
|
||||
continue
|
||||
return made
|
||||
|
||||
|
||||
def _unmerge_vertical_cells(table) -> int:
|
||||
"""Split vertically merged cells so ``Table.Split`` can run.
|
||||
|
||||
Returns number of cell-groups unmerged. Safe no-op if nothing to do.
|
||||
Prefer capturing spans with ``_capture_vmerge_spans`` first and restoring
|
||||
after the table split.
|
||||
"""
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
return 0
|
||||
ncols = _probe_ncols(table)
|
||||
|
||||
made = 0
|
||||
r = 1
|
||||
while r <= nrows:
|
||||
for c in range(1, ncols + 1):
|
||||
try:
|
||||
cell = table.Cell(r, c)
|
||||
except Exception:
|
||||
continue
|
||||
span = 1
|
||||
while r + span <= nrows:
|
||||
try:
|
||||
table.Cell(r + span, c)
|
||||
break
|
||||
except Exception:
|
||||
row_exists = False
|
||||
for cc in range(1, ncols + 1):
|
||||
try:
|
||||
table.Cell(r + span, cc)
|
||||
row_exists = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not row_exists:
|
||||
break
|
||||
span += 1
|
||||
if span > 1:
|
||||
try:
|
||||
cell.Split(span, 1)
|
||||
made += 1
|
||||
except Exception:
|
||||
pass
|
||||
r += 1
|
||||
try:
|
||||
nrows = int(table.Rows.Count)
|
||||
except Exception:
|
||||
break
|
||||
return made
|
||||
|
||||
|
||||
def _paragraph_page_numbers_in_cell(table) -> list[int]:
|
||||
"""Fallback: page of each paragraph in a single-cell table (legacy 1-row listings)."""
|
||||
try:
|
||||
@@ -245,6 +503,25 @@ def _expand_single_cell_listing_to_rows(table) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _paragraph_page(paragraph) -> int | None:
|
||||
try:
|
||||
start = int(paragraph.Range.Start)
|
||||
return int(
|
||||
paragraph.Range.Document.Range(start, start).Information(
|
||||
_WD_ACTIVE_END_PAGE_NUMBER
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _caption_page_before_table(table) -> int | None:
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return None
|
||||
return _paragraph_page(p)
|
||||
|
||||
|
||||
def _fix_pass(
|
||||
doc,
|
||||
*,
|
||||
@@ -285,18 +562,81 @@ def _fix_pass(
|
||||
doc.Repaginate()
|
||||
|
||||
pages = _row_page_numbers(table)
|
||||
# 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]):
|
||||
try:
|
||||
if _page_break_before_table_caption(table):
|
||||
details.append(
|
||||
f"orphan-caption: {caption.kind} {caption.number} "
|
||||
f"→ page break before caption"
|
||||
)
|
||||
doc.Repaginate()
|
||||
else:
|
||||
details.append(
|
||||
f"skip: orphan-caption {caption.kind} {caption.number}: "
|
||||
f"no caption para"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: orphan-caption page break: {exc}")
|
||||
continue
|
||||
|
||||
break_at = find_page_break_row(pages)
|
||||
if break_at is None:
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
continue
|
||||
|
||||
# Vertical merges block Rows()/Table.Split — unmerge, split, then restore.
|
||||
vmerge_spans: list[tuple[int, int, int, str]] = []
|
||||
if not _rows_individually_accessible(table):
|
||||
try:
|
||||
vmerge_spans = _capture_vmerge_spans(table)
|
||||
n_un = _unmerge_vertical_cells(table)
|
||||
if n_un:
|
||||
details.append(
|
||||
f"unmerge: {caption.kind} {caption.number} ({n_un} groups)"
|
||||
)
|
||||
doc.Repaginate()
|
||||
table = doc.Tables(ti)
|
||||
pages = _row_page_numbers(table)
|
||||
break_at = find_page_break_row(pages)
|
||||
if break_at is None:
|
||||
details.append(
|
||||
f"skip: {caption.kind} {caption.number} "
|
||||
f"no page break after unmerge"
|
||||
)
|
||||
continue
|
||||
if break_at > int(table.Rows.Count):
|
||||
continue
|
||||
except Exception as exc:
|
||||
details.append(f"skip: unmerge {caption.kind} {caption.number}: {exc}")
|
||||
continue
|
||||
|
||||
had_header = False
|
||||
try:
|
||||
had_header = bool(table.Rows(1).HeadingFormat)
|
||||
except Exception:
|
||||
had_header = False
|
||||
|
||||
# Method guide: caption + column headers alone on a page is forbidden.
|
||||
# If Word broke right after the heading row, push the table (caption) to
|
||||
# the next page instead of splitting into a header-only fragment.
|
||||
if is_orphan_header_break(break_at, had_header=had_header):
|
||||
try:
|
||||
if _page_break_before_table_caption(table):
|
||||
details.append(
|
||||
f"orphan-header: {caption.kind} {caption.number} → page break before caption"
|
||||
)
|
||||
doc.Repaginate()
|
||||
else:
|
||||
details.append(
|
||||
f"skip: orphan-header {caption.kind} {caption.number}: no caption para"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: orphan-header page break: {exc}")
|
||||
continue
|
||||
|
||||
try:
|
||||
table.Split(break_at)
|
||||
except Exception as exc:
|
||||
@@ -304,12 +644,28 @@ def _fix_pass(
|
||||
continue
|
||||
|
||||
try:
|
||||
table = doc.Tables(ti)
|
||||
cont = doc.Tables(ti + 1)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: after split cannot get continuation table[{ti}+1]: {exc}")
|
||||
made += 1
|
||||
continue
|
||||
|
||||
if vmerge_spans:
|
||||
try:
|
||||
first_spans, cont_spans = split_vmerge_spans_at_break(
|
||||
vmerge_spans, break_at
|
||||
)
|
||||
n1 = _restore_vmerge_spans(table, first_spans)
|
||||
n2 = _restore_vmerge_spans(cont, cont_spans)
|
||||
if n1 or n2:
|
||||
details.append(
|
||||
f"remerge: {caption.kind} {caption.number} "
|
||||
f"(first={n1}, cont={n2})"
|
||||
)
|
||||
except Exception as exc:
|
||||
details.append(f"skip: remerge {caption.kind} {caption.number}: {exc}")
|
||||
|
||||
before_cont = _paragraph_text_before_table(cont)
|
||||
first_line = before_cont.strip().split("\n", 1)[0] if before_cont else ""
|
||||
if not (first_line and _CONTINUATION_RE.match(first_line)):
|
||||
@@ -332,7 +688,7 @@ def _fix_pass(
|
||||
except Exception as exc:
|
||||
details.append(f"skip: listing borders: {exc}")
|
||||
|
||||
# Header repeat is opt-in (default off)
|
||||
# Header repeat is opt-in (default off) — not Word's native tblHeader paint
|
||||
if repeat_header and had_header and caption.kind == "table":
|
||||
try:
|
||||
_ensure_header_on_continuation(first_table=table, cont_table=cont)
|
||||
@@ -351,19 +707,44 @@ def _caption_before_table(table) -> CaptionInfo | None:
|
||||
return parse_caption_text(text)
|
||||
|
||||
|
||||
def _paragraph_text_before_table(table) -> str:
|
||||
def _paragraph_before_table(table):
|
||||
"""Return the Word Paragraph immediately before ``table``, or None."""
|
||||
try:
|
||||
rng = table.Range
|
||||
if rng.Start <= 1:
|
||||
return ""
|
||||
return None
|
||||
doc = table.Range.Document
|
||||
prev = doc.Range(rng.Start - 1, rng.Start)
|
||||
p = prev.Paragraphs(1)
|
||||
return prev.Paragraphs(1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _paragraph_text_before_table(table) -> str:
|
||||
try:
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return ""
|
||||
return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _page_break_before_table_caption(table) -> bool:
|
||||
"""Set PageBreakBefore on the caption paragraph above the table."""
|
||||
p = _paragraph_before_table(table)
|
||||
if p is None:
|
||||
return False
|
||||
info = parse_caption_text(
|
||||
(p.Range.Text or "").replace("\r", "").replace("\x07", "").strip()
|
||||
)
|
||||
if info is None or info.is_continuation:
|
||||
return False
|
||||
p.Format.PageBreakBefore = True
|
||||
p.Format.KeepWithNext = True
|
||||
return True
|
||||
|
||||
|
||||
def _insert_continuation_before(doc, table, caption: CaptionInfo) -> None:
|
||||
"""Insert caption paragraph *outside* the table (before its start).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user