Add optional nursery roster composition

This commit is contained in:
Media
2026-08-30 20:04:00 -05:00
parent 3eab672c6c
commit 7651a3ed9d
12 changed files with 1578 additions and 116 deletions
+126 -4
View File
@@ -1,8 +1,9 @@
"""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.
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
@@ -20,9 +21,11 @@ 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
@@ -35,6 +38,8 @@ 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
@@ -148,7 +153,7 @@ class RosterPeriod:
@dataclass(frozen=True)
class LayoutSettings:
"""User-adjustable layout values, with the legacy values as defaults."""
"""User-adjustable legacy and combined-mode layout values."""
content_shift: float = 100
bottom_margin: float = 60
@@ -158,6 +163,12 @@ class LayoutSettings:
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 = {
@@ -168,6 +179,12 @@ class LayoutSettings:
"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)
@@ -222,6 +239,12 @@ class LayoutSettings:
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]:
@@ -232,6 +255,19 @@ class LayoutSettings:
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)
@@ -374,3 +410,89 @@ def format_roster(
) 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)