"""Shared PDF-to-PNG roster formatter. The defaults in :class:`LayoutSettings` are the original production geometry. Both the CLI and web server call this module so the browser workflow cannot silently introduce a second rendering path. """ 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 modules.add_date import add_date from modules.add_logo import add_logo from modules.adjust_layout import adjust_layout 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 _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 layout values, with the legacy values as defaults.""" 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 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), } 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, ) 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() @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)