Files
md_to_gost/md2gost/word_fix.py
T
Igor20264 b38661f588
Python application / build (push) Has been cancelled
Update 0.4.0
- Add\Rework UI
- Add Split Table and Listing
- Add Support Customazeble schems
2026-09-04 22:28:39 +03:00

526 lines
17 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 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