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
+2 -1
View File
@@ -16,7 +16,8 @@ RUN groupadd --gid "${APP_GID}" roster \
&& mkdir -p /app/output \ && mkdir -p /app/output \
&& chown -R roster:roster /app && chown -R roster:roster /app
COPY --chown=roster:roster main.py roster_formatter.py server.py ./ COPY --chown=roster:roster main.py roster_formatter.py server.py \
roster_classifier.py combined_roster.py ./
COPY --chown=roster:roster modules/ ./modules/ COPY --chown=roster:roster modules/ ./modules/
COPY --chown=roster:roster utils/ ./utils/ COPY --chown=roster:roster utils/ ./utils/
COPY --chown=roster:roster assets/ ./assets/ COPY --chown=roster:roster assets/ ./assets/
+33 -17
View File
@@ -1,6 +1,6 @@
# Roster Formatter # Roster Formatter
A local browser app that turns the current one-page roster PDF into the established formatted PNG. The web app and command-line tool use the same renderer. A local browser app that turns one or two Subsplash roster PDFs into one formatted PNG. Upload the full roster by itself, or upload it together with an optional nursery-only roster in the same drop zone. The web app and command-line tool share the production renderer.
## Start with Docker Compose ## Start with Docker Compose
@@ -10,7 +10,7 @@ From this folder:
docker compose up -d --build docker compose up -d --build
``` ```
Open <http://127.0.0.1:8787>, upload the roster PDF, choose its month, and select **Format roster**. Open <http://127.0.0.1:8787>, choose or drop one or two roster PDFs together, confirm the month, and select **Format roster**.
Common commands: Common commands:
@@ -39,12 +39,25 @@ roster.example.com {
The `tabernet` network must already exist. Because roster images contain personal information, add authentication in Caddy before exposing this service outside your private network. The `tabernet` network must already exist. Because roster images contain personal information, add authentication in Caddy before exposing this service outside your private network.
## Upload modes
- **One PDF:** the formatter uses the byte-compatible established single-roster path. It treats the upload as the main roster and does not require classification.
- **Two PDFs:** upload one full/main roster and one nursery-only roster in either order. The formatter normalizes their extracted text and identifies them from ministry-role headings. Several broader roles such as Media, Greeters, Ushers, Prayer, or River Kid indicate the main roster; Nursery/Nursery Workers without broader roles indicates the nursery-only roster.
Classification never uses filenames, dates, or weekdays. A Wednesday/Thursday conference export with the normal ministry roles is therefore still a main roster. If the pair does not contain exactly one confidently identified main roster and one nursery-only roster, the app reports the ambiguity instead of guessing.
Both modes retain the existing per-file PDF validation, host allowlist, same-origin POST checks, and browser security headers.
In combined mode, the main roster is placed above a compact nursery section. Their useful table bounds are clipped and composed from the original vector PDF content, then the finished page is rasterized once. The logo and month label are reduced only in combined mode to leave more table space; a one-PDF result keeps its established header and rendering geometry.
## Saved files and automatic cleanup ## Saved files and automatic cleanup
Every successful PNG is returned to the browser with a **Download PNG** button and saved in [`output/`](output). The Compose container mounts that folder, so saved PNGs survive rebuilds and restarts. Every successful PNG is returned to the browser with a **Download PNG** button and saved in [`output/`](output). The Compose container mounts that folder, so saved PNGs survive rebuilds and restarts.
Layout defaults are persistent too. Change values under **Advanced adjustments**, then select **Save as defaults** to prefill them for future rosters. **Reset changes** restores the currently saved values, while **Use original values** loads the production geometry without saving it. The server stores these defaults in `output/.roster-layout-defaults.json`, inside the same mounted volume, and the roster cleanup never removes that settings file. Layout defaults are persistent too. Change values under **Advanced adjustments**, then select **Save as defaults** to prefill them for future rosters. **Reset changes** restores the currently saved values, while **Use original values** loads the production geometry without saving it. The server stores these defaults in `output/.roster-layout-defaults.json`, inside the same mounted volume, and the roster cleanup never removes that settings file.
Existing version-1 defaults files remain supported. Their saved single-roster, logo, and date values are retained, while the new main/nursery fields receive their factory defaults. Saving defaults again writes version 2. You can also adjust an unusual month and format it without saving, leaving the global defaults unchanged.
On startup, every six hours, and after each successful format, the app removes only filenames matching its roster pattern, such as `MARCH 2026 Roster.png`, when either its roster month or saved-file age is outside the three-calendar-month window. The file-age check prevents an accidentally future-dated filename from bypassing cleanup. Other files are not removed. Cleanup is permanent. On startup, every six hours, and after each successful format, the app removes only filenames matching its roster pattern, such as `MARCH 2026 Roster.png`, when either its roster month or saved-file age is outside the three-calendar-month window. The file-age check prevents an accidentally future-dated filename from bypassing cleanup. Other files are not removed. Cleanup is permanent.
Change the retention window or browser port with a `.env` file beside `compose.yaml`: Change the retention window or browser port with a `.env` file beside `compose.yaml`:
@@ -52,10 +65,11 @@ Change the retention window or browser port with a `.env` file beside `compose.y
```dotenv ```dotenv
ROSTER_RETENTION_MONTHS=6 ROSTER_RETENTION_MONTHS=6
ROSTER_PORT=8788 ROSTER_PORT=8788
ROSTER_MAX_UPLOAD_MB=20
TZ=America/Chicago TZ=America/Chicago
``` ```
Set `ROSTER_RETENTION_MONTHS=0` to disable cleanup. `ROSTER_MAX_UPLOAD_MB` is the limit for each PDF; the request limit automatically allows two files plus multipart overhead. Set `ROSTER_RETENTION_MONTHS=0` to disable cleanup.
The image runs as UID/GID `1000:1000`, matching this machine. On a host with a different checkout owner, set `ROSTER_UID` and `ROSTER_GID` in `.env`, then rebuild so the container can write to `output/`: The image runs as UID/GID `1000:1000`, matching this machine. On a host with a different checkout owner, set `ROSTER_UID` and `ROSTER_GID` in `.env`, then rebuild so the container can write to `output/`:
@@ -66,24 +80,24 @@ ROSTER_GID=1001
## Layout guarantees ## Layout guarantees
With the advanced fields untouched, the renderer preserves the original production values: The canvas is always 792 × 612 PDF points (11 × 8.5 inches, landscape), and every PNG is always 1650 × 1275 pixels at 150 DPI. Neither value is editable.
| Setting | Default | Factory layout values are:
| --- | ---: |
| Canvas | 792 × 612 PDF points |
| PNG export | 1650 × 1275 pixels at 150 DPI |
| Roster vertical shift | 100 |
| Bottom trim | 60 |
| Logo position | X 50, Y -10 |
| Logo scale | 0.27 |
| Date position | X 460, Y 88 |
| Date font size | 48 |
The web page exposes the layout values in a collapsed **Advanced adjustments** section. Only an intentional field change—or explicitly saving that change as the new default—alters logo/date scale or placement. The canvas size and PNG DPI are not editable. The original production values remain available through **Use original values**. | Mode/section | Setting | Default |
| --- | --- | ---: |
| One PDF | Vertical shift | 100 |
| One PDF | Bottom trim | 60 |
| Header | Logo position / scale | X 50, Y -10 / 0.27 |
| Header | Month position / font size | X 460, Y 88 / 48 |
| Combined main | Position / scale | X 66, Y 120 / 0.86 |
| Combined nursery | Position / scale | X 71, Y 519 / 0.85 |
The collapsed **Advanced adjustments** section provides independent X, Y, and scale controls for the main and nursery sections, so changing one does not move or resize the other. It also retains the single-PDF shift/trim and shared logo/month controls. Combined mode automatically renders the logo at 76% of its selected scale and the month text at five-sixths of its selected font size; those reductions are not applied in one-PDF mode. **Use original values** restores all factory values shown above.
The page theme defaults to **System** and follows the browser/operating-system color preference. The **System / Light / Dark** control remembers the choice in that browser. Theme changes affect only the webpage and never the generated PNG. The page theme defaults to **System** and follows the browser/operating-system color preference. The **System / Light / Dark** control remembers the choice in that browser. Theme changes affect only the webpage and never the generated PNG.
Input is deliberately limited to a one-page, unrotated, 11 × 8.5 inch landscape PDF. This prevents a differently sized upload from silently changing the established geometry. Every input is independently required to be a non-empty, genuine, one-page, unrotated 11 × 8.5 inch landscape PDF. This prevents either upload from silently changing the established geometry.
## Run without Docker ## Run without Docker
@@ -101,7 +115,7 @@ python3 -m venv .venv
.venv/bin/python server.py .venv/bin/python server.py
``` ```
The original CLI workflow is still available and now accepts an explicit input path: The original single-roster CLI workflow remains available and accepts an explicit input path:
```bash ```bash
venv/bin/python main.py --input input/original.pdf --month "July 2026" venv/bin/python main.py --input input/original.pdf --month "July 2026"
@@ -116,3 +130,5 @@ PYTHONDONTWRITEBYTECODE=1 venv/bin/python -m unittest discover -v
``` ```
Uploads and intermediate PDFs are created in isolated temporary directories and removed immediately after formatting. Only the final PNG is retained in `output/`. Uploads and intermediate PDFs are created in isolated temporary directories and removed immediately after formatting. Only the final PNG is retained in `output/`.
The legacy visual regression uses the real `input/original.pdf`. Combined-layout tests currently use a representative synthetic nursery-only export because the repository does not yet contain a real nursery-only Subsplash fixture; the factory combined positions may benefit from final tuning when the first real export is available.
+180
View File
@@ -0,0 +1,180 @@
"""Vector-PDF composition for a main roster plus a nursery-only strip."""
from __future__ import annotations
from dataclasses import dataclass
from os import PathLike
from pathlib import Path
import fitz # type: ignore
@dataclass(frozen=True)
class SectionPlacement:
x: float
y: float
scale: float
@dataclass(frozen=True)
class PlacedSection:
source: fitz.Rect
destination: fitz.Rect
@dataclass(frozen=True)
class CombinedComposition:
main: PlacedSection
nursery: PlacedSection
def useful_content_bounds(page: fitz.Page, padding: float = 1.5) -> fitz.Rect:
"""Find useful table bounds from vector drawings and extracted content.
Near-full-page rectangles are ignored so a PDF background cannot defeat the
crop. Subsplash table cells and text remain part of the union.
"""
page_rect = page.rect
maximum_background_area = page_rect.get_area() * 0.95
text_rectangles: list[fitz.Rect] = []
for block in page.get_text("blocks"):
if len(block) > 6 and block[6] != 0:
continue
rect = fitz.Rect(block[:4]) & page_rect
if not rect.is_empty and rect.get_area() < maximum_background_area:
text_rectangles.append(rect)
text_bounds: fitz.Rect | None = None
if text_rectangles:
text_bounds = fitz.Rect(text_rectangles[0])
for rectangle in text_rectangles[1:]:
text_bounds.include_rect(rectangle)
# A clipped PDF form can expose drawing metadata for content outside its
# visible clip. Anchor drawings to the actual extracted-text rows so hidden
# table lines cannot expand a nursery strip back to a full page.
visible_rows = None
if text_bounds is not None:
visible_rows = fitz.Rect(
page_rect.x0,
max(page_rect.y0, text_bounds.y0 - 8),
page_rect.x1,
min(page_rect.y1, text_bounds.y1 + 8),
)
drawing_rectangles: list[fitz.Rect] = []
for drawing in page.get_drawings():
rect = fitz.Rect(drawing["rect"])
if rect.width == 0 and rect.height > 0:
rect.x0 -= 0.35
rect.x1 += 0.35
if rect.height == 0 and rect.width > 0:
rect.y0 -= 0.35
rect.y1 += 0.35
rect &= page_rect
if rect.is_empty or rect.get_area() >= maximum_background_area:
continue
if visible_rows is not None and (rect & visible_rows).is_empty:
continue
drawing_rectangles.append(rect)
candidates = text_rectangles + drawing_rectangles
if not candidates:
raise ValueError("No table content could be found in the roster PDF.")
bounds = fitz.Rect(candidates[0])
for candidate in candidates[1:]:
bounds.include_rect(candidate)
if text_bounds is not None:
bounds.y1 = min(bounds.y1, text_bounds.y1 + 8)
return fitz.Rect(
max(page_rect.x0, bounds.x0 - padding),
max(page_rect.y0, bounds.y0 - padding),
min(page_rect.x1, bounds.x1 + padding),
min(page_rect.y1, bounds.y1 + padding),
)
def _place_section(
destination_page: fitz.Page,
source_document: fitz.Document,
placement: SectionPlacement,
) -> PlacedSection:
source = useful_content_bounds(source_document[0])
destination = fitz.Rect(
placement.x,
placement.y,
placement.x + source.width * placement.scale,
placement.y + source.height * placement.scale,
)
destination_page.show_pdf_page(
destination,
source_document,
0,
clip=source,
keep_proportion=False,
overlay=True,
)
return PlacedSection(source=source, destination=destination)
def compose_combined_pdf(
main_pdf: str | PathLike[str],
nursery_pdf: str | PathLike[str],
output_pdf: str | PathLike[str],
*,
page_width: float,
page_height: float,
main_placement: SectionPlacement,
nursery_placement: SectionPlacement,
logo_path: str | PathLike[str],
logo_position: tuple[float, float],
logo_scale: float,
month_label: str,
font_path: str | PathLike[str],
date_position: tuple[float, float],
date_font_size: float,
) -> CombinedComposition:
"""Compose both source tables and the shared header into one vector PDF."""
main_document = fitz.open(main_pdf)
nursery_document = fitz.open(nursery_pdf)
output_document = fitz.open()
try:
page = output_document.new_page(width=page_width, height=page_height)
page.draw_rect(page.rect, color=None, fill=(1, 1, 1), overlay=False)
main_section = _place_section(page, main_document, main_placement)
nursery_section = _place_section(page, nursery_document, nursery_placement)
logo_pixmap = fitz.Pixmap(str(Path(logo_path).resolve()))
logo_width = logo_pixmap.width * logo_scale
logo_height = logo_pixmap.height * logo_scale
logo_pixmap = None
logo_x, logo_y = logo_position
page.insert_image(
fitz.Rect(logo_x, logo_y, logo_x + logo_width, logo_y + logo_height),
filename=str(Path(logo_path).resolve()),
overlay=True,
)
resolved_font = str(Path(font_path).resolve())
page.insert_font(fontname="custom", fontfile=resolved_font)
date_x, date_y = date_position
page.insert_text(
(date_x, date_y),
month_label,
fontname="custom",
fontsize=date_font_size,
color=(0, 0, 0),
overlay=True,
)
output_document.save(output_pdf)
return CombinedComposition(main=main_section, nursery=nursery_section)
finally:
output_document.close()
nursery_document.close()
main_document.close()
+153
View File
@@ -0,0 +1,153 @@
"""Content-based classification for Subsplash roster exports.
Weekdays and filenames are intentionally absent from this module. A roster is
identified only by the ministry-role structure found in its extracted text.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from os import PathLike
from pathlib import Path
import re
import unicodedata
import fitz # type: ignore
class RosterType(Enum):
MAIN = "main"
NURSERY_ONLY = "nursery_only"
UNKNOWN = "unknown"
class RosterClassificationError(ValueError):
"""Raised when a pair cannot be assigned without guessing."""
# Each key is one maintainable role concept. Aliases tolerate small heading
# changes without counting variants as separate evidence.
NURSERY_ROLE_ALIASES = ("NURSERY WORKERS", "NURSERY WORKER", "NURSERY")
BROAD_ROLE_ALIASES: dict[str, tuple[str, ...]] = {
"MEDIA": ("MEDIA",),
"LITTLE WADERS": ("LITTLE WADERS", "LITTLE WADER"),
"RIVER KID": ("RIVER KIDS", "RIVER KID"),
"GREETERS": ("GREETERS", "GREETER"),
"USHERS": ("USHERS", "USHER"),
"OFFERING MESSAGE": ("OFFERING MESSAGE",),
"PRAYER": ("PRAYER",),
"CLEANER": ("CLEANERS", "CLEANER"),
}
MINIMUM_MAIN_ROLE_SIGNALS = 2
@dataclass(frozen=True)
class RosterClassification:
roster_type: RosterType
nursery_found: bool
broader_roles: tuple[str, ...]
normalized_text: str
@property
def diagnostic(self) -> str:
nursery = "nursery heading found" if self.nursery_found else "no nursery heading"
roles = ", ".join(self.broader_roles) if self.broader_roles else "none"
return f"{nursery}; broader role headings: {roles}"
@dataclass(frozen=True)
class AssignedRosters:
main: Path
nursery: Path
main_classification: RosterClassification
nursery_classification: RosterClassification
def normalize_roster_text(text: str) -> str:
"""Normalize case and spacing while preserving extracted line boundaries."""
normalized_lines = []
for raw_line in unicodedata.normalize("NFKC", text).upper().splitlines():
line = re.sub(r"[^A-Z0-9]+", " ", raw_line).strip()
if line:
normalized_lines.append(line)
return "\n".join(normalized_lines)
def classify_roster_text(text: str) -> RosterClassification:
"""Classify extracted text without using dates, weekdays, or length."""
normalized = normalize_roster_text(text)
lines = frozenset(normalized.splitlines())
nursery_found = any(alias in lines for alias in NURSERY_ROLE_ALIASES)
broader_roles = tuple(
role
for role, aliases in BROAD_ROLE_ALIASES.items()
if any(alias in lines for alias in aliases)
)
if len(broader_roles) >= MINIMUM_MAIN_ROLE_SIGNALS:
roster_type = RosterType.MAIN
elif nursery_found and not broader_roles:
roster_type = RosterType.NURSERY_ONLY
else:
roster_type = RosterType.UNKNOWN
return RosterClassification(
roster_type=roster_type,
nursery_found=nursery_found,
broader_roles=broader_roles,
normalized_text=normalized,
)
def classify_roster_pdf(path: str | PathLike[str]) -> RosterClassification:
"""Extract page text and classify a previously validated one-page PDF."""
document = fitz.open(path)
try:
text = "\n".join(page.get_text("text") for page in document)
finally:
document.close()
return classify_roster_text(text)
def assign_roster_pdfs(
first: str | PathLike[str],
second: str | PathLike[str],
) -> AssignedRosters:
"""Assign an unordered PDF pair, refusing every ambiguous combination."""
paths = (Path(first), Path(second))
classifications = tuple(classify_roster_pdf(path) for path in paths)
main_indexes = [
index
for index, result in enumerate(classifications)
if result.roster_type is RosterType.MAIN
]
nursery_indexes = [
index
for index, result in enumerate(classifications)
if result.roster_type is RosterType.NURSERY_ONLY
]
if len(main_indexes) == 1 and len(nursery_indexes) == 1:
main_index = main_indexes[0]
nursery_index = nursery_indexes[0]
return AssignedRosters(
main=paths[main_index],
nursery=paths[nursery_index],
main_classification=classifications[main_index],
nursery_classification=classifications[nursery_index],
)
diagnostics = "; ".join(
f"PDF {index + 1}: {result.roster_type.value} ({result.diagnostic})"
for index, result in enumerate(classifications)
)
raise RosterClassificationError(
"The formatter could not determine one full roster and one nursery-only "
"roster from the two PDFs. The full roster needs several broader ministry-role "
"headings, while the nursery-only roster should contain Nursery or Nursery "
f"Workers without those broader roles. {diagnostics}."
)
+126 -4
View File
@@ -1,8 +1,9 @@
"""Shared PDF-to-PNG roster formatter. """Shared PDF-to-PNG roster formatter.
The defaults in :class:`LayoutSettings` are the original production geometry. The legacy members of :class:`LayoutSettings` preserve the original production
Both the CLI and web server call this module so the browser workflow cannot geometry byte-for-byte. Combined-mode members place independently clipped
silently introduce a second rendering path. 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 __future__ import annotations
@@ -20,9 +21,11 @@ from typing import Mapping
import fitz # type: ignore import fitz # type: ignore
from combined_roster import SectionPlacement, compose_combined_pdf
from modules.add_date import add_date from modules.add_date import add_date
from modules.add_logo import add_logo from modules.add_logo import add_logo
from modules.adjust_layout import adjust_layout from modules.adjust_layout import adjust_layout
from roster_classifier import RosterClassificationError, assign_roster_pdfs
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@@ -35,6 +38,8 @@ PAGE_HEIGHT = 612
PNG_DPI = 150 PNG_DPI = 150
PNG_WIDTH = 1650 PNG_WIDTH = 1650
PNG_HEIGHT = 1275 PNG_HEIGHT = 1275
COMBINED_LOGO_SCALE_FACTOR = 0.76
COMBINED_DATE_FONT_SCALE_FACTOR = 5 / 6
_MONTH_ALIASES = { _MONTH_ALIASES = {
alias.lower(): number alias.lower(): number
@@ -148,7 +153,7 @@ class RosterPeriod:
@dataclass(frozen=True) @dataclass(frozen=True)
class LayoutSettings: class LayoutSettings:
"""User-adjustable layout values, with the legacy values as defaults.""" """User-adjustable legacy and combined-mode layout values."""
content_shift: float = 100 content_shift: float = 100
bottom_margin: float = 60 bottom_margin: float = 60
@@ -158,6 +163,12 @@ class LayoutSettings:
date_x: float = 460 date_x: float = 460
date_y: float = 88 date_y: float = 88
date_font_size: int = 48 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: def __post_init__(self) -> None:
limits = { limits = {
@@ -168,6 +179,12 @@ class LayoutSettings:
"logo_scale": (0.01, 2), "logo_scale": (0.01, 2),
"date_x": (-1000, 2000), "date_x": (-1000, 2000),
"date_y": (-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(): for name, (minimum, maximum) in limits.items():
value = getattr(self, name) value = getattr(self, name)
@@ -222,6 +239,12 @@ class LayoutSettings:
date_x=parse_float("date_x"), date_x=parse_float("date_x"),
date_y=parse_float("date_y"), date_y=parse_float("date_y"),
date_font_size=font_size, 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]: def as_form_values(self) -> dict[str, str]:
@@ -232,6 +255,19 @@ class LayoutSettings:
DEFAULT_LAYOUT = 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) @dataclass(frozen=True)
@@ -374,3 +410,89 @@ def format_roster(
) from exc ) from exc
return FormatResult(path=output_path, period=selected_period) 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)
+65 -44
View File
@@ -21,10 +21,13 @@ from urllib.parse import quote, unquote, urlsplit
from roster_formatter import ( from roster_formatter import (
BASE_DIR, BASE_DIR,
CURRENT_LAYOUT_FIELDS,
DEFAULT_LAYOUT, DEFAULT_LAYOUT,
LEGACY_LAYOUT_FIELDS,
LayoutSettings, LayoutSettings,
RosterFormatError, RosterFormatError,
RosterPeriod, RosterPeriod,
format_combined_roster,
format_roster, format_roster,
) )
@@ -37,6 +40,7 @@ DEFAULT_MAX_UPLOAD_MB = 20
RETENTION_CHECK_SECONDS = 6 * 60 * 60 RETENTION_CHECK_SECONDS = 6 * 60 * 60
DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"}) DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json" LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json"
LAYOUT_DEFAULTS_VERSION = 2
MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024 MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024
@@ -110,14 +114,21 @@ class OutputStore:
try: try:
payload = json.loads(raw.decode("utf-8")) payload = json.loads(raw.decode("utf-8"))
if not isinstance(payload, dict) or payload.get("version") != 1: if not isinstance(payload, dict) or set(payload) != {"version", "layout"}:
raise ValueError("unsupported settings format") raise ValueError("unsupported settings format")
layout = payload.get("layout") layout = payload.get("layout")
expected = set(DEFAULT_LAYOUT.as_form_values()) if not isinstance(layout, dict):
if not isinstance(layout, dict) or set(layout) != expected:
raise ValueError("settings fields do not match") raise ValueError("settings fields do not match")
version = payload.get("version")
submitted_fields = frozenset(layout)
if version == 1 and submitted_fields == LEGACY_LAYOUT_FIELDS:
pass
elif version == LAYOUT_DEFAULTS_VERSION and submitted_fields == CURRENT_LAYOUT_FIELDS:
pass
else:
raise ValueError("unsupported settings version or fields")
values = {name: str(value) for name, value in layout.items()} values = {name: str(value) for name, value in layout.items()}
return LayoutSettings.from_mapping(values) return LayoutSettings.from_mapping(values, defaults=DEFAULT_LAYOUT)
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc: except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc:
LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc) LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc)
return DEFAULT_LAYOUT return DEFAULT_LAYOUT
@@ -125,7 +136,10 @@ class OutputStore:
def save_layout_defaults(self, settings: LayoutSettings) -> None: def save_layout_defaults(self, settings: LayoutSettings) -> None:
data = ( data = (
json.dumps( json.dumps(
{"version": 1, "layout": settings.as_json_values()}, {
"version": LAYOUT_DEFAULTS_VERSION,
"layout": settings.as_json_values(),
},
indent=2, indent=2,
sort_keys=True, sort_keys=True,
) )
@@ -280,7 +294,7 @@ class RosterWebApp:
self.logo_path = BASE_DIR / "assets" / "logo.png" self.logo_path = BASE_DIR / "assets" / "logo.png"
self.store = OutputStore(output_dir, retention_months) self.store = OutputStore(output_dir, retention_months)
self.max_upload_bytes = max_upload_bytes self.max_upload_bytes = max_upload_bytes
self.max_request_bytes = max_upload_bytes + 1024 * 1024 self.max_request_bytes = 2 * max_upload_bytes + 1024 * 1024
self.allowed_hosts = DEFAULT_ALLOWED_HOSTS | frozenset( self.allowed_hosts = DEFAULT_ALLOWED_HOSTS | frozenset(
host.lower().rstrip(".") for host in (allowed_hosts or set()) host.lower().rstrip(".") for host in (allowed_hosts or set())
) )
@@ -294,23 +308,11 @@ class RosterWebApp:
"__DEFAULT_MONTH__": RosterPeriod.suggested().html_value, "__DEFAULT_MONTH__": RosterPeriod.suggested().html_value,
"__RETENTION_MONTHS__": str(self.store.retention_months), "__RETENTION_MONTHS__": str(self.store.retention_months),
"__MAX_UPLOAD_MB__": str(self.max_upload_bytes // (1024 * 1024)), "__MAX_UPLOAD_MB__": str(self.max_upload_bytes // (1024 * 1024)),
"__CONTENT_SHIFT__": str(saved_layout.content_shift),
"__BOTTOM_MARGIN__": str(saved_layout.bottom_margin),
"__LOGO_X__": str(saved_layout.logo_x),
"__LOGO_Y__": str(saved_layout.logo_y),
"__LOGO_SCALE__": str(saved_layout.logo_scale),
"__DATE_X__": str(saved_layout.date_x),
"__DATE_Y__": str(saved_layout.date_y),
"__DATE_FONT_SIZE__": str(saved_layout.date_font_size),
"__FACTORY_CONTENT_SHIFT__": str(DEFAULT_LAYOUT.content_shift),
"__FACTORY_BOTTOM_MARGIN__": str(DEFAULT_LAYOUT.bottom_margin),
"__FACTORY_LOGO_X__": str(DEFAULT_LAYOUT.logo_x),
"__FACTORY_LOGO_Y__": str(DEFAULT_LAYOUT.logo_y),
"__FACTORY_LOGO_SCALE__": str(DEFAULT_LAYOUT.logo_scale),
"__FACTORY_DATE_X__": str(DEFAULT_LAYOUT.date_x),
"__FACTORY_DATE_Y__": str(DEFAULT_LAYOUT.date_y),
"__FACTORY_DATE_FONT_SIZE__": str(DEFAULT_LAYOUT.date_font_size),
} }
for name, value in saved_layout.as_form_values().items():
replacements[f"__{name.upper()}__"] = value
for name, value in DEFAULT_LAYOUT.as_form_values().items():
replacements[f"__FACTORY_{name.upper()}__"] = value
rendered = template rendered = template
for placeholder, value in replacements.items(): for placeholder, value in replacements.items():
rendered = rendered.replace(placeholder, value) rendered = rendered.replace(placeholder, value)
@@ -318,21 +320,27 @@ class RosterWebApp:
def format_upload( def format_upload(
self, self,
upload: UploadedFile, uploads: list[UploadedFile],
values: Mapping[str, str], values: Mapping[str, str],
) -> tuple[bytes, str, bool, list[str]]: ) -> tuple[bytes, str, bool, list[str]]:
if not upload.filename: if len(uploads) not in {1, 2}:
raise RosterFormatError("Choose a roster PDF to upload.") raise RosterFormatError("Choose one or two roster PDFs to upload.")
if Path(upload.filename).suffix.lower() != ".pdf": for index, upload in enumerate(uploads, start=1):
raise RosterFormatError("Choose a file whose name ends in .pdf.") prefix = "" if len(uploads) == 1 else f"PDF {index}: "
if not upload.data: if not upload.filename:
raise RosterFormatError("The uploaded PDF is empty.") raise RosterFormatError(f"{prefix}Choose a roster PDF to upload.")
if len(upload.data) > self.max_upload_bytes: if Path(upload.filename).suffix.lower() != ".pdf":
maximum = self.max_upload_bytes // (1024 * 1024) raise RosterFormatError(
raise RequestError( f"{prefix}Choose a file whose name ends in .pdf."
HTTPStatus.REQUEST_ENTITY_TOO_LARGE, )
f"The PDF must be {maximum} MB or smaller.", if not upload.data:
) raise RosterFormatError(f"{prefix}The uploaded PDF is empty.")
if len(upload.data) > self.max_upload_bytes:
maximum = self.max_upload_bytes // (1024 * 1024)
raise RequestError(
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
f"{prefix}Each PDF must be {maximum} MB or smaller.",
)
period = RosterPeriod.parse(values.get("month")) period = RosterPeriod.parse(values.get("month"))
settings = LayoutSettings.from_mapping( settings = LayoutSettings.from_mapping(
@@ -343,10 +351,23 @@ class RosterWebApp:
with self._format_lock: with self._format_lock:
with TemporaryDirectory(prefix="roster-web-") as temp_dir: with TemporaryDirectory(prefix="roster-web-") as temp_dir:
work_dir = Path(temp_dir) work_dir = Path(temp_dir)
input_path = work_dir / "upload.pdf" input_paths = [
work_dir / f"upload-{index}.pdf"
for index in range(1, len(uploads) + 1)
]
output_path = work_dir / period.output_filename output_path = work_dir / period.output_filename
input_path.write_bytes(upload.data) for input_path, upload in zip(input_paths, uploads, strict=True):
format_roster(input_path, output_path, period, settings) input_path.write_bytes(upload.data)
if len(input_paths) == 1:
format_roster(input_paths[0], output_path, period, settings)
else:
format_combined_roster(
input_paths[0],
input_paths[1],
output_path,
period,
settings,
)
png = output_path.read_bytes() png = output_path.read_bytes()
_, removed, saved = self.store.save(period.output_filename, png) _, removed, saved = self.store.save(period.output_filename, png)
@@ -363,7 +384,7 @@ class RosterWebApp:
return settings return settings
def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], UploadedFile]: def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], list[UploadedFile]]:
if "\r" in content_type or "\n" in content_type: if "\r" in content_type or "\n" in content_type:
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.") raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid upload content type.")
@@ -412,12 +433,12 @@ def parse_multipart(content_type: str, body: bytes) -> tuple[dict[str, str], Upl
except (LookupError, UnicodeDecodeError) as exc: except (LookupError, UnicodeDecodeError) as exc:
raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc
if len(uploads) != 1: if len(uploads) not in {1, 2}:
raise RequestError( raise RequestError(
HTTPStatus.BAD_REQUEST, HTTPStatus.BAD_REQUEST,
"Upload exactly one roster PDF.", "Upload one or two roster PDFs.",
) )
return values, uploads[0] return values, uploads
class RosterHTTPServer(ThreadingHTTPServer): class RosterHTTPServer(ThreadingHTTPServer):
@@ -678,8 +699,8 @@ class RosterRequestHandler(BaseHTTPRequestHandler):
if len(body) != content_length: if len(body) != content_length:
raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.") raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.")
values, upload = parse_multipart(content_type, body) values, uploads = parse_multipart(content_type, body)
png, filename, saved, removed = self.app.format_upload(upload, values) png, filename, saved, removed = self.app.format_upload(uploads, values)
if removed: if removed:
LOGGER.info("Retention removed: %s", ", ".join(removed)) LOGGER.info("Retention removed: %s", ", ".join(removed))
self._send_bytes( self._send_bytes(
+167
View File
@@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
from typing import Literal, Sequence
import fitz # type: ignore import fitz # type: ignore
@@ -36,3 +37,169 @@ def make_geometry_pdf(path: Path) -> Path:
doc.save(path) doc.save(path)
doc.close() doc.close()
return path return path
def _make_subsplash_roster_pdf(
path: Path,
roster_type: Literal["main", "nursery_only"],
dates: Sequence[str],
) -> Path:
"""Create a realistic one-page, vector Subsplash-style roster fixture.
The fixture deliberately resembles the structure of ``input/original.pdf``:
dates run across the top, role headings and assignment labels run down the
left, and the cells contain volunteer names. It is not used by the legacy
geometry golden, so changes here cannot mask a single-roster regression.
"""
if not dates:
raise ValueError("at least one date is required")
document = fitz.open()
page = document.new_page(width=792, height=612)
page.draw_rect(page.rect, color=None, fill=(1, 1, 1), width=0)
if roster_type == "main":
table = fitz.Rect(34, 48, 758, 446)
roles = (
("MEDIA", "Sound", "Alex Morgan"),
("NURSERY", "Nursery Workers", "Jamie Rivera"),
("LITTLE WADERS", "Teacher", "Taylor Brooks"),
("RIVER KID", "Teacher", "Jordan Lee"),
("GREETERS", "Greet", "Morgan Reed"),
("USHERS", "Usher", "Casey Miller"),
("OFFERING MESSAGE", "Speaker", "Riley Davis"),
("PRAYER", "Prayer", "Avery Wilson"),
)
else:
# Mirrors the compact real nursery strip: roughly 36 points of date
# header plus about 66 points for the band and worker row.
table = fitz.Rect(14, 188, 778, 290)
roles = (("NURSERY WORKERS", "Nursery Workers", "Jamie Rivera"),)
first_column_width = 154
header_height = 42 if roster_type == "main" else 36
date_width = (table.width - first_column_width) / len(dates)
role_height = (table.height - header_height) / len(roles)
navy = (0.06, 0.18, 0.25)
pale_blue = (0.90, 0.95, 0.97)
grid = (0.36, 0.43, 0.47)
page.draw_rect(table, color=grid, fill=(1, 1, 1), width=0.8)
page.draw_rect(
fitz.Rect(table.x0, table.y0, table.x1, table.y0 + header_height),
color=grid,
fill=navy,
width=0.8,
)
page.insert_text(
(table.x0 + 8, table.y0 + 26),
"SERVING SCHEDULE",
fontname="helv",
fontsize=10,
color=(1, 1, 1),
)
first_date_x = table.x0 + first_column_width
page.draw_line(
fitz.Point(first_date_x, table.y0),
fitz.Point(first_date_x, table.y1),
color=grid,
width=0.8,
)
for index, date_label in enumerate(dates):
x = first_date_x + index * date_width
if index:
page.draw_line(
fitz.Point(x, table.y0),
fitz.Point(x, table.y1),
color=grid,
width=0.6,
)
page.insert_text(
(x + 8, table.y0 + 18),
date_label,
fontname="hebo",
fontsize=9,
color=(1, 1, 1),
)
page.insert_text(
(x + 8, table.y0 + 32),
"7:00pm" if date_label.lower().startswith(("wed", "thu")) else "10:00am",
fontname="helv",
fontsize=8,
color=(1, 1, 1),
)
for role_index, (heading, assignment, volunteer) in enumerate(roles):
y0 = table.y0 + header_height + role_index * role_height
y1 = y0 + role_height
if role_index:
page.draw_line(
fitz.Point(table.x0, y0),
fitz.Point(table.x1, y0),
color=grid,
width=0.6,
)
page.draw_rect(
fitz.Rect(table.x0, y0, first_date_x, y1),
color=None,
fill=pale_blue,
width=0,
)
page.insert_text(
(table.x0 + 7, y0 + 15),
heading,
fontname="hebo",
fontsize=7.5,
color=navy,
)
page.insert_text(
(
table.x0 + 7,
min(y1 - 5, y0 + (31 if roster_type == "main" else 43)),
),
assignment,
fontname="helv",
fontsize=7.5,
color=(0.12, 0.12, 0.12),
)
for date_index in range(len(dates)):
x = first_date_x + date_index * date_width + 7
name = volunteer if date_index % 2 == 0 else f"{volunteer.split()[0]} Kim"
page.insert_text(
(
x,
y0
+ min(
27 if roster_type == "main" else 41,
role_height * 0.72,
),
),
name,
fontname="helv",
fontsize=7.5,
color=(0.08, 0.08, 0.08),
)
document.save(path)
document.close()
return path
def make_main_roster_pdf(
path: Path,
dates: Sequence[str] = ("Sun 6 Sep", "Sun 13 Sep", "Sun 20 Sep", "Sun 27 Sep"),
) -> Path:
"""Create a full roster with several broad ministry-role headings."""
return _make_subsplash_roster_pdf(path, "main", dates)
def make_nursery_only_roster_pdf(
path: Path,
dates: Sequence[str] = ("Wed 2 Sep", "Wed 9 Sep", "Wed 16 Sep", "Wed 23 Sep"),
) -> Path:
"""Create a roster whose only ministry role is Nursery Workers."""
return _make_subsplash_roster_pdf(path, "nursery_only", dates)
+266 -1
View File
@@ -1,12 +1,23 @@
from datetime import date from datetime import date
from dataclasses import replace
from hashlib import sha256 from hashlib import sha256
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import unittest import unittest
from unittest.mock import patch
import fitz # type: ignore import fitz # type: ignore
from PIL import Image from PIL import Image
from combined_roster import compose_combined_pdf
from roster_classifier import (
RosterClassificationError,
RosterType,
assign_roster_pdfs,
classify_roster_pdf,
classify_roster_text,
normalize_roster_text,
)
from roster_formatter import ( from roster_formatter import (
DEFAULT_LAYOUT, DEFAULT_LAYOUT,
PAGE_HEIGHT, PAGE_HEIGHT,
@@ -17,13 +28,20 @@ from roster_formatter import (
LayoutSettings, LayoutSettings,
RosterFormatError, RosterFormatError,
RosterPeriod, RosterPeriod,
format_combined_roster,
format_roster, format_roster,
validate_input_pdf, validate_input_pdf,
) )
from tests.helpers import make_geometry_pdf from tests.helpers import (
make_geometry_pdf,
make_main_roster_pdf,
make_nursery_only_roster_pdf,
)
LEGACY_GEOMETRY_SHA256 = "8b000127605f4fd78f9f39606de955eb02392ca113e8ddd808421d4f04c09c31" LEGACY_GEOMETRY_SHA256 = "8b000127605f4fd78f9f39606de955eb02392ca113e8ddd808421d4f04c09c31"
REAL_SUBSPLASH_INPUT_SHA256 = "a09d8d4479da7f038ba37e08ec51c2959eadfe21f9f3c6ae70aea34784f7e5e0"
REAL_SUBSPLASH_JULY_SHA256 = "89f70043ab92a8683e9f35be54b55f34e33efb59cbdba24dc03eda03b99f146c"
class RosterPeriodTests(unittest.TestCase): class RosterPeriodTests(unittest.TestCase):
@@ -79,6 +97,12 @@ class LayoutRegressionTests(unittest.TestCase):
date_x=460, date_x=460,
date_y=88, date_y=88,
date_font_size=48, date_font_size=48,
main_x=66,
main_y=120,
main_scale=0.86,
nursery_x=71,
nursery_y=519,
nursery_scale=0.85,
), ),
DEFAULT_LAYOUT, DEFAULT_LAYOUT,
) )
@@ -95,6 +119,24 @@ class LayoutRegressionTests(unittest.TestCase):
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size) self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode) self.assertEqual("RGB", image.mode)
def test_real_subsplash_fixture_is_byte_identical(self) -> None:
repository = Path(__file__).resolve().parents[1]
source = repository / "input" / "original.pdf"
self.assertTrue(source.is_file(), "the real Subsplash regression fixture is missing")
self.assertEqual(REAL_SUBSPLASH_INPUT_SHA256, sha256(source.read_bytes()).hexdigest())
with TemporaryDirectory() as temp_dir:
output = Path(temp_dir) / "JULY 2026 Roster.png"
format_roster(source, output, RosterPeriod(2026, 7))
self.assertEqual(
REAL_SUBSPLASH_JULY_SHA256,
sha256(output.read_bytes()).hexdigest(),
)
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode)
def test_explicit_adjustment_changes_pixels_but_not_canvas(self) -> None: def test_explicit_adjustment_changes_pixels_but_not_canvas(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
root = Path(temp_dir) root = Path(temp_dir)
@@ -125,6 +167,229 @@ class LayoutRegressionTests(unittest.TestCase):
with self.assertRaisesRegex(RosterFormatError, "landscape"): with self.assertRaisesRegex(RosterFormatError, "landscape"):
validate_input_pdf(source) validate_input_pdf(source)
def test_combined_section_values_have_independent_numeric_limits(self) -> None:
invalid_values = {
"main_x": "not-a-number",
"main_scale": "0.09",
"nursery_y": "2001",
"nursery_scale": "2.01",
}
for field, value in invalid_values.items():
with self.subTest(field=field):
with self.assertRaises(RosterFormatError):
LayoutSettings.from_mapping({field: value}, defaults=DEFAULT_LAYOUT)
class RosterClassificationTests(unittest.TestCase):
def test_normalized_full_roster_text_classifies_main(self) -> None:
result = classify_roster_text(
" media\nNursery Workers\n little waders \nGreeters\nUSHERS\nPrayer "
)
self.assertEqual(RosterType.MAIN, result.roster_type)
self.assertTrue(result.nursery_found)
self.assertIn("MEDIA", result.broader_roles)
self.assertIn("LITTLE WADERS", result.broader_roles)
def test_normalized_nursery_heading_without_broad_roles_is_nursery_only(self) -> None:
result = classify_roster_text(
"Wed 2 Sep\n nursery---workers \nJamie Rivera\nWed 9 Sep\nJamie Kim"
)
self.assertEqual(RosterType.NURSERY_ONLY, result.roster_type)
self.assertEqual("NURSERY WORKERS", normalize_roster_text(" nursery---workers "))
self.assertEqual((), result.broader_roles)
def test_wednesday_and_thursday_full_roster_is_still_main(self) -> None:
with TemporaryDirectory() as temp_dir:
source = make_main_roster_pdf(
Path(temp_dir) / "conference.pdf",
dates=("Wed 9 Sep", "Thu 10 Sep", "Fri 11 Sep", "Sat 12 Sep"),
)
result = classify_roster_pdf(source)
self.assertEqual(RosterType.MAIN, result.roster_type)
self.assertGreaterEqual(len(result.broader_roles), 2)
def test_weekday_and_filename_never_override_role_structure(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
misleading_main = make_main_roster_pdf(
root / "Wednesday Nursery Only.pdf",
dates=("Wed 2 Sep", "Thu 3 Sep"),
)
misleading_nursery = make_nursery_only_roster_pdf(
root / "Sunday Full Conference Roster.pdf",
dates=("Sun 6 Sep", "Sun 13 Sep"),
)
self.assertEqual(RosterType.MAIN, classify_roster_pdf(misleading_main).roster_type)
self.assertEqual(
RosterType.NURSERY_ONLY,
classify_roster_pdf(misleading_nursery).roster_type,
)
def test_character_count_is_not_used_as_a_classifier(self) -> None:
long_unknown = ("Wednesday service notes and volunteer names " * 100) + "\nMEDIA"
compact_main = "MEDIA\nGREETERS"
self.assertEqual(RosterType.UNKNOWN, classify_roster_text(long_unknown).roster_type)
self.assertEqual(RosterType.MAIN, classify_roster_text(compact_main).roster_type)
def test_unordered_pair_is_assigned_by_content(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
forward = assign_roster_pdfs(main, nursery)
reverse = assign_roster_pdfs(nursery, main)
self.assertEqual(main, forward.main)
self.assertEqual(nursery, forward.nursery)
self.assertEqual(main, reverse.main)
self.assertEqual(nursery, reverse.nursery)
def test_two_main_rosters_are_rejected_with_diagnostics(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_main_roster_pdf(root / "first.pdf")
second = make_main_roster_pdf(root / "second.pdf")
with self.assertRaises(RosterClassificationError) as caught:
assign_roster_pdfs(first, second)
message = str(caught.exception)
self.assertIn("could not determine one full roster and one nursery-only roster", message)
self.assertIn("PDF 1: main", message)
self.assertIn("PDF 2: main", message)
def test_two_nursery_only_rosters_are_rejected_with_diagnostics(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_nursery_only_roster_pdf(root / "first.pdf")
second = make_nursery_only_roster_pdf(root / "second.pdf")
with self.assertRaises(RosterClassificationError) as caught:
assign_roster_pdfs(first, second)
message = str(caught.exception)
self.assertIn("could not determine one full roster and one nursery-only roster", message)
self.assertIn("PDF 1: nursery_only", message)
self.assertIn("PDF 2: nursery_only", message)
class CombinedRosterRegressionTests(unittest.TestCase):
@staticmethod
def _make_pair(root: Path) -> tuple[Path, Path]:
return (
make_main_roster_pdf(root / "main.pdf"),
make_nursery_only_roster_pdf(root / "nursery.pdf"),
)
def test_combined_output_has_fixed_dimensions_and_main_above_nursery(self) -> None:
compositions = []
def recording_compose(*args, **kwargs):
composition = compose_combined_pdf(*args, **kwargs)
compositions.append(composition)
return composition
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
output = root / "combined.png"
with patch("roster_formatter.compose_combined_pdf", side_effect=recording_compose):
format_combined_roster(main, nursery, output, RosterPeriod(2026, 9))
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
self.assertEqual("RGB", image.mode)
self.assertEqual(1, len(compositions))
composition = compositions[0]
self.assertLessEqual(composition.main.destination.y1, composition.nursery.destination.y0)
self.assertGreater(composition.main.source.height, composition.nursery.source.height)
self.assertLessEqual(composition.main.destination.y1, PAGE_HEIGHT)
self.assertLessEqual(composition.nursery.destination.y1, PAGE_HEIGHT)
def test_reverse_upload_order_produces_identical_combined_png(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
forward = root / "forward.png"
reverse = root / "reverse.png"
format_combined_roster(main, nursery, forward, RosterPeriod(2026, 9))
format_combined_roster(nursery, main, reverse, RosterPeriod(2026, 9))
self.assertEqual(forward.read_bytes(), reverse.read_bytes())
def test_main_and_nursery_placement_settings_are_independent(self) -> None:
placements = []
def recording_compose(*args, **kwargs):
placements.append((kwargs["main_placement"], kwargs["nursery_placement"]))
return compose_combined_pdf(*args, **kwargs)
main_adjusted = replace(
DEFAULT_LAYOUT,
main_x=DEFAULT_LAYOUT.main_x + 17,
main_y=DEFAULT_LAYOUT.main_y + 9,
main_scale=DEFAULT_LAYOUT.main_scale - 0.08,
)
nursery_adjusted = replace(
DEFAULT_LAYOUT,
nursery_x=DEFAULT_LAYOUT.nursery_x - 11,
nursery_y=DEFAULT_LAYOUT.nursery_y - 13,
nursery_scale=DEFAULT_LAYOUT.nursery_scale + 0.07,
)
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main, nursery = self._make_pair(root)
with patch("roster_formatter.compose_combined_pdf", side_effect=recording_compose):
format_combined_roster(
main,
nursery,
root / "default.png",
RosterPeriod(2026, 9),
)
format_combined_roster(
main,
nursery,
root / "main-adjusted.png",
RosterPeriod(2026, 9),
main_adjusted,
)
format_combined_roster(
main,
nursery,
root / "nursery-adjusted.png",
RosterPeriod(2026, 9),
nursery_adjusted,
)
default_main, default_nursery = placements[0]
changed_main, unchanged_nursery = placements[1]
unchanged_main, changed_nursery = placements[2]
self.assertNotEqual(default_main, changed_main)
self.assertEqual(default_nursery, unchanged_nursery)
self.assertEqual(default_main, unchanged_main)
self.assertNotEqual(default_nursery, changed_nursery)
def test_ambiguous_pair_does_not_create_an_output(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
first = make_main_roster_pdf(root / "first.pdf")
second = make_main_roster_pdf(root / "second.pdf")
output = root / "should-not-exist.png"
with self.assertRaisesRegex(RosterFormatError, "could not determine"):
format_combined_roster(first, second, output, RosterPeriod(2026, 9))
self.assertFalse(output.exists())
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+393 -13
View File
@@ -9,12 +9,42 @@ import unittest
from urllib.error import HTTPError from urllib.error import HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from roster_formatter import DEFAULT_LAYOUT, LayoutSettings, RosterPeriod, format_roster from PIL import Image
from server import OutputStore, RosterHTTPServer, RosterWebApp
from tests.helpers import make_geometry_pdf from roster_formatter import (
DEFAULT_LAYOUT,
PNG_HEIGHT,
PNG_WIDTH,
LayoutSettings,
RosterFormatError,
RosterPeriod,
format_roster,
)
from server import (
LAYOUT_DEFAULTS_VERSION,
OutputStore,
RequestError,
RosterHTTPServer,
RosterWebApp,
UploadedFile,
parse_multipart,
)
from tests.helpers import (
make_geometry_pdf,
make_main_roster_pdf,
make_nursery_only_roster_pdf,
)
def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> tuple[str, bytes]: def multipart_body(
fields: dict[str, str],
filename: str | None = None,
file_data: bytes | None = None,
*,
uploads: list[tuple[str, bytes]] | None = None,
) -> tuple[str, bytes]:
"""Build a request with zero, one, or repeated ``roster`` file fields."""
boundary = "----RosterRegressionBoundary" boundary = "----RosterRegressionBoundary"
chunks: list[bytes] = [] chunks: list[bytes] = []
for name, value in fields.items(): for name, value in fields.items():
@@ -26,16 +56,24 @@ def multipart_body(fields: dict[str, str], filename: str, file_data: bytes) -> t
b"\r\n", b"\r\n",
] ]
) )
selected_uploads = uploads
if selected_uploads is None:
selected_uploads = [] if filename is None or file_data is None else [(filename, file_data)]
for selected_filename, selected_data in selected_uploads:
chunks.extend(
[
f"--{boundary}\r\n".encode(),
(
'Content-Disposition: form-data; name="roster"; '
f'filename="{selected_filename}"\r\n'
).encode(),
b"Content-Type: application/pdf\r\n\r\n",
selected_data,
b"\r\n",
]
)
chunks.extend( chunks.extend(
[ [
f"--{boundary}\r\n".encode(),
(
'Content-Disposition: form-data; name="roster"; '
f'filename="{filename}"\r\n'
).encode(),
b"Content-Type: application/pdf\r\n\r\n",
file_data,
b"\r\n",
f"--{boundary}--\r\n".encode(), f"--{boundary}--\r\n".encode(),
] ]
) )
@@ -55,6 +93,12 @@ class OutputStoreTests(unittest.TestCase):
date_x=472, date_x=472,
date_y=91, date_y=91,
date_font_size=52, date_font_size=52,
main_x=73,
main_y=128,
main_scale=0.79,
nursery_x=65,
nursery_y=515,
nursery_scale=0.91,
) )
store = OutputStore(root, retention_months=3) store = OutputStore(root, retention_months=3)
@@ -63,7 +107,7 @@ class OutputStoreTests(unittest.TestCase):
defaults_path = root / ".roster-layout-defaults.json" defaults_path = root / ".roster-layout-defaults.json"
self.assertTrue(defaults_path.is_file()) self.assertTrue(defaults_path.is_file())
self.assertEqual( self.assertEqual(
{"version": 1, "layout": settings.as_json_values()}, {"version": LAYOUT_DEFAULTS_VERSION, "layout": settings.as_json_values()},
json.loads(defaults_path.read_text(encoding="utf-8")), json.loads(defaults_path.read_text(encoding="utf-8")),
) )
restarted_store = OutputStore(root, retention_months=3) restarted_store = OutputStore(root, retention_months=3)
@@ -74,6 +118,47 @@ class OutputStoreTests(unittest.TestCase):
self.assertTrue(defaults_path.is_file()) self.assertTrue(defaults_path.is_file())
self.assertEqual(settings, restarted_store.load_layout_defaults()) self.assertEqual(settings, restarted_store.load_layout_defaults())
def test_v1_layout_defaults_load_with_factory_combined_values_and_migrate(self) -> None:
legacy_layout = {
"content_shift": 117,
"bottom_margin": 63,
"logo_x": 54,
"logo_y": -6,
"logo_scale": 0.3,
"date_x": 469,
"date_y": 92,
"date_font_size": 51,
}
expected = LayoutSettings.from_mapping(
{name: str(value) for name, value in legacy_layout.items()},
defaults=DEFAULT_LAYOUT,
)
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
store = OutputStore(root, retention_months=3)
store.layout_defaults_path.write_text(
json.dumps({"version": 1, "layout": legacy_layout}),
encoding="utf-8",
)
loaded = store.load_layout_defaults()
self.assertEqual(expected, loaded)
self.assertEqual(DEFAULT_LAYOUT.main_x, loaded.main_x)
self.assertEqual(DEFAULT_LAYOUT.main_y, loaded.main_y)
self.assertEqual(DEFAULT_LAYOUT.main_scale, loaded.main_scale)
self.assertEqual(DEFAULT_LAYOUT.nursery_x, loaded.nursery_x)
self.assertEqual(DEFAULT_LAYOUT.nursery_y, loaded.nursery_y)
self.assertEqual(DEFAULT_LAYOUT.nursery_scale, loaded.nursery_scale)
store.save_layout_defaults(loaded)
migrated = json.loads(store.layout_defaults_path.read_text(encoding="utf-8"))
self.assertEqual(LAYOUT_DEFAULTS_VERSION, migrated["version"])
self.assertEqual(set(DEFAULT_LAYOUT.as_form_values()), set(migrated["layout"]))
self.assertEqual(expected.as_json_values(), migrated["layout"])
def test_invalid_layout_defaults_fall_back_to_factory_values(self) -> None: def test_invalid_layout_defaults_fall_back_to_factory_values(self) -> None:
with TemporaryDirectory() as temp_dir: with TemporaryDirectory() as temp_dir:
root = Path(temp_dir) root = Path(temp_dir)
@@ -137,6 +222,167 @@ class OutputStoreTests(unittest.TestCase):
self.assertTrue(old.exists()) self.assertTrue(old.exists())
class MultipartParserTests(unittest.TestCase):
def test_one_roster_file_is_accepted(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
"main.pdf",
b"%PDF-one",
)
values, uploads = parse_multipart(content_type, body)
self.assertEqual({"month": "2026-09"}, values)
self.assertEqual(1, len(uploads))
self.assertEqual("main.pdf", uploads[0].filename)
self.assertEqual(b"%PDF-one", uploads[0].data)
def test_two_repeated_roster_files_are_accepted_in_order(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
("nursery.pdf", b"%PDF-nursery"),
("main.pdf", b"%PDF-main"),
],
)
values, uploads = parse_multipart(content_type, body)
self.assertEqual({"month": "2026-09"}, values)
self.assertEqual(["nursery.pdf", "main.pdf"], [upload.filename for upload in uploads])
self.assertEqual(
[b"%PDF-nursery", b"%PDF-main"],
[upload.data for upload in uploads],
)
def test_zero_roster_files_is_rejected(self) -> None:
content_type, body = multipart_body({"month": "2026-09"}, uploads=[])
with self.assertRaises(RequestError) as caught:
parse_multipart(content_type, body)
self.assertEqual("Upload one or two roster PDFs.", caught.exception.message)
def test_three_roster_files_is_rejected(self) -> None:
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
("first.pdf", b"%PDF-first"),
("second.pdf", b"%PDF-second"),
("third.pdf", b"%PDF-third"),
],
)
with self.assertRaises(RequestError) as caught:
parse_multipart(content_type, body)
self.assertEqual("Upload one or two roster PDFs.", caught.exception.message)
class RosterWebAppUnitTests(unittest.TestCase):
@staticmethod
def _uploaded(path: Path) -> UploadedFile:
return UploadedFile(
filename=path.name,
content_type="application/pdf",
data=path.read_bytes(),
)
def test_combined_format_retains_only_the_final_png(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
output_dir = root / "output"
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
app = RosterWebApp(output_dir, retention_months=0)
values = DEFAULT_LAYOUT.as_form_values()
values["month"] = "2026-09"
png, filename, saved, removed = app.format_upload(
[self._uploaded(main), self._uploaded(nursery)],
values,
)
self.assertEqual("SEP 2026 Roster.png", filename)
self.assertTrue(saved)
self.assertEqual([], removed)
self.assertEqual(png, (output_dir / filename).read_bytes())
self.assertEqual([filename], sorted(path.name for path in output_dir.iterdir()))
self.assertFalse(list(output_dir.glob("*.pdf")))
self.assertFalse(list(output_dir.glob("*.tmp")))
with Image.open(output_dir / filename) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
def test_each_pdf_is_independently_validated(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
output_dir = root / "output"
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
valid_main = self._uploaded(main)
valid_nursery = self._uploaded(nursery)
invalid = UploadedFile("damaged.pdf", "application/pdf", b"not a pdf")
app = RosterWebApp(output_dir, retention_months=0)
for uploads, expected_index in (
([invalid, valid_nursery], 1),
([valid_main, invalid], 2),
):
with self.subTest(invalid_pdf=expected_index):
with self.assertRaisesRegex(
RosterFormatError,
rf"PDF {expected_index}: The uploaded file is not a PDF",
):
app.format_upload(uploads, {"month": "2026-09"})
self.assertEqual([], list(output_dir.iterdir()))
def test_filename_validation_is_independent_for_second_pdf(self) -> None:
with TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
main = make_main_roster_pdf(root / "main.pdf")
nursery = make_nursery_only_roster_pdf(root / "nursery.pdf")
app = RosterWebApp(root / "output", retention_months=0)
mislabeled = UploadedFile(
"nursery.txt",
"application/pdf",
nursery.read_bytes(),
)
with self.assertRaisesRegex(
RosterFormatError,
r"PDF 2: Choose a file whose name ends in \.pdf",
):
app.format_upload([self._uploaded(main), mislabeled], {"month": "2026-09"})
def test_per_file_limit_identifies_the_oversized_second_pdf(self) -> None:
with TemporaryDirectory() as temp_dir:
app = RosterWebApp(
Path(temp_dir) / "output",
retention_months=0,
max_upload_bytes=5,
)
first = UploadedFile("first.pdf", "application/pdf", b"%PDF")
second = UploadedFile("second.pdf", "application/pdf", b"123456")
with self.assertRaises(RequestError) as caught:
app.format_upload([first, second], {"month": "2026-09"})
self.assertIn("PDF 2:", caught.exception.message)
self.assertIn("Each PDF must be", caught.exception.message)
def test_request_limit_leaves_room_for_two_individually_valid_files(self) -> None:
with TemporaryDirectory() as temp_dir:
per_file_bytes = 20 * 1024 * 1024
app = RosterWebApp(
Path(temp_dir) / "output",
retention_months=0,
max_upload_bytes=per_file_bytes,
)
self.assertGreater(app.max_request_bytes, 2 * per_file_bytes)
class WebServerTests(unittest.TestCase): class WebServerTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temporary = TemporaryDirectory() self.temporary = TemporaryDirectory()
@@ -169,6 +415,39 @@ class WebServerTests(unittest.TestCase):
self.assertNotIn("__DEFAULT_MONTH__", html) self.assertNotIn("__DEFAULT_MONTH__", html)
self.assertNotIn("__LOGO_SCALE__", html) self.assertNotIn("__LOGO_SCALE__", html)
self.assertIn('data-max-upload-mb="20"', html) self.assertIn('data-max-upload-mb="20"', html)
self.assertEqual(1, html.count('name="roster" type="file"'))
self.assertIn('name="roster" type="file" accept="application/pdf,.pdf" multiple', html)
self.assertIn('id="selected-files"', html)
self.assertIn("one main roster, optional nursery roster", html)
self.assertIn("<legend>Main roster</legend>", html)
self.assertIn("<legend>Nursery-only roster</legend>", html)
for field in (
"main_x",
"main_y",
"main_scale",
"nursery_x",
"nursery_y",
"nursery_scale",
):
self.assertIn(f'name="{field}"', html)
self.assertNotIn('name="main_roster"', html)
self.assertNotIn('name="nursery_roster"', html)
def test_static_script_supports_picker_and_drop_for_two_files(self) -> None:
with urlopen(f"{self.base_url}/static/app.js", timeout=5) as response:
script = response.read().decode()
self.assertIn("let selectedFiles = [];", script)
self.assertIn("if (files.length > 2)", script)
self.assertIn("chooseFiles(fileInput.files)", script)
self.assertIn("chooseFiles(files)", script)
self.assertIn(
'selectedFiles.forEach((file) => formData.append("roster", file, file.name))',
script,
)
self.assertIn("roster types are identified automatically", script)
self.assertIn("input.value = input.dataset.default", script)
self.assertIn("input.value = input.dataset.factory", script)
def test_layout_defaults_endpoint_persists_canonical_values_in_index(self) -> None: def test_layout_defaults_endpoint_persists_canonical_values_in_index(self) -> None:
submitted = DEFAULT_LAYOUT.as_form_values() submitted = DEFAULT_LAYOUT.as_form_values()
@@ -182,6 +461,12 @@ class WebServerTests(unittest.TestCase):
"date_x": "475", "date_x": "475",
"date_y": "92", "date_y": "92",
"date_font_size": "53", "date_font_size": "53",
"main_x": "72",
"main_y": "126",
"main_scale": "0.810",
"nursery_x": "68",
"nursery_y": "514",
"nursery_scale": "0.920",
} }
) )
expected = LayoutSettings.from_mapping(submitted).as_form_values() expected = LayoutSettings.from_mapping(submitted).as_form_values()
@@ -218,6 +503,16 @@ class WebServerTests(unittest.TestCase):
'data-default="53" data-factory="48"', 'data-default="53" data-factory="48"',
html, html,
) )
self.assertIn(
'name="main_x" type="number" value="72.0" '
'data-default="72.0" data-factory="66"',
html,
)
self.assertIn(
'name="nursery_scale" type="number" value="0.92" '
'data-default="0.92" data-factory="0.85"',
html,
)
self.assertNotIn("__FACTORY_", html) self.assertNotIn("__FACTORY_", html)
def test_layout_defaults_endpoint_requires_every_layout_field(self) -> None: def test_layout_defaults_endpoint_requires_every_layout_field(self) -> None:
@@ -305,6 +600,91 @@ class WebServerTests(unittest.TestCase):
self.assertEqual(direct.read_bytes(), web_png) self.assertEqual(direct.read_bytes(), web_png)
self.assertEqual(web_png, (self.output_dir / "JULY 2026 Roster.png").read_bytes()) self.assertEqual(web_png, (self.output_dir / "JULY 2026 Roster.png").read_bytes())
def test_two_pdf_upload_returns_combined_png_and_no_working_files(self) -> None:
main = make_main_roster_pdf(self.root / "main.pdf")
nursery = make_nursery_only_roster_pdf(self.root / "nursery.pdf")
fields = DEFAULT_LAYOUT.as_form_values()
fields["month"] = "2026-09"
content_type, body = multipart_body(
fields,
uploads=[
(nursery.name, nursery.read_bytes()),
(main.name, main.read_bytes()),
],
)
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with urlopen(request, timeout=15) as response:
png = response.read()
self.assertIn("SEP 2026 Roster.png", response.headers["Content-Disposition"])
output = self.output_dir / "SEP 2026 Roster.png"
self.assertEqual(png, output.read_bytes())
self.assertEqual([output.name], sorted(path.name for path in self.output_dir.iterdir()))
self.assertFalse(list(self.output_dir.glob("*.pdf")))
self.assertFalse(list(self.output_dir.glob("*.tmp")))
with Image.open(output) as image:
self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size)
def test_two_main_pdfs_return_clear_ambiguity_error(self) -> None:
first = make_main_roster_pdf(self.root / "first.pdf")
second = make_main_roster_pdf(self.root / "second.pdf")
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
(first.name, first.read_bytes()),
(second.name, second.read_bytes()),
],
)
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with self.assertRaises(HTTPError) as caught:
urlopen(request, timeout=10)
self.assertEqual(422, caught.exception.code)
error = json.loads(caught.exception.read())["error"]
self.assertIn("could not determine one full roster and one nursery-only roster", error)
self.assertIn("PDF 1: main", error)
self.assertIn("PDF 2: main", error)
self.assertEqual([], list(self.output_dir.iterdir()))
def test_request_level_limit_rejects_total_before_individual_file_limits(self) -> None:
main = make_main_roster_pdf(self.root / "main.pdf")
nursery = make_nursery_only_roster_pdf(self.root / "nursery.pdf")
content_type, body = multipart_body(
{"month": "2026-09"},
uploads=[
(main.name, main.read_bytes()),
(nursery.name, nursery.read_bytes()),
],
)
self.assertLess(len(main.read_bytes()), self.app.max_upload_bytes)
self.assertLess(len(nursery.read_bytes()), self.app.max_upload_bytes)
self.app.max_request_bytes = len(body) - 1
request = Request(
f"{self.base_url}/format",
data=body,
method="POST",
headers={"Content-Type": content_type},
)
with self.assertRaises(HTTPError) as caught:
urlopen(request, timeout=5)
self.assertEqual(413, caught.exception.code)
self.assertEqual({"error": "The upload is too large."}, json.loads(caught.exception.read()))
self.assertEqual([], list(self.output_dir.iterdir()))
def test_format_missing_layout_fields_uses_saved_defaults(self) -> None: def test_format_missing_layout_fields_uses_saved_defaults(self) -> None:
settings = LayoutSettings( settings = LayoutSettings(
content_shift=116, content_shift=116,
+86 -29
View File
@@ -4,6 +4,7 @@ const fileInput = document.querySelector("#roster-file");
const dropZone = document.querySelector("#drop-zone"); const dropZone = document.querySelector("#drop-zone");
const dropTitle = document.querySelector("#drop-title"); const dropTitle = document.querySelector("#drop-title");
const dropCopy = document.querySelector("#drop-copy"); const dropCopy = document.querySelector("#drop-copy");
const selectedFilesList = document.querySelector("#selected-files");
const monthInput = document.querySelector("#month"); const monthInput = document.querySelector("#month");
const suggestedMonthButton = document.querySelector("#suggested-month"); const suggestedMonthButton = document.querySelector("#suggested-month");
const resetLayoutButton = document.querySelector("#reset-layout"); const resetLayoutButton = document.querySelector("#reset-layout");
@@ -29,7 +30,7 @@ const defaultMonth = body.dataset.defaultMonth;
const retentionMonths = Number(body.dataset.retentionMonths); const retentionMonths = Number(body.dataset.retentionMonths);
const maximumUploadMb = Number(body.dataset.maxUploadMb); const maximumUploadMb = Number(body.dataset.maxUploadMb);
const maximumUploadBytes = maximumUploadMb * 1024 * 1024; const maximumUploadBytes = maximumUploadMb * 1024 * 1024;
let selectedFile = null; let selectedFiles = [];
let resultUrl = null; let resultUrl = null;
const themeChoices = new Set(["system", "light", "dark"]); const themeChoices = new Set(["system", "light", "dark"]);
@@ -49,19 +50,41 @@ function setThemeChoice(choice, persist = true) {
} }
} }
function setFile(file) { function setFiles(files) {
selectedFile = file || null; selectedFiles = [...files];
if (!selectedFile) { selectedFilesList.replaceChildren();
if (!selectedFiles.length) {
fileInput.value = ""; fileInput.value = "";
dropZone.classList.remove("has-file"); dropZone.classList.remove("has-file");
dropTitle.textContent = "Choose your roster PDF"; dropTitle.textContent = "Choose your roster PDFs";
dropCopy.textContent = `or drag it here · one-page landscape Letter · up to ${maximumUploadMb} MB`; dropCopy.textContent = "or drag them here · one main roster, optional nursery roster";
selectedFilesList.hidden = true;
return; return;
} }
dropZone.classList.add("has-file"); dropZone.classList.add("has-file");
dropTitle.textContent = selectedFile.name; dropTitle.textContent = selectedFiles.length === 1 ? "1 roster PDF selected" : "2 roster PDFs selected";
dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`; dropCopy.textContent = selectedFiles.length === 1
? "Ready to format as the main roster"
: "Ready to format · roster types are identified automatically";
selectedFiles.forEach((file) => {
const item = document.createElement("span");
item.className = "selected-file";
const name = document.createElement("strong");
name.className = "selected-file-name";
name.textContent = file.name;
name.title = file.name;
const size = document.createElement("span");
size.className = "selected-file-size";
size.textContent = formatFileSize(file.size);
item.append(name, size);
selectedFilesList.append(item);
});
selectedFilesList.hidden = false;
} }
function formatFileSize(bytes) { function formatFileSize(bytes) {
@@ -71,6 +94,46 @@ function formatFileSize(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
} }
function fileSelectionError(files) {
if (!files.length) {
return "Choose at least one roster PDF.";
}
if (files.length > 2) {
return "Choose no more than two roster PDFs: one main roster and one optional nursery roster.";
}
for (const file of files) {
const mimeType = (file.type || "").toLowerCase();
const supportedMimeType = !mimeType
|| mimeType === "application/pdf"
|| mimeType === "application/x-pdf"
|| mimeType === "application/octet-stream";
if (!file.name.toLowerCase().endsWith(".pdf") || !supportedMimeType) {
return `“${file.name}” must be a PDF file whose name ends in .pdf.`;
}
if (file.size === 0) {
return `“${file.name}” is empty. Choose a valid roster PDF.`;
}
if (file.size > maximumUploadBytes) {
return `“${file.name}” must be ${maximumUploadMb} MB or smaller.`;
}
}
return null;
}
function chooseFiles(files) {
const candidates = [...files];
const error = fileSelectionError(candidates);
if (error) {
setFiles([]);
showError(error);
return false;
}
setFiles(candidates);
clearError();
return true;
}
function showError(message) { function showError(message) {
formMessage.textContent = message; formMessage.textContent = message;
formMessage.hidden = false; formMessage.hidden = false;
@@ -221,8 +284,12 @@ async function loadHistory() {
} }
fileInput.addEventListener("change", () => { fileInput.addEventListener("change", () => {
setFile(fileInput.files[0] || null); if (!fileInput.files.length) {
clearError(); setFiles([]);
clearError();
return;
}
chooseFiles(fileInput.files);
}); });
["dragenter", "dragover"].forEach((eventName) => { ["dragenter", "dragover"].forEach((eventName) => {
@@ -240,15 +307,12 @@ fileInput.addEventListener("change", () => {
}); });
dropZone.addEventListener("drop", (event) => { dropZone.addEventListener("drop", (event) => {
const file = [...event.dataTransfer.files].find( const files = [...event.dataTransfer.files];
(candidate) => candidate.type === "application/pdf" || candidate.name.toLowerCase().endsWith(".pdf"), if (!files.length) {
); showError("Drop one or two roster PDFs here.");
if (!file) {
showError("Drop a PDF file here.");
return; return;
} }
setFile(file); chooseFiles(files);
clearError();
}); });
window.addEventListener("dragover", (event) => event.preventDefault()); window.addEventListener("dragover", (event) => event.preventDefault());
@@ -291,26 +355,19 @@ form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
clearError(); clearError();
if (!selectedFile) { const selectionError = fileSelectionError(selectedFiles);
showError("Choose a roster PDF first."); if (selectionError) {
showError(selectionError);
fileInput.focus(); fileInput.focus();
return; return;
} }
if (!selectedFile.name.toLowerCase().endsWith(".pdf")) {
showError("The roster filename must end in .pdf.");
return;
}
if (selectedFile.size > maximumUploadBytes) {
showError(`The roster PDF must be ${maximumUploadMb} MB or smaller.`);
return;
}
if (!form.reportValidity()) { if (!form.reportValidity()) {
return; return;
} }
const formData = new FormData(form); const formData = new FormData(form);
formData.delete("roster"); formData.delete("roster");
formData.append("roster", selectedFile, selectedFile.name); selectedFiles.forEach((file) => formData.append("roster", file, file.name));
setBusy(true); setBusy(true);
try { try {
@@ -355,7 +412,7 @@ form.addEventListener("submit", async (event) => {
}); });
formatAnother.addEventListener("click", () => { formatAnother.addEventListener("click", () => {
setFile(null); setFiles([]);
resultCard.hidden = true; resultCard.hidden = true;
clearError(); clearError();
dropZone.scrollIntoView({ behavior: "smooth", block: "center" }); dropZone.scrollIntoView({ behavior: "smooth", block: "center" });
+49 -7
View File
@@ -33,8 +33,8 @@
<p class="eyebrow">Roster formatter</p> <p class="eyebrow">Roster formatter</p>
<h1 id="page-title">From current roster to ready-to-share PNG.</h1> <h1 id="page-title">From current roster to ready-to-share PNG.</h1>
<p class="lede"> <p class="lede">
Upload the one-page PDF, confirm its month, and download the formatted image. Upload your main roster and, when needed, its nursery-only roster. Confirm the month,
The current layout is preserved unless you intentionally change an advanced value. then download one formatted image—the PDFs are identified automatically.
</p> </p>
<div class="format-facts" aria-label="Output details"> <div class="format-facts" aria-label="Output details">
<span>PNG</span> <span>PNG</span>
@@ -55,10 +55,12 @@
<form id="roster-form" action="/format" method="post" enctype="multipart/form-data"> <form id="roster-form" action="/format" method="post" enctype="multipart/form-data">
<label class="drop-zone" id="drop-zone" for="roster-file"> <label class="drop-zone" id="drop-zone" for="roster-file">
<input id="roster-file" name="roster" type="file" accept="application/pdf,.pdf"> <input id="roster-file" name="roster" type="file" accept="application/pdf,.pdf" multiple>
<span class="upload-mark" aria-hidden="true">↑</span> <span class="upload-mark" aria-hidden="true">↑</span>
<span class="drop-title" id="drop-title">Choose your roster PDF</span> <span class="drop-title" id="drop-title">Choose your roster PDFs</span>
<span class="drop-copy" id="drop-copy">or drag it here · one-page landscape Letter · up to __MAX_UPLOAD_MB__ MB</span> <span class="drop-copy" id="drop-copy">or drag them here · one main roster, optional nursery roster</span>
<span class="drop-requirements">One-page landscape Letter PDFs · up to __MAX_UPLOAD_MB__ MB each</span>
<span class="selected-files" id="selected-files" aria-live="polite" hidden></span>
</label> </label>
<div class="field-block"> <div class="field-block">
@@ -80,7 +82,7 @@
</summary> </summary>
<div class="advanced-body"> <div class="advanced-body">
<div class="advanced-heading"> <div class="advanced-heading">
<p>Values use PDF points. Saved defaults prefill every new roster.</p> <p>Positions use PDF points; scales are multipliers. Saved defaults prefill every new roster.</p>
<div class="layout-actions"> <div class="layout-actions">
<button class="text-button" id="reset-layout" type="button">Reset changes</button> <button class="text-button" id="reset-layout" type="button">Reset changes</button>
<button class="text-button" id="factory-layout" type="button">Use original values</button> <button class="text-button" id="factory-layout" type="button">Use original values</button>
@@ -88,7 +90,7 @@
</div> </div>
<fieldset> <fieldset>
<legend>Roster content</legend> <legend>Single-PDF roster</legend>
<div class="setting-grid"> <div class="setting-grid">
<label> <label>
Vertical shift Vertical shift
@@ -101,6 +103,46 @@
</div> </div>
</fieldset> </fieldset>
<p class="combined-settings-note">
The section controls below apply when two PDFs are combined. Each roster can be moved or scaled independently.
</p>
<fieldset>
<legend>Main roster</legend>
<div class="setting-grid setting-grid--three">
<label>
X position
<input name="main_x" type="number" value="__MAIN_X__" data-default="__MAIN_X__" data-factory="__FACTORY_MAIN_X__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Y position
<input name="main_y" type="number" value="__MAIN_Y__" data-default="__MAIN_Y__" data-factory="__FACTORY_MAIN_Y__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Scale
<input name="main_scale" type="number" value="__MAIN_SCALE__" data-default="__MAIN_SCALE__" data-factory="__FACTORY_MAIN_SCALE__" data-layout-input step="0.01" min="0.1" max="2" required>
</label>
</div>
</fieldset>
<fieldset>
<legend>Nursery-only roster</legend>
<div class="setting-grid setting-grid--three">
<label>
X position
<input name="nursery_x" type="number" value="__NURSERY_X__" data-default="__NURSERY_X__" data-factory="__FACTORY_NURSERY_X__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Y position
<input name="nursery_y" type="number" value="__NURSERY_Y__" data-default="__NURSERY_Y__" data-factory="__FACTORY_NURSERY_Y__" data-layout-input step="1" min="-1000" max="2000" required>
</label>
<label>
Scale
<input name="nursery_scale" type="number" value="__NURSERY_SCALE__" data-default="__NURSERY_SCALE__" data-factory="__FACTORY_NURSERY_SCALE__" data-layout-input step="0.01" min="0.1" max="2" required>
</label>
</div>
</fieldset>
<fieldset> <fieldset>
<legend>Logo</legend> <legend>Logo</legend>
<div class="setting-grid setting-grid--three"> <div class="setting-grid setting-grid--three">
+58
View File
@@ -460,6 +460,53 @@ h1 {
line-height: 1.5; line-height: 1.5;
} }
.drop-requirements {
position: relative;
z-index: 1;
margin-top: 3px;
color: var(--ink-soft);
font-size: 0.72rem;
line-height: 1.5;
}
.selected-files {
position: relative;
z-index: 1;
display: grid;
width: min(100%, 440px);
gap: 7px;
margin-top: 17px;
}
.selected-file {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-width: 0;
padding: 9px 11px;
color: var(--ink);
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-solid);
text-align: left;
}
.selected-file-name {
min-width: 0;
overflow: hidden;
font-size: 0.76rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.selected-file-size {
color: var(--ink-soft);
font-size: 0.69rem;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.field-block { .field-block {
margin-top: 30px; margin-top: 30px;
} }
@@ -634,6 +681,17 @@ legend {
font-size: 0.72rem; font-size: 0.72rem;
} }
.combined-settings-note {
margin: 17px 0 2px;
padding: 10px 12px;
color: var(--ink-soft);
border: 1px solid var(--line);
border-radius: 9px;
background: var(--surface-solid);
font-size: 0.72rem;
line-height: 1.5;
}
.canvas-lock { .canvas-lock {
margin: 18px 0 0; margin: 18px 0 0;
padding: 11px 13px; padding: 11px 13px;