181 lines
5.7 KiB
Python
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()
|