377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""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)
|