"""Shared PDF-to-PNG roster formatter. The legacy members of :class:`LayoutSettings` preserve the original production geometry byte-for-byte. Combined-mode members place independently clipped vector tables on the same fixed canvas. Both the CLI and web server share this module so the browser workflow cannot silently introduce another renderer. """ from __future__ import annotations from calendar import month_name from dataclasses import dataclass, fields from datetime import date, datetime from math import isfinite from os import PathLike, fsync, replace from pathlib import Path import re from shutil import copyfileobj from tempfile import NamedTemporaryFile, TemporaryDirectory from typing import Mapping import fitz # type: ignore from combined_roster import SectionPlacement, compose_combined_pdf from modules.add_date import add_date from modules.add_logo import add_logo from modules.adjust_layout import adjust_layout from roster_classifier import RosterClassificationError, assign_roster_pdfs BASE_DIR = Path(__file__).resolve().parent LOGO_PATH = BASE_DIR / "assets" / "logo.png" FONT_PATH = BASE_DIR / "assets" / "fonts" / "Roboto-Variable.ttf" # Fixed export geometry. These are intentionally not web-editable. PAGE_WIDTH = 792 PAGE_HEIGHT = 612 PNG_DPI = 150 PNG_WIDTH = 1650 PNG_HEIGHT = 1275 COMBINED_LOGO_SCALE_FACTOR = 0.76 COMBINED_DATE_FONT_SCALE_FACTOR = 5 / 6 _MONTH_ALIASES = { alias.lower(): number for number in range(1, 13) for alias in (month_name[number], month_name[number][:3]) } _MONTH_ALIASES["sept"] = 9 _HUMAN_PERIOD_RE = re.compile(r"^([A-Za-z]+)(?:\s+([0-9]{4}))?$") _ISO_PERIOD_RE = re.compile(r"^([0-9]{4})-([0-9]{2})$") _OUTPUT_NAME_RE = re.compile( r"^(JAN|FEB|MARCH|APRIL|MAY|JUNE|JULY|AUG|SEP|OCT|NOV|DEC) " r"([0-9]{4}) Roster\.png$" ) _OUTPUT_LABEL_TO_MONTH = { "JAN": 1, "FEB": 2, "MARCH": 3, "APRIL": 4, "MAY": 5, "JUNE": 6, "JULY": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12, } class RosterFormatError(ValueError): """A safe, user-facing roster formatting error.""" @dataclass(frozen=True, order=True) class RosterPeriod: """The month represented by a formatted roster.""" year: int month: int def __post_init__(self) -> None: if not 1 <= self.month <= 12: raise RosterFormatError("The roster month must be between 1 and 12.") if not 1900 <= self.year <= 9999: raise RosterFormatError("The roster year must contain four digits.") @property def month_token(self) -> str: full_month = month_name[self.month].upper() return full_month[:3] if len(full_month) > 5 else full_month @property def label(self) -> str: return f"{self.month_token} {self.year}" @property def html_value(self) -> str: return f"{self.year:04d}-{self.month:02d}" @property def output_filename(self) -> str: return f"{self.label} Roster.png" @classmethod def suggested(cls, today: date | datetime | None = None) -> "RosterPeriod": current = (today or date.today()).date() if isinstance(today, datetime) else (today or date.today()) year = current.year month = current.month if current.day >= 15: month += 1 if month == 13: month = 1 year += 1 return cls(year=year, month=month) @classmethod def parse( cls, value: str | None, today: date | datetime | None = None, ) -> "RosterPeriod": if value is None or not value.strip(): return cls.suggested(today) cleaned = value.strip() iso_match = _ISO_PERIOD_RE.fullmatch(cleaned) if iso_match: return cls(year=int(iso_match.group(1)), month=int(iso_match.group(2))) human_match = _HUMAN_PERIOD_RE.fullmatch(cleaned) if not human_match: raise RosterFormatError( "Use a month and four-digit year, such as July 2026." ) month = _MONTH_ALIASES.get(human_match.group(1).lower()) if month is None: raise RosterFormatError(f"Unknown month: {human_match.group(1)}.") current = (today or date.today()).date() if isinstance(today, datetime) else (today or date.today()) year = int(human_match.group(2)) if human_match.group(2) else current.year return cls(year=year, month=month) @classmethod def from_output_filename(cls, filename: str) -> "RosterPeriod | None": match = _OUTPUT_NAME_RE.fullmatch(filename) if not match: return None return cls(year=int(match.group(2)), month=_OUTPUT_LABEL_TO_MONTH[match.group(1)]) @dataclass(frozen=True) class LayoutSettings: """User-adjustable legacy and combined-mode layout values.""" content_shift: float = 100 bottom_margin: float = 60 logo_x: float = 50 logo_y: float = -10 logo_scale: float = 0.27 date_x: float = 460 date_y: float = 88 date_font_size: int = 48 main_x: float = 66 main_y: float = 120 main_scale: float = 0.86 nursery_x: float = 71 nursery_y: float = 519 nursery_scale: float = 0.85 def __post_init__(self) -> None: limits = { "content_shift": (-300, 500), "bottom_margin": (0, 500), "logo_x": (-1000, 2000), "logo_y": (-1000, 2000), "logo_scale": (0.01, 2), "date_x": (-1000, 2000), "date_y": (-1000, 2000), "main_x": (-1000, 2000), "main_y": (-1000, 2000), "main_scale": (0.1, 2), "nursery_x": (-1000, 2000), "nursery_y": (-1000, 2000), "nursery_scale": (0.1, 2), } for name, (minimum, maximum) in limits.items(): value = getattr(self, name) if not isinstance(value, (int, float)) or not isfinite(value): raise RosterFormatError(f"{name.replace('_', ' ').title()} must be a number.") if not minimum <= value <= maximum: raise RosterFormatError( f"{name.replace('_', ' ').title()} must be between {minimum} and {maximum}." ) if self.bottom_margin >= PAGE_HEIGHT: raise RosterFormatError("Bottom margin must be smaller than the page height.") if isinstance(self.date_font_size, bool) or not isinstance(self.date_font_size, int): raise RosterFormatError("Date font size must be a whole number.") if not 8 <= self.date_font_size <= 200: raise RosterFormatError("Date font size must be between 8 and 200.") @classmethod def from_mapping( cls, values: Mapping[str, str], defaults: "LayoutSettings | None" = None, ) -> "LayoutSettings": fallback = defaults or cls() def parse_float(name: str) -> float: raw = values.get(name) if raw is None or not raw.strip(): return float(getattr(fallback, name)) try: return float(raw) except ValueError as exc: raise RosterFormatError( f"{name.replace('_', ' ').title()} must be a number." ) from exc raw_font_size = values.get("date_font_size") if raw_font_size is None or not raw_font_size.strip(): font_size = fallback.date_font_size else: try: font_size = int(raw_font_size) except ValueError as exc: raise RosterFormatError("Date font size must be a whole number.") from exc return cls( content_shift=parse_float("content_shift"), bottom_margin=parse_float("bottom_margin"), logo_x=parse_float("logo_x"), logo_y=parse_float("logo_y"), logo_scale=parse_float("logo_scale"), date_x=parse_float("date_x"), date_y=parse_float("date_y"), date_font_size=font_size, main_x=parse_float("main_x"), main_y=parse_float("main_y"), main_scale=parse_float("main_scale"), nursery_x=parse_float("nursery_x"), nursery_y=parse_float("nursery_y"), nursery_scale=parse_float("nursery_scale"), ) def as_form_values(self) -> dict[str, str]: return {field.name: str(getattr(self, field.name)) for field in fields(self)} def as_json_values(self) -> dict[str, float | int]: return {field.name: getattr(self, field.name) for field in fields(self)} DEFAULT_LAYOUT = LayoutSettings() LEGACY_LAYOUT_FIELDS = frozenset( { "content_shift", "bottom_margin", "logo_x", "logo_y", "logo_scale", "date_x", "date_y", "date_font_size", } ) CURRENT_LAYOUT_FIELDS = frozenset(DEFAULT_LAYOUT.as_form_values()) @dataclass(frozen=True) class FormatResult: path: Path period: RosterPeriod @property def filename(self) -> str: return self.period.output_filename def validate_input_pdf(input_path: str | PathLike[str]) -> None: """Reject inputs whose geometry would make the established layout drift.""" path = Path(input_path) try: with path.open("rb") as source: header = source.read(1024) except OSError as exc: raise RosterFormatError("The uploaded PDF could not be read.") from exc if b"%PDF-" not in header: raise RosterFormatError("The uploaded file is not a PDF.") try: doc = fitz.open(path) try: if doc.needs_pass: raise RosterFormatError("Password-protected PDFs are not supported.") if doc.page_count != 1: raise RosterFormatError("Upload a one-page roster PDF.") page = doc.load_page(0) if page.rotation != 0: raise RosterFormatError("The roster PDF must not have page rotation metadata.") if ( abs(page.rect.width - PAGE_WIDTH) > 0.5 or abs(page.rect.height - PAGE_HEIGHT) > 0.5 ): raise RosterFormatError( "The roster must be an 11 × 8.5 inch landscape PDF (792 × 612 points)." ) finally: doc.close() except RosterFormatError: raise except Exception as exc: raise RosterFormatError("The uploaded PDF is damaged or unsupported.") from exc def convert_pdf_to_png( pdf_path: str | PathLike[str], output_path: str | PathLike[str], dpi: int = PNG_DPI, ) -> None: """Rasterize page one exactly as the original formatter did.""" doc = fitz.open(pdf_path) try: page = doc.load_page(0) pix = page.get_pixmap(dpi=dpi) pix.save(output_path) finally: doc.close() def format_roster( input_pdf: str | PathLike[str], output_png: str | PathLike[str], period: RosterPeriod | str | None = None, settings: LayoutSettings = DEFAULT_LAYOUT, ) -> FormatResult: """Format one roster using isolated intermediate files and an atomic output.""" selected_period = period if isinstance(period, RosterPeriod) else RosterPeriod.parse(period) input_path = Path(input_pdf) output_path = Path(output_png) validate_input_pdf(input_path) output_path.parent.mkdir(parents=True, exist_ok=True) with TemporaryDirectory(prefix="roster-format-") as temp_dir: work_dir = Path(temp_dir) shifted_pdf = work_dir / "shifted.pdf" logo_pdf = work_dir / "with_logo.pdf" final_pdf = work_dir / "final.pdf" staged_png = work_dir / "formatted.png" try: adjust_layout( input_path, shifted_pdf, shift_amount=settings.content_shift, bottom_margin=settings.bottom_margin, ) add_logo( shifted_pdf, logo_pdf, LOGO_PATH, position=(settings.logo_x, settings.logo_y), scale=settings.logo_scale, ) add_date( input_path=logo_pdf, output_path=final_pdf, month_year=selected_period.label, font_path=FONT_PATH, position=(settings.date_x, settings.date_y), font_size=settings.date_font_size, ) convert_pdf_to_png(final_pdf, staged_png) # Only the completed PNG crosses into the destination filesystem. # This keeps working PDFs in /tmp while preserving an atomic replace # even when /tmp and output_path are different mounts. destination_temp: Path | None = None try: with staged_png.open("rb") as source, NamedTemporaryFile( mode="wb", prefix=".roster-output-", suffix=".png", dir=output_path.parent, delete=False, ) as destination: destination_temp = Path(destination.name) copyfileobj(source, destination) destination.flush() fsync(destination.fileno()) replace(destination_temp, output_path) destination_temp = None finally: if destination_temp is not None: destination_temp.unlink(missing_ok=True) except RosterFormatError: raise except Exception as exc: raise RosterFormatError( "The roster could not be formatted. Check that the PDF is valid and try again." ) from exc return FormatResult(path=output_path, period=selected_period) def format_combined_roster( first_pdf: str | PathLike[str], second_pdf: str | PathLike[str], output_png: str | PathLike[str], period: RosterPeriod | str | None = None, settings: LayoutSettings = DEFAULT_LAYOUT, ) -> FormatResult: """Classify and compose one main roster plus one nursery-only roster.""" selected_period = period if isinstance(period, RosterPeriod) else RosterPeriod.parse(period) input_paths = (Path(first_pdf), Path(second_pdf)) output_path = Path(output_png) for index, input_path in enumerate(input_paths, start=1): try: validate_input_pdf(input_path) except RosterFormatError as exc: raise RosterFormatError(f"PDF {index}: {exc}") from exc try: assigned = assign_roster_pdfs(*input_paths) except RosterClassificationError as exc: raise RosterFormatError(str(exc)) from exc output_path.parent.mkdir(parents=True, exist_ok=True) with TemporaryDirectory(prefix="roster-combined-") as temp_dir: work_dir = Path(temp_dir) composed_pdf = work_dir / "combined.pdf" staged_png = work_dir / "formatted.png" try: compose_combined_pdf( assigned.main, assigned.nursery, composed_pdf, page_width=PAGE_WIDTH, page_height=PAGE_HEIGHT, main_placement=SectionPlacement( x=settings.main_x, y=settings.main_y, scale=settings.main_scale, ), nursery_placement=SectionPlacement( x=settings.nursery_x, y=settings.nursery_y, scale=settings.nursery_scale, ), logo_path=LOGO_PATH, logo_position=(settings.logo_x, settings.logo_y), logo_scale=settings.logo_scale * COMBINED_LOGO_SCALE_FACTOR, month_label=selected_period.label, font_path=FONT_PATH, date_position=(settings.date_x, settings.date_y), date_font_size=( settings.date_font_size * COMBINED_DATE_FONT_SCALE_FACTOR ), ) convert_pdf_to_png(composed_pdf, staged_png) destination_temp: Path | None = None try: with staged_png.open("rb") as source, NamedTemporaryFile( mode="wb", prefix=".roster-output-", suffix=".png", dir=output_path.parent, delete=False, ) as destination: destination_temp = Path(destination.name) copyfileobj(source, destination) destination.flush() fsync(destination.fileno()) replace(destination_temp, output_path) destination_temp = None finally: if destination_temp is not None: destination_temp.unlink(missing_ok=True) except RosterFormatError: raise except Exception as exc: raise RosterFormatError( "The combined roster could not be composed. Check both PDFs and try again." ) from exc return FormatResult(path=output_path, period=selected_period)