b38661f588
Python application / build (push) Has been cancelled
- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
227 lines
7.4 KiB
Python
227 lines
7.4 KiB
Python
"""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)
|