V2: Added Webserver for faster formatting
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user