Files
Roster/combined_roster.py

181 lines
5.7 KiB
Python

"""Vector-PDF composition for a main roster plus a nursery-only strip."""
from __future__ import annotations
from dataclasses import dataclass
from os import PathLike
from pathlib import Path
import fitz # type: ignore
@dataclass(frozen=True)
class SectionPlacement:
x: float
y: float
scale: float
@dataclass(frozen=True)
class PlacedSection:
source: fitz.Rect
destination: fitz.Rect
@dataclass(frozen=True)
class CombinedComposition:
main: PlacedSection
nursery: PlacedSection
def useful_content_bounds(page: fitz.Page, padding: float = 1.5) -> fitz.Rect:
"""Find useful table bounds from vector drawings and extracted content.
Near-full-page rectangles are ignored so a PDF background cannot defeat the
crop. Subsplash table cells and text remain part of the union.
"""
page_rect = page.rect
maximum_background_area = page_rect.get_area() * 0.95
text_rectangles: list[fitz.Rect] = []
for block in page.get_text("blocks"):
if len(block) > 6 and block[6] != 0:
continue
rect = fitz.Rect(block[:4]) & page_rect
if not rect.is_empty and rect.get_area() < maximum_background_area:
text_rectangles.append(rect)
text_bounds: fitz.Rect | None = None
if text_rectangles:
text_bounds = fitz.Rect(text_rectangles[0])
for rectangle in text_rectangles[1:]:
text_bounds.include_rect(rectangle)
# A clipped PDF form can expose drawing metadata for content outside its
# visible clip. Anchor drawings to the actual extracted-text rows so hidden
# table lines cannot expand a nursery strip back to a full page.
visible_rows = None
if text_bounds is not None:
visible_rows = fitz.Rect(
page_rect.x0,
max(page_rect.y0, text_bounds.y0 - 8),
page_rect.x1,
min(page_rect.y1, text_bounds.y1 + 8),
)
drawing_rectangles: list[fitz.Rect] = []
for drawing in page.get_drawings():
rect = fitz.Rect(drawing["rect"])
if rect.width == 0 and rect.height > 0:
rect.x0 -= 0.35
rect.x1 += 0.35
if rect.height == 0 and rect.width > 0:
rect.y0 -= 0.35
rect.y1 += 0.35
rect &= page_rect
if rect.is_empty or rect.get_area() >= maximum_background_area:
continue
if visible_rows is not None and (rect & visible_rows).is_empty:
continue
drawing_rectangles.append(rect)
candidates = text_rectangles + drawing_rectangles
if not candidates:
raise ValueError("No table content could be found in the roster PDF.")
bounds = fitz.Rect(candidates[0])
for candidate in candidates[1:]:
bounds.include_rect(candidate)
if text_bounds is not None:
bounds.y1 = min(bounds.y1, text_bounds.y1 + 8)
return fitz.Rect(
max(page_rect.x0, bounds.x0 - padding),
max(page_rect.y0, bounds.y0 - padding),
min(page_rect.x1, bounds.x1 + padding),
min(page_rect.y1, bounds.y1 + padding),
)
def _place_section(
destination_page: fitz.Page,
source_document: fitz.Document,
placement: SectionPlacement,
) -> PlacedSection:
source = useful_content_bounds(source_document[0])
destination = fitz.Rect(
placement.x,
placement.y,
placement.x + source.width * placement.scale,
placement.y + source.height * placement.scale,
)
destination_page.show_pdf_page(
destination,
source_document,
0,
clip=source,
keep_proportion=False,
overlay=True,
)
return PlacedSection(source=source, destination=destination)
def compose_combined_pdf(
main_pdf: str | PathLike[str],
nursery_pdf: str | PathLike[str],
output_pdf: str | PathLike[str],
*,
page_width: float,
page_height: float,
main_placement: SectionPlacement,
nursery_placement: SectionPlacement,
logo_path: str | PathLike[str],
logo_position: tuple[float, float],
logo_scale: float,
month_label: str,
font_path: str | PathLike[str],
date_position: tuple[float, float],
date_font_size: float,
) -> CombinedComposition:
"""Compose both source tables and the shared header into one vector PDF."""
main_document = fitz.open(main_pdf)
nursery_document = fitz.open(nursery_pdf)
output_document = fitz.open()
try:
page = output_document.new_page(width=page_width, height=page_height)
page.draw_rect(page.rect, color=None, fill=(1, 1, 1), overlay=False)
main_section = _place_section(page, main_document, main_placement)
nursery_section = _place_section(page, nursery_document, nursery_placement)
logo_pixmap = fitz.Pixmap(str(Path(logo_path).resolve()))
logo_width = logo_pixmap.width * logo_scale
logo_height = logo_pixmap.height * logo_scale
logo_pixmap = None
logo_x, logo_y = logo_position
page.insert_image(
fitz.Rect(logo_x, logo_y, logo_x + logo_width, logo_y + logo_height),
filename=str(Path(logo_path).resolve()),
overlay=True,
)
resolved_font = str(Path(font_path).resolve())
page.insert_font(fontname="custom", fontfile=resolved_font)
date_x, date_y = date_position
page.insert_text(
(date_x, date_y),
month_label,
fontname="custom",
fontsize=date_font_size,
color=(0, 0, 0),
overlay=True,
)
output_document.save(output_pdf)
return CombinedComposition(main=main_section, nursery=nursery_section)
finally:
output_document.close()
nursery_document.close()
main_document.close()