120 lines
4.0 KiB
Python
120 lines
4.0 KiB
Python
"""Защита заголовков групп от «висячих» переносов на границе страницы (как в десктопе)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
# Совпадает с TABLE_LAYOUTS в pdf_export.py
|
||
PAGE_LIMITS: dict[str, tuple[int, int]] = {
|
||
"perechen": (25, 32),
|
||
"specification_pcb": (26, 32),
|
||
"specification": (26, 32),
|
||
"vedomost": (24, 29),
|
||
}
|
||
|
||
_TEXT_SKIP = frozenset({"id", "row_index", "page_number", "overflow_fields"})
|
||
|
||
|
||
def assign_page_numbers(rows: list[dict[str, Any]], first: int, other: int) -> None:
|
||
page = 1
|
||
used = 0
|
||
limit = first
|
||
for i, row in enumerate(rows):
|
||
if used >= limit:
|
||
page += 1
|
||
used = 0
|
||
limit = other
|
||
row["row_index"] = i
|
||
row["page_number"] = page
|
||
used += 1
|
||
|
||
|
||
def _rows_on_page_before(rows: list[dict[str, Any]], idx: int, page: int) -> int:
|
||
return sum(1 for j in range(idx) if rows[j].get("page_number") == page)
|
||
|
||
|
||
def _group_continues_on_next_page(rows: list[dict[str, Any]], header_idx: int, page: int) -> bool:
|
||
for i in range(header_idx + 1, len(rows)):
|
||
r = rows[i]
|
||
if r.get("is_header"):
|
||
break
|
||
if r.get("page_number") == page + 1 and not r.get("is_empty"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def needs_page_break_protection(
|
||
rows: list[dict[str, Any]], row_index: int, first: int, other: int
|
||
) -> tuple[bool, int]:
|
||
"""Возвращает (нужна_защита, сколько_пустых_вставить_перед_заголовком)."""
|
||
if row_index < 0 or row_index >= len(rows):
|
||
return False, 0
|
||
row = rows[row_index]
|
||
if not row.get("is_header"):
|
||
return False, 0
|
||
|
||
page = int(row.get("page_number") or 1)
|
||
max_rows = first if page == 1 else other
|
||
on_page = _rows_on_page_before(rows, row_index, page)
|
||
|
||
if on_page not in (max_rows - 1, max_rows - 2):
|
||
return False, 0
|
||
if not _group_continues_on_next_page(rows, row_index, page):
|
||
return False, 0
|
||
|
||
empty_count = 2 if on_page == max_rows - 2 else 1
|
||
return True, empty_count
|
||
|
||
|
||
def _make_empty_row(sample: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
empty: dict[str, Any] = {
|
||
"is_empty": True,
|
||
"is_header": False,
|
||
"is_underline": False,
|
||
"page_number": 1,
|
||
"row_index": 0,
|
||
}
|
||
if sample:
|
||
for k, v in sample.items():
|
||
if k in _TEXT_SKIP or k in empty:
|
||
continue
|
||
if isinstance(v, bool):
|
||
empty[k] = False
|
||
elif isinstance(v, (int, float)) and k not in ("id",):
|
||
empty[k] = ""
|
||
elif isinstance(v, str):
|
||
empty[k] = ""
|
||
elif v is None:
|
||
empty[k] = ""
|
||
return empty
|
||
|
||
|
||
def protect_page_breaks(rows: list[dict[str, Any]], table_type: str) -> list[dict[str, Any]]:
|
||
"""Вставляет 1–2 пустые строки перед orphan-заголовком; пересчитывает page_number."""
|
||
if table_type not in PAGE_LIMITS or not rows:
|
||
return rows
|
||
|
||
first, other = PAGE_LIMITS[table_type]
|
||
working = [dict(r) for r in rows]
|
||
sample = working[0] if working else None
|
||
|
||
# Повторяем, пока есть что защищать (вставки сдвигают границы страниц)
|
||
for _ in range(len(working) + 2):
|
||
assign_page_numbers(working, first, other)
|
||
insert_at: list[tuple[int, int]] = []
|
||
for i in range(len(working)):
|
||
need, count = needs_page_break_protection(working, i, first, other)
|
||
if need:
|
||
insert_at.append((i, count))
|
||
if not insert_at:
|
||
break
|
||
# С конца, чтобы индексы не сбивались
|
||
for idx, count in reversed(insert_at):
|
||
for _ in range(count):
|
||
working.insert(idx, _make_empty_row(sample))
|
||
else:
|
||
assign_page_numbers(working, first, other)
|
||
|
||
assign_page_numbers(working, first, other)
|
||
return working
|