Add optional nursery roster composition
This commit is contained in:
+126
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user