Files
md_to_gost/md2gost/page_fill_check.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

227 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)