"""Post-conversion heuristic: half-empty pages inside an unfinished section. Requires Windows + Microsoft Word + pywin32. Findings are ALWAYS heuristic — false positives are expected (large figures, end of subsection, odd breaks). Never treat as --strict errors. """ from __future__ import annotations from dataclasses import dataclass # Unused fraction of the text area below the last content on the page. EMPTY_FRACTION_THRESHOLD = 0.40 FRONT_MATTER_HINTS = ( "СОДЕРЖАНИЕ", "ТИТУЛ", "ЗАДАНИЕ", ) @dataclass class PageMetric: """Synthetic / measured metrics for one page (CI-friendly).""" page_index: int # 1-based content_bottom_frac: float # 0..1, how far down the text area content reaches section_title: str next_section_title: str | None is_landscape: bool = False is_last_doc_page: bool = False mostly_figure_or_table: bool = False @dataclass class PageFillIssue: page_index: int message: str severity: str = "heuristic" def evaluate_page_fill( pages: list[PageMetric], *, empty_threshold: float = EMPTY_FRACTION_THRESHOLD, ) -> list[PageFillIssue]: """Pure heuristic over page metrics (no Word). Safe for unit tests.""" issues: list[PageFillIssue] = [] for i, page in enumerate(pages): if page.is_landscape: continue if page.mostly_figure_or_table: continue if page.is_last_doc_page: continue title_u = (page.section_title or "").upper() if any(h in title_u for h in FRONT_MATTER_HINTS): continue # Last page of this section (next page starts a different H1) if page.next_section_title and page.next_section_title != page.section_title: continue empty_frac = 1.0 - page.content_bottom_frac if empty_frac <= empty_threshold: continue # Same section continues on the next page if i + 1 >= len(pages): continue nxt = pages[i + 1] if nxt.section_title != page.section_title: continue issues.append(PageFillIssue( page_index=page.page_index, message=( f"[heuristic] page.fill: на стр. {page.page_index} пустой низ " f"~{empty_frac:.0%} полосы, а раздел «{page.section_title}» " f"продолжается на следующей странице. " f"Проверка эвристическая — возможны ложные срабатывания " f"(крупный рисунок, конец пункта, нестандартный разрыв)." ), )) return issues def check_docx_page_fill(path: str) -> tuple[list[PageFillIssue], str]: """ Open DOCX in Word, repaginate, collect metrics, evaluate. Returns (issues, status_message). On missing Word/pywin32 returns ([], reason). """ try: import win32com.client # type: ignore except ImportError: return [], ( "Проверка вёрстки пропущена: нужен пакет pywin32 " "(pip install pywin32) и Microsoft Word." ) word = None doc = None try: word = win32com.client.DispatchEx("Word.Application") word.Visible = False word.DisplayAlerts = 0 abs_path = str(path) doc = word.Documents.Open(abs_path, ReadOnly=True) doc.Repaginate() pages_metrics: list[PageMetric] = [] page_count = int(doc.ComputeStatistics(2)) # wdStatisticPages for page_no in range(1, page_count + 1): try: metric = _measure_page(doc, page_no, page_count) except Exception: continue if metric is not None: pages_metrics.append(metric) issues = evaluate_page_fill(pages_metrics) if not issues: return [], ( "Проверка вёрстки (Word): замечаний по полупустым страницам нет " "(эвристика; возможны пропуски)." ) return issues, ( f"Проверка вёрстки (Word): найдено замечаний — {len(issues)} " f"(все эвристические, могут быть ложными)." ) except Exception as exc: return [], f"Проверка вёрстки пропущена: не удалось открыть Word ({exc})." finally: try: if doc is not None: doc.Close(False) except Exception: pass try: if word is not None: word.Quit() except Exception: pass def _measure_page(doc, page_no: int, page_count: int) -> PageMetric | None: """Best-effort measurement via Word COM selection / page setup.""" selection = doc.Application.Selection selection.GoTo(What=1, Which=1, Count=page_no) # wdGoToPage, wdGoToAbsolute section = selection.Sections(1) ps = section.PageSetup is_landscape = bool(int(ps.Orientation) == 1) # wdOrientLandscape page_h = float(ps.PageHeight) top = float(ps.TopMargin) bottom = float(ps.BottomMargin) text_h = max(page_h - top - bottom, 1.0) start = int(selection.Start) if page_no < page_count: selection.GoTo(What=1, Which=1, Count=page_no + 1) end = int(selection.Start) - 1 else: end = int(doc.Content.End) if end < start: end = start rng = doc.Range(start, end) try: # wdVerticalPositionRelativeToPage = 6 vpos = float(rng.Information(6)) content_bottom = max(0.0, min(1.0, (vpos - top) / text_h)) except Exception: content_bottom = 1.0 section_title = _heading_near(doc, start) next_title = None if page_no < page_count: try: selection.GoTo(What=1, Which=1, Count=page_no + 1) next_title = _heading_near(doc, int(selection.Start)) except Exception: next_title = None mostly_object = False try: text_len = len((rng.Text or "").strip()) if rng.Tables.Count >= 1 and text_len < 80: mostly_object = True if rng.InlineShapes.Count >= 1 and text_len < 80: mostly_object = True except Exception: pass return PageMetric( page_index=page_no, content_bottom_frac=content_bottom, section_title=section_title or "", next_section_title=next_title, is_landscape=is_landscape, is_last_doc_page=(page_no == page_count), mostly_figure_or_table=mostly_object, ) def _heading_near(doc, pos: int) -> str: """Walk backwards for nearest Heading 1 style paragraph.""" try: p = doc.Range(pos, pos).Paragraphs(1) for _ in range(80): style = str(p.Style) if "Heading 1" in style or "Заголовок 1" in style: return (p.Range.Text or "").strip().replace("\r", "") if p.Range.Start <= 1: break p = p.Previous() if p is None: break except Exception: pass return "" def format_page_fill_report(issues: list[PageFillIssue], status: str) -> str: lines = [status] for i in issues: lines.append(f" [{i.severity}] page:{i.page_index}: {i.message}") return "\n".join(lines)