"""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Таблица|Таблицы|Листинг|Листинга)\s+" r"(?P[\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