"""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 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:")) 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 _row_page_numbers(table) -> list[int]: """Page number at the start of each row (more reliable than end-of-range).""" 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 pages.append(page) return pages 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 _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) break_at = find_page_break_row(pages) if break_at is None: continue if break_at > int(table.Rows.Count): continue had_header = False try: had_header = bool(table.Rows(1).HeadingFormat) except Exception: had_header = False try: table.Split(break_at) except Exception as exc: details.append(f"skip: split table[{ti}] row {break_at}: {exc}") continue try: 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 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) 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_text_before_table(table) -> str: try: rng = table.Range if rng.Start <= 1: return "" doc = table.Range.Document prev = doc.Range(rng.Start - 1, rng.Start) p = prev.Paragraphs(1) return (p.Range.Text or "").replace("\r", "").replace("\x07", "").strip() except Exception: return "" 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