Files
Roster/roster_formatter.py

499 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)