907 lines
30 KiB
Python
907 lines
30 KiB
Python
"""Post-process DOCX in Word: split tables/listings at real page breaks + «Продолжение…».
|
|
|
|
Requires Windows + Microsoft Word + pywin32. Pure helpers below are unit-testable
|
|
without Word.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
|
|
# wdActiveEndPageNumber
|
|
_WD_ACTIVE_END_PAGE_NUMBER = 3
|
|
|
|
_CAPTION_RE = re.compile(
|
|
r"^(?:Продолжение\s+)?"
|
|
r"(?P<kind>Таблица|Таблицы|Листинг|Листинга)\s+"
|
|
r"(?P<number>[\d.]+)",
|
|
re.IGNORECASE,
|
|
)
|
|
_CONTINUATION_RE = re.compile(r"^Продолжение\s+(?:Таблицы|Листинга)\b", re.IGNORECASE)
|
|
|
|
MAX_PASSES = 5
|
|
|
|
|
|
@dataclass
|
|
class CaptionInfo:
|
|
kind: str # "table" | "listing"
|
|
number: str
|
|
is_continuation: bool = False
|
|
|
|
|
|
@dataclass
|
|
class FixResult:
|
|
ok: bool
|
|
splits: int = 0
|
|
skipped: int = 0
|
|
message: str = ""
|
|
details: list[str] = field(default_factory=list)
|
|
|
|
|
|
def parse_caption_text(text: str) -> CaptionInfo | None:
|
|
"""Parse «Таблица 2.1 — …» / «Продолжение Таблицы 2.1» / «Листинг 1 …»."""
|
|
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: list[int]) -> int | None:
|
|
"""
|
|
Return 1-based Word row index where a new page starts.
|
|
|
|
page_numbers[i] is the page of row i+1. None if the table does not cross pages.
|
|
"""
|
|
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: 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}"
|
|
return f"Продолжение Таблицы {number}"
|
|
|
|
|
|
def caption_style_name(kind: str) -> str:
|
|
return "Caption Listing" if kind == "listing" else "Название таблицы"
|
|
|
|
|
|
def fix_continuations(
|
|
path: str,
|
|
*,
|
|
tables: bool = True,
|
|
listings: bool = True,
|
|
repeat_header: bool = False,
|
|
) -> FixResult:
|
|
"""
|
|
Open DOCX in Word, split cross-page tables/listings, insert continuation captions.
|
|
|
|
On missing Word/pywin32 returns ok=False with a reason (caller should not fail convert).
|
|
"""
|
|
if sys.platform != "win32":
|
|
return FixResult(
|
|
False,
|
|
message="Пост-разрыв таблиц (word): только Windows + Microsoft Word.",
|
|
)
|
|
try:
|
|
import win32com.client # type: ignore
|
|
except ImportError:
|
|
return FixResult(
|
|
False,
|
|
message=(
|
|
"Пост-разрыв таблиц пропущен: нужен pywin32 "
|
|
"(pip install pywin32) и Microsoft Word."
|
|
),
|
|
)
|
|
|
|
abs_path = os.path.abspath(path)
|
|
if not os.path.isfile(abs_path):
|
|
return FixResult(False, message=f"Файл не найден: {abs_path}")
|
|
|
|
word = None
|
|
doc = None
|
|
splits = 0
|
|
details: list[str] = []
|
|
try:
|
|
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):
|
|
doc.Repaginate()
|
|
made = _fix_pass(
|
|
doc,
|
|
tables=tables,
|
|
listings=listings,
|
|
repeat_header=repeat_header,
|
|
details=details,
|
|
)
|
|
if made == 0:
|
|
break
|
|
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}"
|
|
+ (f", пропусков {skipped}" if skipped else "")
|
|
+ "."
|
|
)
|
|
return FixResult(True, splits=splits, skipped=skipped, message=msg, details=details)
|
|
except Exception as exc:
|
|
skipped = sum(1 for d in details if d.startswith("skip:"))
|
|
return FixResult(
|
|
False,
|
|
splits=splits,
|
|
skipped=skipped,
|
|
message=f"Пост-разрыв таблиц не удался: {exc}",
|
|
details=details,
|
|
)
|
|
finally:
|
|
if doc is not None:
|
|
try:
|
|
doc.Close(False)
|
|
except Exception:
|
|
pass
|
|
if word is not None:
|
|
try:
|
|
word.Quit()
|
|
except Exception:
|
|
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).
|
|
|
|
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):
|
|
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:
|
|
if int(table.Rows.Count) != 1:
|
|
return []
|
|
cell = table.Cell(1, 1)
|
|
paras = cell.Range.Paragraphs
|
|
pages: list[int] = []
|
|
for i in range(1, int(paras.Count) + 1):
|
|
try:
|
|
start = int(paras(i).Range.Start)
|
|
page = int(paras(i).Range.Document.Range(start, start).Information(
|
|
_WD_ACTIVE_END_PAGE_NUMBER
|
|
))
|
|
except Exception:
|
|
page = pages[-1] if pages else 1
|
|
pages.append(page)
|
|
return pages
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _expand_single_cell_listing_to_rows(table) -> bool:
|
|
"""
|
|
Convert 1-row listing (many paragraphs in one cell) into one row per paragraph.
|
|
Returns True if the table was rewritten.
|
|
"""
|
|
try:
|
|
if int(table.Rows.Count) != 1:
|
|
return False
|
|
cell = table.Cell(1, 1)
|
|
paras = cell.Range.Paragraphs
|
|
count = int(paras.Count)
|
|
if count <= 1:
|
|
return False
|
|
# Collect plain texts first (mutating while iterating is unsafe)
|
|
texts: list[str] = []
|
|
for i in range(1, count + 1):
|
|
t = (paras(i).Range.Text or "").replace("\r", "").replace("\x07", "")
|
|
texts.append(t)
|
|
# Clear cell, keep first paragraph as first row content
|
|
cell.Range.Text = texts[0] if texts else ""
|
|
# Add rows for remaining lines
|
|
for t in texts[1:]:
|
|
row = table.Rows.Add()
|
|
row.Cells(1).Range.Text = t
|
|
return True
|
|
except Exception:
|
|
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,
|
|
*,
|
|
tables: bool,
|
|
listings: bool,
|
|
repeat_header: bool,
|
|
details: list[str],
|
|
) -> int:
|
|
"""One pass over tables (bottom-up). Returns number of splits performed."""
|
|
made = 0
|
|
count = int(doc.Tables.Count)
|
|
for ti in range(count, 0, -1):
|
|
try:
|
|
table = doc.Tables(ti)
|
|
except Exception:
|
|
details.append(f"skip: table[{ti}] inaccessible")
|
|
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 not tables:
|
|
continue
|
|
if caption.kind == "listing" and not listings:
|
|
continue
|
|
|
|
# Legacy single-cell listings: expand to rows so Split works
|
|
if caption.kind == "listing" and int(table.Rows.Count) == 1:
|
|
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()
|
|
|
|
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:
|
|
details.append(f"skip: split table[{ti}] row {break_at}: {exc}")
|
|
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)):
|
|
try:
|
|
_insert_continuation_before(doc, cont, caption)
|
|
except Exception as exc:
|
|
details.append(f"skip: insert caption after split: {exc}")
|
|
|
|
# Open bottom of first fragment (tables only — listings keep a full frame)
|
|
if caption.kind == "table":
|
|
try:
|
|
_clear_table_bottom_border(table)
|
|
except Exception as exc:
|
|
details.append(f"skip: clear bottom border: {exc}")
|
|
|
|
if caption.kind == "listing":
|
|
try:
|
|
_apply_listing_box_borders(table)
|
|
_apply_listing_box_borders(cont)
|
|
except Exception as exc:
|
|
details.append(f"skip: listing borders: {exc}")
|
|
|
|
# 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)
|
|
except Exception as exc:
|
|
details.append(f"skip: header copy: {exc}")
|
|
|
|
made += 1
|
|
details.append(f"split: {caption.kind} {caption.number} @row {break_at}")
|
|
|
|
return made
|
|
|
|
def _caption_before_table(table) -> CaptionInfo | None:
|
|
text = _paragraph_text_before_table(table)
|
|
if not text:
|
|
return None
|
|
return parse_caption_text(text)
|
|
|
|
|
|
def _paragraph_before_table(table):
|
|
"""Return the Word Paragraph immediately before ``table``, or None."""
|
|
try:
|
|
rng = table.Range
|
|
if rng.Start <= 1:
|
|
return None
|
|
doc = table.Range.Document
|
|
prev = doc.Range(rng.Start - 1, rng.Start)
|
|
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).
|
|
|
|
``InsertBefore`` at ``table.Range.Start`` puts text into the first cell —
|
|
Word treats the table start as inside the table. Move one character before
|
|
the table, insert a paragraph break, then fill that new paragraph.
|
|
"""
|
|
label = continuation_label(caption.kind, caption.number)
|
|
style = caption_style_name(caption.kind)
|
|
|
|
# wdCollapseStart=1, wdCharacter=1
|
|
rng = table.Range.Duplicate
|
|
rng.Collapse(1)
|
|
start0 = int(table.Range.Start)
|
|
if start0 > 0:
|
|
rng.Move(1, -1) # land on the paragraph mark before the table
|
|
rng.InsertParagraphAfter()
|
|
# New empty paragraph sits between previous content and the table.
|
|
# Refresh table start — it moved forward by one paragraph mark.
|
|
table_start = int(table.Range.Start)
|
|
if table_start < 1:
|
|
raise RuntimeError("table at document start after insert")
|
|
# Paragraph immediately before the table
|
|
para = doc.Range(table_start - 1, table_start - 1).Paragraphs(1)
|
|
# Write into the paragraph without including the trailing \r that borders the table
|
|
text_rng = para.Range.Duplicate
|
|
# Exclude final paragraph mark so we don't merge into the table
|
|
if int(text_rng.End) > int(text_rng.Start):
|
|
text_rng.End = int(text_rng.End) - 1
|
|
text_rng.Text = label
|
|
|
|
try:
|
|
para.Style = style
|
|
except Exception:
|
|
# Fallback if RU style missing in older docs
|
|
try:
|
|
para.Style = "Caption Table" if caption.kind == "table" else "Caption"
|
|
except Exception:
|
|
pass
|
|
try:
|
|
para.Range.Font.Italic = True
|
|
para.Range.Font.Bold = False
|
|
para.Range.Font.Underline = 0 # wdUnderlineNone
|
|
para.Range.Font.Name = "Times New Roman"
|
|
para.Range.Font.Size = 12
|
|
except Exception:
|
|
pass
|
|
try:
|
|
para.Format.FirstLineIndent = 0
|
|
para.Format.SpaceAfter = 0
|
|
para.Format.SpaceBefore = 6 # pt ≈ Mm(6) for first continuation look
|
|
para.Format.KeepWithNext = True
|
|
para.Format.Alignment = 0 # wdAlignParagraphLeft
|
|
except Exception:
|
|
pass
|
|
|
|
# Sanity: caption must not live inside a table cell
|
|
try:
|
|
if int(para.Range.Tables.Count) > 0:
|
|
raise RuntimeError("continuation caption landed inside a table")
|
|
except AttributeError:
|
|
pass
|
|
|
|
|
|
# WdBorderType (Word): top=-1, left=-2, bottom=-3, right=-4, insideH=-5, insideV=-6
|
|
_WD_BORDER_TOP = -1
|
|
_WD_BORDER_LEFT = -2
|
|
_WD_BORDER_BOTTOM = -3
|
|
_WD_BORDER_RIGHT = -4
|
|
_WD_BORDER_HORIZONTAL = -5
|
|
_WD_BORDER_VERTICAL = -6
|
|
_WD_LINE_STYLE_NONE = 0
|
|
_WD_LINE_STYLE_SINGLE = 1
|
|
|
|
|
|
def _set_border(table, border_id: int, *, line_style: int, line_width: float = 0.5) -> None:
|
|
b = table.Borders(border_id)
|
|
b.LineStyle = line_style
|
|
if line_style != _WD_LINE_STYLE_NONE:
|
|
try:
|
|
b.LineWidth = line_width
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _clear_table_bottom_border(table) -> None:
|
|
_set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_NONE)
|
|
|
|
|
|
def _apply_listing_box_borders(table) -> None:
|
|
"""Outer frame only — no inside H/V (code block look)."""
|
|
_set_border(table, _WD_BORDER_TOP, line_style=_WD_LINE_STYLE_SINGLE)
|
|
_set_border(table, _WD_BORDER_LEFT, line_style=_WD_LINE_STYLE_SINGLE)
|
|
_set_border(table, _WD_BORDER_BOTTOM, line_style=_WD_LINE_STYLE_SINGLE)
|
|
_set_border(table, _WD_BORDER_RIGHT, line_style=_WD_LINE_STYLE_SINGLE)
|
|
try:
|
|
_set_border(table, _WD_BORDER_HORIZONTAL, line_style=_WD_LINE_STYLE_NONE)
|
|
_set_border(table, _WD_BORDER_VERTICAL, line_style=_WD_LINE_STYLE_NONE)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _cell_plain_text(cell) -> str:
|
|
"""Cell text without end-of-cell markers."""
|
|
raw = cell.Range.Text or ""
|
|
return raw.replace("\r", "").replace("\x07", "").replace("\a", "").strip("\n")
|
|
|
|
|
|
def _ensure_header_on_continuation(*, first_table, cont_table) -> None:
|
|
"""Prepend a copy of the first fragment's header row onto the continuation table."""
|
|
try:
|
|
if int(cont_table.Rows.Count) < 1:
|
|
return
|
|
if bool(cont_table.Rows(1).HeadingFormat):
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
hdr = first_table.Rows(1)
|
|
n_hdr = int(hdr.Cells.Count)
|
|
# Insert empty row above first data row
|
|
new_row = cont_table.Rows.Add(BeforeRow=cont_table.Rows(1))
|
|
n_new = int(new_row.Cells.Count)
|
|
n = min(n_hdr, n_new)
|
|
for ci in range(1, n + 1):
|
|
try:
|
|
# Prefer FormattedText but strip the cell's terminal markers by
|
|
# assigning only the in-cell paragraph text.
|
|
src_cell = hdr.Cells(ci)
|
|
dst_cell = new_row.Cells(ci)
|
|
# Clear destination cell paragraphs then copy plain text
|
|
dst_cell.Range.Text = ""
|
|
plain = _cell_plain_text(src_cell)
|
|
# Setting Range.Text on a cell appends \r\a — pass plain only
|
|
if plain:
|
|
dst_cell.Range.Text = plain
|
|
# Best-effort: copy bold/italic from first paragraph of source
|
|
try:
|
|
src_font = src_cell.Range.Paragraphs(1).Range.Font
|
|
dst_p = dst_cell.Range.Paragraphs(1).Range
|
|
dst_p.Font.Bold = src_font.Bold
|
|
dst_p.Font.Italic = False # header data, not caption
|
|
dst_p.Font.Name = src_font.Name
|
|
dst_p.Font.Size = src_font.Size
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
continue
|
|
new_row.HeadingFormat = True
|
|
# Ensure data rows are not marked as header
|
|
try:
|
|
for ri in range(2, int(cont_table.Rows.Count) + 1):
|
|
cont_table.Rows(ri).HeadingFormat = False
|
|
except Exception:
|
|
pass
|
|
except Exception:
|
|
# Don't leave a half-broken row — best effort only
|
|
pass
|