From 7651a3ed9d1b4407a86dede6d1542bb735100857 Mon Sep 17 00:00:00 2001 From: Media Date: Sun, 30 Aug 2026 20:04:00 -0500 Subject: [PATCH] Add optional nursery roster composition --- Dockerfile | 3 +- README.md | 50 +++-- combined_roster.py | 180 ++++++++++++++++++ roster_classifier.py | 153 +++++++++++++++ roster_formatter.py | 130 ++++++++++++- server.py | 109 ++++++----- tests/helpers.py | 167 +++++++++++++++++ tests/test_formatter.py | 267 +++++++++++++++++++++++++- tests/test_server.py | 406 ++++++++++++++++++++++++++++++++++++++-- web/app.js | 115 +++++++++--- web/index.html | 56 +++++- web/styles.css | 58 ++++++ 12 files changed, 1578 insertions(+), 116 deletions(-) create mode 100644 combined_roster.py create mode 100644 roster_classifier.py diff --git a/Dockerfile b/Dockerfile index 8c1cee7..e490e6b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,8 @@ RUN groupadd --gid "${APP_GID}" roster \ && mkdir -p /app/output \ && 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 utils/ ./utils/ COPY --chown=roster:roster assets/ ./assets/ diff --git a/README.md b/README.md index d17f288..c39069f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 @@ -10,7 +10,7 @@ From this folder: docker compose up -d --build ``` -Open , upload the roster PDF, choose its month, and select **Format roster**. +Open , choose or drop one or two roster PDFs together, confirm the month, and select **Format roster**. 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. +## 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 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. +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. 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 ROSTER_RETENTION_MONTHS=6 ROSTER_PORT=8788 +ROSTER_MAX_UPLOAD_MB=20 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/`: @@ -66,24 +80,24 @@ ROSTER_GID=1001 ## 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 | -| --- | ---: | -| 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 | +Factory layout values are: -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. -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 @@ -101,7 +115,7 @@ python3 -m venv .venv .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 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/`. + +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. diff --git a/combined_roster.py b/combined_roster.py new file mode 100644 index 0000000..60faa88 --- /dev/null +++ b/combined_roster.py @@ -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() diff --git a/roster_classifier.py b/roster_classifier.py new file mode 100644 index 0000000..969e1b5 --- /dev/null +++ b/roster_classifier.py @@ -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}." + ) diff --git a/roster_formatter.py b/roster_formatter.py index 4c9afbe..207cc00 100644 --- a/roster_formatter.py +++ b/roster_formatter.py @@ -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) diff --git a/server.py b/server.py index 7a6cdf4..ffa54f3 100644 --- a/server.py +++ b/server.py @@ -21,10 +21,13 @@ from urllib.parse import quote, unquote, urlsplit from roster_formatter import ( BASE_DIR, + CURRENT_LAYOUT_FIELDS, DEFAULT_LAYOUT, + LEGACY_LAYOUT_FIELDS, LayoutSettings, RosterFormatError, RosterPeriod, + format_combined_roster, format_roster, ) @@ -37,6 +40,7 @@ DEFAULT_MAX_UPLOAD_MB = 20 RETENTION_CHECK_SECONDS = 6 * 60 * 60 DEFAULT_ALLOWED_HOSTS = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"}) LAYOUT_DEFAULTS_FILENAME = ".roster-layout-defaults.json" +LAYOUT_DEFAULTS_VERSION = 2 MAX_DEFAULTS_REQUEST_BYTES = 16 * 1024 @@ -110,14 +114,21 @@ class OutputStore: try: 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") layout = payload.get("layout") - expected = set(DEFAULT_LAYOUT.as_form_values()) - if not isinstance(layout, dict) or set(layout) != expected: + if not isinstance(layout, dict): 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()} - return LayoutSettings.from_mapping(values) + return LayoutSettings.from_mapping(values, defaults=DEFAULT_LAYOUT) except (UnicodeDecodeError, json.JSONDecodeError, ValueError, TypeError) as exc: LOGGER.warning("Ignoring invalid saved layout defaults: %s", exc) return DEFAULT_LAYOUT @@ -125,7 +136,10 @@ class OutputStore: def save_layout_defaults(self, settings: LayoutSettings) -> None: data = ( json.dumps( - {"version": 1, "layout": settings.as_json_values()}, + { + "version": LAYOUT_DEFAULTS_VERSION, + "layout": settings.as_json_values(), + }, indent=2, sort_keys=True, ) @@ -280,7 +294,7 @@ class RosterWebApp: self.logo_path = BASE_DIR / "assets" / "logo.png" self.store = OutputStore(output_dir, retention_months) 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( host.lower().rstrip(".") for host in (allowed_hosts or set()) ) @@ -294,23 +308,11 @@ class RosterWebApp: "__DEFAULT_MONTH__": RosterPeriod.suggested().html_value, "__RETENTION_MONTHS__": str(self.store.retention_months), "__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 for placeholder, value in replacements.items(): rendered = rendered.replace(placeholder, value) @@ -318,21 +320,27 @@ class RosterWebApp: def format_upload( self, - upload: UploadedFile, + uploads: list[UploadedFile], values: Mapping[str, str], ) -> tuple[bytes, str, bool, list[str]]: - if not upload.filename: - raise RosterFormatError("Choose a roster PDF to upload.") - if Path(upload.filename).suffix.lower() != ".pdf": - raise RosterFormatError("Choose a file whose name ends in .pdf.") - if not upload.data: - raise RosterFormatError("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"The PDF must be {maximum} MB or smaller.", - ) + if len(uploads) not in {1, 2}: + raise RosterFormatError("Choose one or two roster PDFs to upload.") + for index, upload in enumerate(uploads, start=1): + prefix = "" if len(uploads) == 1 else f"PDF {index}: " + if not upload.filename: + raise RosterFormatError(f"{prefix}Choose a roster PDF to upload.") + if Path(upload.filename).suffix.lower() != ".pdf": + raise RosterFormatError( + f"{prefix}Choose a file whose name ends in .pdf." + ) + 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")) settings = LayoutSettings.from_mapping( @@ -343,10 +351,23 @@ class RosterWebApp: with self._format_lock: with TemporaryDirectory(prefix="roster-web-") as 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 - input_path.write_bytes(upload.data) - format_roster(input_path, output_path, period, settings) + for input_path, upload in zip(input_paths, uploads, strict=True): + 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() _, removed, saved = self.store.save(period.output_filename, png) @@ -363,7 +384,7 @@ class RosterWebApp: 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: 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: raise RequestError(HTTPStatus.BAD_REQUEST, "Invalid form text encoding.") from exc - if len(uploads) != 1: + if len(uploads) not in {1, 2}: raise RequestError( HTTPStatus.BAD_REQUEST, - "Upload exactly one roster PDF.", + "Upload one or two roster PDFs.", ) - return values, uploads[0] + return values, uploads class RosterHTTPServer(ThreadingHTTPServer): @@ -678,8 +699,8 @@ class RosterRequestHandler(BaseHTTPRequestHandler): if len(body) != content_length: raise RequestError(HTTPStatus.BAD_REQUEST, "The upload was incomplete.") - values, upload = parse_multipart(content_type, body) - png, filename, saved, removed = self.app.format_upload(upload, values) + values, uploads = parse_multipart(content_type, body) + png, filename, saved, removed = self.app.format_upload(uploads, values) if removed: LOGGER.info("Retention removed: %s", ", ".join(removed)) self._send_bytes( diff --git a/tests/helpers.py b/tests/helpers.py index 078398a..62b149b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Literal, Sequence import fitz # type: ignore @@ -36,3 +37,169 @@ def make_geometry_pdf(path: Path) -> Path: doc.save(path) doc.close() 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) diff --git a/tests/test_formatter.py b/tests/test_formatter.py index 0f92b59..f330614 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,12 +1,23 @@ from datetime import date +from dataclasses import replace from hashlib import sha256 from pathlib import Path from tempfile import TemporaryDirectory import unittest +from unittest.mock import patch import fitz # type: ignore 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 ( DEFAULT_LAYOUT, PAGE_HEIGHT, @@ -17,13 +28,20 @@ from roster_formatter import ( LayoutSettings, RosterFormatError, RosterPeriod, + format_combined_roster, format_roster, 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" +REAL_SUBSPLASH_INPUT_SHA256 = "a09d8d4479da7f038ba37e08ec51c2959eadfe21f9f3c6ae70aea34784f7e5e0" +REAL_SUBSPLASH_JULY_SHA256 = "89f70043ab92a8683e9f35be54b55f34e33efb59cbdba24dc03eda03b99f146c" class RosterPeriodTests(unittest.TestCase): @@ -79,6 +97,12 @@ class LayoutRegressionTests(unittest.TestCase): date_x=460, date_y=88, 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, ) @@ -95,6 +119,24 @@ class LayoutRegressionTests(unittest.TestCase): self.assertEqual((PNG_WIDTH, PNG_HEIGHT), image.size) 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: with TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -125,6 +167,229 @@ class LayoutRegressionTests(unittest.TestCase): with self.assertRaisesRegex(RosterFormatError, "landscape"): 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__": unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index c4deb30..a45260b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,12 +9,42 @@ import unittest from urllib.error import HTTPError from urllib.request import Request, urlopen -from roster_formatter import DEFAULT_LAYOUT, LayoutSettings, RosterPeriod, format_roster -from server import OutputStore, RosterHTTPServer, RosterWebApp -from tests.helpers import make_geometry_pdf +from PIL import Image + +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" chunks: list[bytes] = [] 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", ] ) + 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( [ - 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(), ] ) @@ -55,6 +93,12 @@ class OutputStoreTests(unittest.TestCase): date_x=472, date_y=91, 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) @@ -63,7 +107,7 @@ class OutputStoreTests(unittest.TestCase): defaults_path = root / ".roster-layout-defaults.json" self.assertTrue(defaults_path.is_file()) 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")), ) restarted_store = OutputStore(root, retention_months=3) @@ -74,6 +118,47 @@ class OutputStoreTests(unittest.TestCase): self.assertTrue(defaults_path.is_file()) 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: with TemporaryDirectory() as temp_dir: root = Path(temp_dir) @@ -137,6 +222,167 @@ class OutputStoreTests(unittest.TestCase): 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): def setUp(self) -> None: self.temporary = TemporaryDirectory() @@ -169,6 +415,39 @@ class WebServerTests(unittest.TestCase): self.assertNotIn("__DEFAULT_MONTH__", html) self.assertNotIn("__LOGO_SCALE__", 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("Main roster", html) + self.assertIn("Nursery-only roster", 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: submitted = DEFAULT_LAYOUT.as_form_values() @@ -182,6 +461,12 @@ class WebServerTests(unittest.TestCase): "date_x": "475", "date_y": "92", "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() @@ -218,6 +503,16 @@ class WebServerTests(unittest.TestCase): 'data-default="53" data-factory="48"', 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) 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(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: settings = LayoutSettings( content_shift=116, diff --git a/web/app.js b/web/app.js index 7897fbf..2bd13e5 100644 --- a/web/app.js +++ b/web/app.js @@ -4,6 +4,7 @@ const fileInput = document.querySelector("#roster-file"); const dropZone = document.querySelector("#drop-zone"); const dropTitle = document.querySelector("#drop-title"); const dropCopy = document.querySelector("#drop-copy"); +const selectedFilesList = document.querySelector("#selected-files"); const monthInput = document.querySelector("#month"); const suggestedMonthButton = document.querySelector("#suggested-month"); const resetLayoutButton = document.querySelector("#reset-layout"); @@ -29,7 +30,7 @@ const defaultMonth = body.dataset.defaultMonth; const retentionMonths = Number(body.dataset.retentionMonths); const maximumUploadMb = Number(body.dataset.maxUploadMb); const maximumUploadBytes = maximumUploadMb * 1024 * 1024; -let selectedFile = null; +let selectedFiles = []; let resultUrl = null; const themeChoices = new Set(["system", "light", "dark"]); @@ -49,19 +50,41 @@ function setThemeChoice(choice, persist = true) { } } -function setFile(file) { - selectedFile = file || null; - if (!selectedFile) { +function setFiles(files) { + selectedFiles = [...files]; + selectedFilesList.replaceChildren(); + + if (!selectedFiles.length) { fileInput.value = ""; dropZone.classList.remove("has-file"); - dropTitle.textContent = "Choose your roster PDF"; - dropCopy.textContent = `or drag it here · one-page landscape Letter · up to ${maximumUploadMb} MB`; + dropTitle.textContent = "Choose your roster PDFs"; + dropCopy.textContent = "or drag them here · one main roster, optional nursery roster"; + selectedFilesList.hidden = true; return; } dropZone.classList.add("has-file"); - dropTitle.textContent = selectedFile.name; - dropCopy.textContent = `${formatFileSize(selectedFile.size)} · ready to format`; + dropTitle.textContent = selectedFiles.length === 1 ? "1 roster PDF selected" : "2 roster PDFs selected"; + 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) { @@ -71,6 +94,46 @@ function formatFileSize(bytes) { 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) { formMessage.textContent = message; formMessage.hidden = false; @@ -221,8 +284,12 @@ async function loadHistory() { } fileInput.addEventListener("change", () => { - setFile(fileInput.files[0] || null); - clearError(); + if (!fileInput.files.length) { + setFiles([]); + clearError(); + return; + } + chooseFiles(fileInput.files); }); ["dragenter", "dragover"].forEach((eventName) => { @@ -240,15 +307,12 @@ fileInput.addEventListener("change", () => { }); dropZone.addEventListener("drop", (event) => { - const file = [...event.dataTransfer.files].find( - (candidate) => candidate.type === "application/pdf" || candidate.name.toLowerCase().endsWith(".pdf"), - ); - if (!file) { - showError("Drop a PDF file here."); + const files = [...event.dataTransfer.files]; + if (!files.length) { + showError("Drop one or two roster PDFs here."); return; } - setFile(file); - clearError(); + chooseFiles(files); }); window.addEventListener("dragover", (event) => event.preventDefault()); @@ -291,26 +355,19 @@ form.addEventListener("submit", async (event) => { event.preventDefault(); clearError(); - if (!selectedFile) { - showError("Choose a roster PDF first."); + const selectionError = fileSelectionError(selectedFiles); + if (selectionError) { + showError(selectionError); fileInput.focus(); 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()) { return; } const formData = new FormData(form); formData.delete("roster"); - formData.append("roster", selectedFile, selectedFile.name); + selectedFiles.forEach((file) => formData.append("roster", file, file.name)); setBusy(true); try { @@ -355,7 +412,7 @@ form.addEventListener("submit", async (event) => { }); formatAnother.addEventListener("click", () => { - setFile(null); + setFiles([]); resultCard.hidden = true; clearError(); dropZone.scrollIntoView({ behavior: "smooth", block: "center" }); diff --git a/web/index.html b/web/index.html index 475b417..4584ff2 100644 --- a/web/index.html +++ b/web/index.html @@ -33,8 +33,8 @@

Roster formatter

From current roster to ready-to-share PNG.

- Upload the one-page PDF, confirm its month, and download the formatted image. - The current layout is preserved unless you intentionally change an advanced value. + Upload your main roster and, when needed, its nursery-only roster. Confirm the month, + then download one formatted image—the PDFs are identified automatically.

PNG @@ -55,10 +55,12 @@
@@ -80,7 +82,7 @@
-

Values use PDF points. Saved defaults prefill every new roster.

+

Positions use PDF points; scales are multipliers. Saved defaults prefill every new roster.

@@ -88,7 +90,7 @@
- Roster content + Single-PDF roster
+

+ The section controls below apply when two PDFs are combined. Each roster can be moved or scaled independently. +

+ +
+ Main roster +
+ + + +
+
+ +
+ Nursery-only roster +
+ + + +
+
+
Logo
diff --git a/web/styles.css b/web/styles.css index 61be793..4478939 100644 --- a/web/styles.css +++ b/web/styles.css @@ -460,6 +460,53 @@ h1 { 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 { margin-top: 30px; } @@ -634,6 +681,17 @@ legend { 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 { margin: 18px 0 0; padding: 11px 13px;