- Add\Rework UI - Add Split Table and Listing - Add Support Customazeble schems
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user