Add optional nursery roster composition
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user