From 0acd850f4eb87df04a738f139331e88757032455 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 12:33:44 +0000 Subject: [PATCH 1/6] Add Deck for multi-slide and multi-image output Introduce a Deck container that collects Canvas slides and exports them to a multi-page PDF, a multi-slide PPTX, a numbered raster sequence, or a single contact-sheet grid. Slides may have mixed sizes; diagnose() aggregates per-slide findings and warns on size mismatch. Refactor the PDF and PPTX exporters to emit one page/slide per canvas while keeping the single-canvas API unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- README.md | 38 ++++++ docs/api/deck.md | 124 ++++++++++++++++++ docs/api/index.md | 2 + docs/exports.md | 26 ++++ mkdocs.yml | 1 + quickthumb/__init__.py | 3 + quickthumb/_export_pdf.py | 33 ++++- quickthumb/_export_pptx.py | 54 +++++--- quickthumb/deck.py | 260 +++++++++++++++++++++++++++++++++++++ tests/test_deck.py | 259 ++++++++++++++++++++++++++++++++++++ 10 files changed, 776 insertions(+), 24 deletions(-) create mode 100644 docs/api/deck.md create mode 100644 quickthumb/deck.py create mode 100644 tests/test_deck.py diff --git a/README.md b/README.md index 9eaa8c8..659bf63 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,43 @@ pdf_bytes = canvas.to_pdf() # requires quickthumb[pdf] Layers the target format can express (backgrounds, gradients, outlines, shapes, text — including wrapping, rich parts, letter spacing, and effects) are exported natively and stay editable; everything else (raster images, blend modes, custom layers) is embedded as pixel-exact PNG fragments rendered by the regular pipeline. See the [export docs](https://sjquant.github.io/quickthumb/exports/) for the full mapping. +### Decks: multiple images and slides + +A `Deck` is an ordered collection of canvases ("slides"). Each slide is a full +`Canvas`, so it renders exactly as it would on its own. The deck adds +multi-output export on top: + +```python +from quickthumb import Canvas, Deck + +cover = Canvas.from_aspect_ratio("16:9", 1280).background(color="#101820").text( + content="Episode 12", size=120, color="#B8FF00", position=("50%", "50%"), align="center" +) +body = Canvas.from_aspect_ratio("16:9", 1280).background(color="#101820").text( + content="Show notes", size=96, color="#FFFFFF", position=("50%", "50%"), align="center" +) + +deck = Deck().slide(cover).slide(body) # or Deck([cover, body]) / deck.add(a, b, c) + +deck.render("deck.pdf") # one multi-page PDF (a page per slide) +deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) +deck.render("slides.png") # numbered sequence: slides_01.png, slides_02.png, … + +deck.contact_sheet(columns=2).render("grid.png") # all slides in one grid image + +pdf_bytes = deck.to_pdf() # requires quickthumb[pdf] +pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] +``` + +Raster formats have no native multi-page container, so `render()` writes one +file per slide as a zero-padded numbered sequence and returns the written +paths. `.pdf` and `.pptx` produce a single document. Slides may have different +dimensions; `deck.diagnose()` aggregates each slide's +[diagnostics](#diagnostics) (tagged with `slide_index`) and adds a +`mixed-slide-size` warning when they differ, since PPTX uses the first slide's +size for the whole deck. Decks round-trip through JSON with +`deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. + ## JSON-First Workflow quickthumb can round-trip most canvases through JSON: @@ -495,6 +532,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto" | Diagnostics | `canvas.diagnose()` and `quickthumb lint`: off-canvas, tiny text, overflow, low contrast | | Export | PNG, JPEG, WebP, file output, base64, data URLs | | Document renderers | SVG (`to_svg()`), editable PPTX via `quickthumb[pptx]`, PDF via `quickthumb[pdf]` | +| Decks | `Deck` of multiple slides: multi-page PDF, multi-slide PPTX, numbered image sequences, contact-sheet grids, per-slide diagnostics | | Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers | ## Real Example Scripts diff --git a/docs/api/deck.md b/docs/api/deck.md new file mode 100644 index 0000000..a2f9bf0 --- /dev/null +++ b/docs/api/deck.md @@ -0,0 +1,124 @@ +--- +description: Reference for quickthumb Deck — collecting canvases into multi-page PDFs, multi-slide PPTX, numbered image sequences, and contact-sheet grids. +--- + +# Deck + +A `Deck` is an ordered collection of `Canvas` slides. Each slide renders exactly as it would on its own; the deck adds multi-output export on top of the same pipeline. + +## Creation + +### `Deck(slides=None)` + +```python +from quickthumb import Canvas, Deck + +deck = Deck() # empty +deck = Deck([cover, body]) # from a list of canvases +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `slides` | `list[Canvas] \| None` | Initial slides, in order. Each must be a `Canvas`. | + +## Adding slides + +Both builders mutate the deck and return `self` for chaining. + +| Method | Description | +| --- | --- | +| `.slide(canvas)` | Append one `Canvas` as the next slide | +| `.add(*canvases)` | Append several canvases at once | + +```python +deck = Deck().slide(cover).add(body, outro) +``` + +A deck is also a sequence: `len(deck)`, `deck[i]`, and iteration over slides all work, and `deck.slides` returns the underlying list. + +## Export methods + +### `.render(path, format=None, quality=None)` + +Renders the deck, dispatching on the output extension. Returns the list of written file paths. + +```python +deck.render("deck.pdf") # one multi-page PDF (a page per slide) +deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) +deck.render("slides.png") # slides_01.png, slides_02.png, … +deck.render("slides.jpg", quality=85) +``` + +| Extension | Behavior | +| --- | --- | +| `.pdf` | Single multi-page PDF. Requires the `pdf` extra. | +| `.pptx` | Single multi-slide PPTX. Requires the `pptx` extra. | +| `.png` / `.jpg` / `.jpeg` / `.webp` | One file per slide as a zero-padded numbered sequence. | + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `str` | — | Output path; raster names become `_NN` | +| `format` | `str \| None` | `None` | Raster format override (`"PNG"`, `"JPEG"`, `"WEBP"`) | +| `quality` | `int \| None` | `None` | Compression quality. Only valid for raster sequences. | + +!!! warning + Passing `quality` with `.pdf` or `.pptx` output raises `RenderingError`, as does rendering an empty deck. + +### `.to_pdf()` / `.to_pptx()` + +Return the deck as document bytes, one page/slide per canvas. + +```python +with open("deck.pdf", "wb") as f: + f.write(deck.to_pdf()) # requires quickthumb[pdf] +pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] +``` + +### `.contact_sheet(columns=3, thumb_width=480, gap=24, padding=24, background="#FFFFFF")` + +Renders every slide and letterboxes it into a uniform grid cell sized from the first slide's aspect ratio, returning a normal `Canvas` you can render to any raster format. + +```python +deck.contact_sheet(columns=2).render("grid.png") +``` + +| Parameter | Type | Default | Description | +| --- | --- | --- | --- | +| `columns` | `int` | `3` | Grid columns (clamped to the slide count) | +| `thumb_width` | `int` | `480` | Cell width in pixels | +| `gap` | `int` | `24` | Pixels between cells | +| `padding` | `int` | `24` | Outer padding around the grid | +| `background` | `str` | `"#FFFFFF"` | Sheet background color | + +## `.diagnose()` + +Aggregates each slide's [`Canvas.diagnose()`](canvas.md#diagnose) findings and adds deck-wide checks. Returns a list of `DeckDiagnostic` entries: + +```python +for finding in deck.diagnose(): + print(finding.slide_index, finding.code, finding.message) +``` + +| Field | Type | Description | +| --- | --- | --- | +| `code` | `str` | Slide codes (`off-canvas`, `tiny-text`, …) or `mixed-slide-size` | +| `severity` | `str` | `"warning"` or `"error"` | +| `message` | `str` | Human-readable description | +| `slide_index` | `int \| None` | Originating slide, or `None` for deck-wide findings | +| `layer_index` | `int \| None` | Originating layer within the slide, when applicable | + +A `mixed-slide-size` warning is added when slides do not all share the same dimensions, because PPTX export uses the first slide's size for the whole deck. + +## JSON + +### `.to_json()` / `Deck.from_json(json_str)` + +Round-trips the deck through JSON, reusing each canvas's serialization. The shape is `{"slides": [, ...]}`. + +```python +spec = deck.to_json() +restored = Deck.from_json(spec) +``` + +!!! note + As with `Canvas.to_json()`, decks containing `.custom(fn)` slides cannot be serialized unless those callbacks are registered. `from_json()` expects a **JSON string**, not a dict. diff --git a/docs/api/index.md b/docs/api/index.md index f376f76..e7f917f 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,6 +14,7 @@ from quickthumb import ( Background, BlendMode, Canvas, + Deck, Diagnostic, Filter, FitMode, @@ -34,6 +35,7 @@ from quickthumb import ( | Page | What it covers | | --- | --- | | [Canvas](canvas.md) | `Canvas` creation, layer builders, diagnostics, and export methods | +| [Deck](deck.md) | `Deck` — multiple slides to PDF, PPTX, image sequences, and contact sheets | | [Background](background.md) | `.background()` — solid colors, gradients, and images | | [Text](text.md) | `.text()` and `TextPart` — text layers and rich text | | [Image](image.md) | `.image()` — overlay images and cutouts | diff --git a/docs/exports.md b/docs/exports.md index e1c194a..0c3acd7 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -88,6 +88,32 @@ with open("promo.pdf", "wb") as f: !!! note "Fidelity" PDF shadings cannot express transparency, so translucent gradients (and gradients with translucent stops) are embedded as pictures. Blur effects (shadow, glow), strokes on shapes, and gradient/image glyph fills have no faithful PDF vector form and are likewise embedded as pixel-exact PNG fragments. +## Decks (multiple images and slides) + +A `Deck` is an ordered collection of canvases. Each slide is a full `Canvas` and renders exactly as it would on its own, so a deck is just a multi-output container on top of the same pipeline. See the [Deck API reference](api/deck.md) for the full method list. + +```python +from quickthumb import Canvas, Deck + +deck = Deck().slide(cover).slide(body) # or Deck([cover, body]) / deck.add(a, b, c) + +deck.render("deck.pdf") # one multi-page PDF (a page per slide) +deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) +deck.render("slides.png") # numbered sequence: slides_01.png, slides_02.png, … + +deck.contact_sheet(columns=2).render("grid.png") # all slides in one grid image + +pdf_bytes = deck.to_pdf() +pptx_bytes = deck.to_pptx() +``` + +`render()` dispatches on the output extension: `.pdf` and `.pptx` produce a single document, while raster extensions have no native multi-page container, so the deck writes one file per slide as a zero-padded numbered sequence and returns the written paths. + +Slides may have different dimensions. `deck.diagnose()` aggregates each slide's [diagnostics](diagnostics.md) (each tagged with its `slide_index`) and adds a `mixed-slide-size` warning when they differ, because PPTX uses the first slide's size for the whole presentation. Decks round-trip through JSON with `deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. + +!!! note "Contact sheet" + `contact_sheet()` renders every slide and letterboxes it into a uniform grid cell sized from the first slide's aspect ratio, returning a normal `Canvas` you can render to any raster format. + ## CLI The CLI picks the format from the output extension too: diff --git a/mkdocs.yml b/mkdocs.yml index 3bb3a3f..7fa6c29 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - API Reference: - Overview: api/index.md - Canvas: api/canvas.md + - Deck (Multiple Slides): api/deck.md - Background: api/background.md - Text: api/text.md - Image: api/image.md diff --git a/quickthumb/__init__.py b/quickthumb/__init__.py index 0a20250..a2730e8 100644 --- a/quickthumb/__init__.py +++ b/quickthumb/__init__.py @@ -1,4 +1,5 @@ from quickthumb.canvas import Canvas +from quickthumb.deck import Deck, DeckDiagnostic from quickthumb.errors import QuickthumbError, RenderingError, ValidationError from quickthumb.models import ( Align, @@ -29,6 +30,8 @@ __all__ = [ "Canvas", + "Deck", + "DeckDiagnostic", "QuickthumbError", "RenderingError", "ValidationError", diff --git a/quickthumb/_export_pdf.py b/quickthumb/_export_pdf.py index c4f4559..46cb826 100644 --- a/quickthumb/_export_pdf.py +++ b/quickthumb/_export_pdf.py @@ -74,24 +74,44 @@ def export_bytes(self) -> bytes: return buffer.getvalue() def save(self, path_or_stream) -> None: + self.save_canvases([self._canvas], path_or_stream) + + def save_canvases(self, canvases: list[Canvas], path_or_stream) -> None: + """Write one or more canvases to a multi-page PDF (one page per canvas).""" + if not canvases: + raise RenderingError("Cannot export a PDF with no pages.") if hasattr(path_or_stream, "write"): - self._build(path_or_stream) + self._build(canvases, path_or_stream) else: with open(path_or_stream, "wb") as f: - self._build(f) + self._build(canvases, f) + + def export_bytes_canvases(self, canvases: list[Canvas]) -> bytes: + buffer = BytesIO() + self.save_canvases(canvases, buffer) + return buffer.getvalue() - def _build(self, stream) -> None: + def _build(self, canvases: list[Canvas], stream) -> None: from reportlab.pdfgen import canvas as rl_canvas - canvas = self._canvas + first = canvases[0] + pdf = rl_canvas.Canvas(stream, pagesize=(first.width, first.height)) + self._pdf = pdf + for canvas in canvases: + self._draw_page(canvas) + pdf.save() + + def _draw_page(self, canvas: Canvas) -> None: canvas._validate_image_paths() canvas._ctx.begin_render_pass() + self._canvas = canvas - pdf = rl_canvas.Canvas(stream, pagesize=(canvas.width, canvas.height)) + pdf = self._pdf + pdf.setPageSize((canvas.width, canvas.height)) # Flip to a top-left origin so canvas coordinates map straight through. + # showPage() resets the CTM, so each page re-establishes the flip. pdf.translate(0, canvas.height) pdf.scale(1, -1) - self._pdf = pdf prefix, rest = split_backdrop_prefix(flatten_layers(canvas)) if prefix: @@ -102,7 +122,6 @@ def _build(self, stream) -> None: self._emit_layer(layer) pdf.showPage() - pdf.save() # ------------------------------------------------------------------ layers diff --git a/quickthumb/_export_pptx.py b/quickthumb/_export_pptx.py index 0d19561..50e52a3 100644 --- a/quickthumb/_export_pptx.py +++ b/quickthumb/_export_pptx.py @@ -88,30 +88,50 @@ def export_bytes(self) -> bytes: return buffer.getvalue() def save(self, path_or_stream) -> None: - presentation = self._build() + presentation = self._build([self._canvas]) presentation.save(path_or_stream) - def _build(self): + def save_canvases(self, canvases: list[Canvas], path_or_stream) -> None: + """Write one or more canvases to a multi-slide presentation (one slide per canvas).""" + presentation = self._build(canvases) + presentation.save(path_or_stream) + + def export_bytes_canvases(self, canvases: list[Canvas]) -> bytes: + buffer = BytesIO() + self.save_canvases(canvases, buffer) + return buffer.getvalue() + + def _build(self, canvases: list[Canvas]): from pptx import Presentation from pptx.util import Emu - canvas = self._canvas - canvas._validate_image_paths() - canvas._ctx.begin_render_pass() - self._validate_slide_dimensions() + if not canvases: + raise RenderingError("Cannot export a presentation with no slides.") presentation = Presentation() - presentation.slide_width = Emu(_emu(canvas.width)) - presentation.slide_height = Emu(_emu(canvas.height)) - self._slide = presentation.slides.add_slide(presentation.slide_layouts[6]) - - prefix, rest = split_backdrop_prefix(flatten_layers(canvas)) - if prefix: - fragment = rasterize_layers(canvas, prefix) - if fragment: - self._add_fragment(fragment) - for layer in rest: - self._emit_layer(layer) + # The presentation has a single page size; the first slide defines it. + # Validate before sizing so out-of-range canvases raise our error, not + # python-pptx's. + first = canvases[0] + self._canvas = first + self._validate_slide_dimensions() + presentation.slide_width = Emu(_emu(first.width)) + presentation.slide_height = Emu(_emu(first.height)) + + for canvas in canvases: + canvas._validate_image_paths() + canvas._ctx.begin_render_pass() + self._canvas = canvas + self._validate_slide_dimensions() + self._slide = presentation.slides.add_slide(presentation.slide_layouts[6]) + + prefix, rest = split_backdrop_prefix(flatten_layers(canvas)) + if prefix: + fragment = rasterize_layers(canvas, prefix) + if fragment: + self._add_fragment(fragment) + for layer in rest: + self._emit_layer(layer) return presentation diff --git a/quickthumb/deck.py b/quickthumb/deck.py new file mode 100644 index 0000000..6c26843 --- /dev/null +++ b/quickthumb/deck.py @@ -0,0 +1,260 @@ +"""Multi-slide / multi-image decks built on top of Canvas. + +A Deck is an ordered collection of Canvas objects ("slides"). It renders to a +multi-page PDF, a multi-slide PPTX, a numbered sequence of raster images, or a +single contact-sheet grid, reusing the exact same per-canvas render pipeline so +every slide looks identical to rendering that Canvas on its own. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from PIL import Image +from typing_extensions import Self + +from quickthumb._base import FileFormat +from quickthumb.canvas import Canvas +from quickthumb.errors import RenderingError, ValidationError + +_DOCUMENT_EXTENSIONS = {".pdf", ".pptx"} +_RASTER_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"} + + +@dataclass +class DeckDiagnostic: + """A single deck-level finding. + + Slide findings mirror Canvas.diagnose() entries but carry the originating + ``slide_index``; deck-wide findings (such as mixed slide sizes) use a + ``slide_index`` of None and a ``layer_index`` of None. + """ + + code: str + severity: str + message: str + slide_index: int | None = None + layer_index: int | None = None + + +class Deck: + """An ordered collection of Canvas slides with multi-output export.""" + + def __init__(self, slides: list[Canvas] | None = None): + self._slides: list[Canvas] = [] + for slide in slides or []: + self._append_slide(slide) + + @property + def slides(self) -> list[Canvas]: + return self._slides + + def __len__(self) -> int: + return len(self._slides) + + def __iter__(self): + return iter(self._slides) + + def __getitem__(self, index: int) -> Canvas: + return self._slides[index] + + def slide(self, canvas: Canvas) -> Self: + """Append a single Canvas as the next slide (chainable).""" + self._append_slide(canvas) + return self + + def add(self, *canvases: Canvas) -> Self: + """Append several Canvas slides at once (chainable).""" + for canvas in canvases: + self._append_slide(canvas) + return self + + def _append_slide(self, canvas: Canvas) -> None: + if not isinstance(canvas, Canvas): + raise ValidationError("Deck slides must be Canvas instances.") + self._slides.append(canvas) + + def _require_slides(self) -> None: + if not self._slides: + raise RenderingError("Deck has no slides to render.") + + def render( + self, + output_path: str, + format: FileFormat | None = None, + quality: int | None = None, + ) -> list[str]: + """Render the deck, dispatching on the output extension. + + ``.pdf`` and ``.pptx`` produce a single multi-page/multi-slide document. + Raster extensions (``.png``, ``.jpg``, ``.jpeg``, ``.webp``) write one + file per slide as a zero-padded numbered sequence derived from + ``output_path`` (e.g. ``slides.png`` -> ``slides_01.png``, + ``slides_02.png``). Returns the list of written file paths. + """ + self._require_slides() + extension = os.path.splitext(output_path)[1].lower() + + if extension in _DOCUMENT_EXTENSIONS: + if quality is not None: + raise RenderingError( + "Quality parameter is only supported for JPEG and WEBP formats, " + f"not {extension} output." + ) + self._render_document(output_path, extension) + return [output_path] + + if extension in _RASTER_EXTENSIONS or format is not None: + return self._render_sequence(output_path, format, quality) + + raise RenderingError( + f"Unsupported deck output format: {extension or output_path!r}.\n" + "Use .pdf, .pptx, or a raster extension (.png, .jpg, .jpeg, .webp)." + ) + + def _render_document(self, output_path: str, extension: str) -> None: + if extension == ".pdf": + from quickthumb._export_pdf import PdfExporter + + PdfExporter(self._slides[0]).save_canvases(self._slides, output_path) + return + + from quickthumb._export_pptx import PptxExporter + + PptxExporter(self._slides[0]).save_canvases(self._slides, output_path) + + def _render_sequence( + self, output_path: str, format: FileFormat | None, quality: int | None + ) -> list[str]: + stem, extension = os.path.splitext(output_path) + pad = max(2, len(str(len(self._slides)))) + written: list[str] = [] + for index, canvas in enumerate(self._slides, start=1): + slide_path = f"{stem}_{index:0{pad}d}{extension}" + canvas.render(slide_path, format=format, quality=quality) + written.append(slide_path) + return written + + def to_pdf(self) -> bytes: + """Render the deck to a multi-page PDF as bytes (requires quickthumb[pdf]).""" + self._require_slides() + from quickthumb._export_pdf import PdfExporter + + return PdfExporter(self._slides[0]).export_bytes_canvases(self._slides) + + def to_pptx(self) -> bytes: + """Render the deck to a multi-slide PPTX as bytes (requires quickthumb[pptx]).""" + self._require_slides() + from quickthumb._export_pptx import PptxExporter + + return PptxExporter(self._slides[0]).export_bytes_canvases(self._slides) + + def diagnose(self) -> list[DeckDiagnostic]: + """Collect per-slide diagnostics plus deck-wide layout warnings. + + Each slide's Canvas.diagnose() findings are returned tagged with their + ``slide_index``. When slides do not all share the same dimensions a + single ``mixed-slide-size`` warning is prepended, since PPTX uses one + page size for the whole deck and viewers may letterbox the rest. + """ + findings: list[DeckDiagnostic] = [] + + sizes = {(canvas.width, canvas.height) for canvas in self._slides} + if len(sizes) > 1: + findings.append( + DeckDiagnostic( + code="mixed-slide-size", + severity="warning", + message=( + "Slides have differing dimensions " + f"({', '.join(f'{w}x{h}' for w, h in sorted(sizes))}). " + "PPTX export uses the first slide's size for the whole deck." + ), + ) + ) + + for slide_index, canvas in enumerate(self._slides): + for finding in canvas.diagnose(): + findings.append( + DeckDiagnostic( + code=finding.code, + severity=finding.severity, + message=finding.message, + slide_index=slide_index, + layer_index=finding.layer_index, + ) + ) + return findings + + def contact_sheet( + self, + columns: int = 3, + thumb_width: int = 480, + gap: int = 24, + padding: int = 24, + background: str = "#FFFFFF", + ) -> Canvas: + """Compose the slides into a single grid image, returned as a Canvas. + + Each slide is rendered and contained (letterboxed) into a uniform cell + sized from the first slide's aspect ratio. The returned Canvas can be + rendered to any raster format like a normal canvas. + """ + self._require_slides() + if columns < 1: + raise ValidationError("columns must be >= 1") + if thumb_width < 1: + raise ValidationError("thumb_width must be >= 1") + + first = self._slides[0] + cell_w = thumb_width + cell_h = max(1, round(thumb_width * first.height / first.width)) + + cols = min(columns, len(self._slides)) + rows = -(-len(self._slides) // cols) # ceil division + sheet_w = padding * 2 + cols * cell_w + (cols - 1) * gap + sheet_h = padding * 2 + rows * cell_h + (rows - 1) * gap + + thumbnails = [self._slide_thumbnail(canvas, cell_w, cell_h) for canvas in self._slides] + + def draw_grid(base: Image.Image) -> Image.Image: + from PIL import ImageColor + + sheet = Image.new("RGBA", base.size, ImageColor.getrgb(background) + (255,)) + for index, thumb in enumerate(thumbnails): + row, col = divmod(index, cols) + cell_x = padding + col * (cell_w + gap) + cell_y = padding + row * (cell_h + gap) + offset_x = cell_x + (cell_w - thumb.width) // 2 + offset_y = cell_y + (cell_h - thumb.height) // 2 + sheet.alpha_composite(thumb, (offset_x, offset_y)) + return sheet + + return Canvas(sheet_w, sheet_h).custom(draw_grid) + + def _slide_thumbnail(self, canvas: Canvas, cell_w: int, cell_h: int) -> Image.Image: + image = canvas._render_to_image() + scale = min(cell_w / image.width, cell_h / image.height) + size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) + return image.resize(size, Image.LANCZOS) + + def to_json(self) -> str: + import json as _json + + slides = [_json.loads(canvas.to_json()) for canvas in self._slides] + return _json.dumps({"slides": slides}) + + @classmethod + def from_json(cls, data: str) -> Self: + import json as _json + + raw = _json.loads(data) + if not isinstance(raw, dict) or "slides" not in raw: + raise ValidationError("Deck JSON must be an object with a 'slides' list.") + slides_raw = raw["slides"] + if not isinstance(slides_raw, list): + raise ValidationError("Deck 'slides' must be a list of canvas specs.") + + slides = [Canvas.from_json(_json.dumps(slide)) for slide in slides_raw] + return cls(slides=slides) diff --git a/tests/test_deck.py b/tests/test_deck.py new file mode 100644 index 0000000..18b1860 --- /dev/null +++ b/tests/test_deck.py @@ -0,0 +1,259 @@ +"""Tests for Deck: multi-slide / multi-image collections of Canvas objects.""" + +from io import BytesIO +from pathlib import Path + +import pytest +from PIL import Image +from quickthumb import Canvas, Deck +from quickthumb.errors import RenderingError, ValidationError + +from tests._optional import require_pypdfium2 + +pptx = pytest.importorskip("pptx", reason="python-pptx is required for some deck tests") +from pptx import Presentation # noqa: E402 + + +def make_slide(text: str, width: int = 1280, height: int = 720) -> Canvas: + """Build a small, self-contained slide with a solid background and label.""" + return ( + Canvas(width, height) + .background(color="#101820") + .text(content=text, size=96, color="#FFFFFF", position=("50%", "50%"), align="center") + ) + + +class TestDeckComposition: + """Building a deck and inspecting its slides.""" + + def test_should_collect_slides_via_constructor_and_chaining(self): + """slide() and add() append canvases while staying chainable.""" + # given + first = make_slide("1") + second = make_slide("2") + third = make_slide("3") + + # when + deck = Deck([first]).slide(second).add(third) + + # then + assert list(deck) == [first, second, third] + assert len(deck) == 3 + assert deck[1] is second + + def test_should_reject_non_canvas_slides(self): + """Adding something that is not a Canvas raises a validation error.""" + # given + deck = Deck() + + # when / then + with pytest.raises(ValidationError, match="must be Canvas"): + deck.slide("not a canvas") # type: ignore[arg-type] + + def test_should_reject_rendering_an_empty_deck(self, tmp_path: Path): + """An empty deck cannot be rendered to any format.""" + # given + deck = Deck() + + # when / then + with pytest.raises(RenderingError, match="no slides"): + deck.render(str(tmp_path / "out.pdf")) + + +class TestRasterSequence: + """Rendering a deck to per-slide raster images.""" + + def test_should_write_zero_padded_numbered_sequence(self, tmp_path: Path): + """Raster output writes one file per slide with a zero-padded index.""" + # given + deck = Deck().add(make_slide("a"), make_slide("b"), make_slide("c")) + output = tmp_path / "slides.png" + + # when + written = deck.render(str(output)) + + # then + assert written == [ + str(tmp_path / "slides_01.png"), + str(tmp_path / "slides_02.png"), + str(tmp_path / "slides_03.png"), + ] + for path in written: + assert Path(path).exists() + assert Image.open(path).size == (1280, 720) + + def test_should_pass_quality_through_for_jpeg_sequence(self, tmp_path: Path): + """Raster sequences honor the quality argument for JPEG output.""" + # given + deck = Deck().add(make_slide("a"), make_slide("b")) + output = tmp_path / "slides.jpg" + + # when + written = deck.render(str(output), quality=50) + + # then + assert len(written) == 2 + assert all(Path(path).exists() for path in written) + + +class TestDocumentExport: + """Rendering a deck to multi-page PDF and multi-slide PPTX documents.""" + + def test_should_render_one_pdf_page_per_slide(self, tmp_path: Path): + """A PDF deck produces a document with a page for every slide.""" + # given + pdfium = require_pypdfium2() + deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) + output = tmp_path / "deck.pdf" + + # when + deck.render(str(output)) + + # then + document = pdfium.PdfDocument(str(output)) + assert len(document) == 3 + + def test_should_render_one_pptx_slide_per_slide(self, tmp_path: Path): + """A PPTX deck produces a presentation with a slide for every slide.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2")) + output = tmp_path / "deck.pptx" + + # when + deck.render(str(output)) + + # then + presentation = Presentation(str(output)) + assert len(presentation.slides) == 2 + + def test_should_expose_pdf_bytes_with_a_page_per_slide(self): + """to_pdf() returns multi-page PDF bytes.""" + # given + pdfium = require_pypdfium2() + deck = Deck().add(make_slide("1"), make_slide("2")) + + # when + data = deck.to_pdf() + + # then + assert len(pdfium.PdfDocument(BytesIO(data))) == 2 + + def test_should_reject_quality_for_document_output(self, tmp_path: Path): + """Document formats do not accept the raster-only quality argument.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(RenderingError, match="Quality parameter"): + deck.render(str(tmp_path / "deck.pdf"), quality=80) + + def test_should_keep_first_slide_size_for_mixed_pptx(self, tmp_path: Path): + """With mixed sizes the PPTX page size comes from the first slide.""" + # given + deck = Deck().add(make_slide("wide", 1280, 720), make_slide("square", 800, 800)) + output = tmp_path / "deck.pptx" + + # when + deck.render(str(output)) + + # then + presentation = Presentation(str(output)) + assert presentation.slide_width == 1280 * 9525 + assert presentation.slide_height == 720 * 9525 + + +class TestDiagnose: + """Deck-level diagnostics aggregate per-slide findings and deck-wide issues.""" + + def test_should_tag_slide_findings_with_slide_index(self): + """Per-slide diagnostics carry the originating slide index.""" + # given a slide whose text runs off the canvas + offending = Canvas(1280, 720).text( + content="hi", size=64, color="#FFFFFF", position=("200%", "50%") + ) + deck = Deck().add(make_slide("ok"), offending) + + # when + findings = deck.diagnose() + + # then + off_canvas = [f for f in findings if f.code == "off-canvas"] + assert off_canvas + assert all(f.slide_index == 1 for f in off_canvas) + + def test_should_warn_when_slides_have_mixed_sizes(self): + """Differing slide dimensions raise a single deck-wide warning.""" + # given + deck = Deck().add(make_slide("a", 1280, 720), make_slide("b", 800, 800)) + + # when + findings = deck.diagnose() + + # then + mixed = [f for f in findings if f.code == "mixed-slide-size"] + assert len(mixed) == 1 + assert mixed[0].slide_index is None + assert mixed[0].severity == "warning" + + def test_should_not_warn_when_slides_share_a_size(self): + """Uniformly sized slides produce no mixed-size warning.""" + # given + deck = Deck().add(make_slide("a"), make_slide("b")) + + # when + findings = deck.diagnose() + + # then + assert not [f for f in findings if f.code == "mixed-slide-size"] + + +class TestContactSheet: + """Composing slides into a single grid image.""" + + def test_should_lay_out_slides_in_a_grid_canvas(self, tmp_path: Path): + """contact_sheet() returns a renderable Canvas sized for the grid.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) + + # when + sheet = deck.contact_sheet(columns=2, thumb_width=400, gap=20, padding=20) + output = tmp_path / "grid.png" + sheet.render(str(output)) + + # then: 2 columns, 2 rows for 3 slides + cell_h = round(400 * 720 / 1280) + expected_width = 20 * 2 + 2 * 400 + 20 + expected_height = 20 * 2 + 2 * cell_h + 20 + assert (sheet.width, sheet.height) == (expected_width, expected_height) + assert Image.open(output).size == (expected_width, expected_height) + + def test_should_reject_non_positive_columns(self): + """A contact sheet needs at least one column.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(ValidationError, match="columns"): + deck.contact_sheet(columns=0) + + +class TestJsonRoundTrip: + """Decks serialize to and from JSON via the underlying canvas specs.""" + + def test_should_round_trip_through_json(self): + """from_json(to_json()) reproduces every slide's spec.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2")) + + # when + restored = Deck.from_json(deck.to_json()) + + # then + assert len(restored) == 2 + assert [c.to_json() for c in restored] == [c.to_json() for c in deck] + + def test_should_reject_json_without_slides(self): + """Deck JSON must carry a 'slides' list.""" + # when / then + with pytest.raises(ValidationError, match="slides"): + Deck.from_json('{"pages": []}') From b5a91288d11c5fc0b6c210e65b140452b4a2bdc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 12:41:49 +0000 Subject: [PATCH 2/6] Address deck review feedback - render(): drop the format-based dispatch escape hatch, reject a raster format override on document output, and give .svg its own clear error. - slides property returns a copy so the Canvas type guard can't be bypassed. - contact_sheet validates image paths up front for consistent errors. - PPTX exporter no longer validates the first slide twice. - Document that mixed-size decks clip larger later slides in PPTX. - Add tests for render dispatch, to_pptx() bytes, and column clamping. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- README.md | 6 +++-- docs/api/deck.md | 2 +- docs/exports.md | 2 +- quickthumb/_export_pptx.py | 3 ++- quickthumb/deck.py | 20 ++++++++++++-- tests/test_deck.py | 55 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 659bf63..225ee31 100644 --- a/README.md +++ b/README.md @@ -418,8 +418,10 @@ file per slide as a zero-padded numbered sequence and returns the written paths. `.pdf` and `.pptx` produce a single document. Slides may have different dimensions; `deck.diagnose()` aggregates each slide's [diagnostics](#diagnostics) (tagged with `slide_index`) and adds a -`mixed-slide-size` warning when they differ, since PPTX uses the first slide's -size for the whole deck. Decks round-trip through JSON with +`mixed-slide-size` warning when they differ. The PDF path sizes each page to its +slide, but PPTX has a single presentation size taken from the first slide, so +slides larger than the first are clipped by PowerPoint — keep deck slides a +uniform size when targeting `.pptx`. Decks round-trip through JSON with `deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. ## JSON-First Workflow diff --git a/docs/api/deck.md b/docs/api/deck.md index a2f9bf0..ad21e2c 100644 --- a/docs/api/deck.md +++ b/docs/api/deck.md @@ -107,7 +107,7 @@ for finding in deck.diagnose(): | `slide_index` | `int \| None` | Originating slide, or `None` for deck-wide findings | | `layer_index` | `int \| None` | Originating layer within the slide, when applicable | -A `mixed-slide-size` warning is added when slides do not all share the same dimensions, because PPTX export uses the first slide's size for the whole deck. +A `mixed-slide-size` warning is added when slides do not all share the same dimensions. The PDF path sizes each page to its slide, but PPTX export uses the first slide's size for the whole deck, so larger later slides are clipped by PowerPoint — keep slides a uniform size when targeting `.pptx`. ## JSON diff --git a/docs/exports.md b/docs/exports.md index 0c3acd7..d2feb8b 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -109,7 +109,7 @@ pptx_bytes = deck.to_pptx() `render()` dispatches on the output extension: `.pdf` and `.pptx` produce a single document, while raster extensions have no native multi-page container, so the deck writes one file per slide as a zero-padded numbered sequence and returns the written paths. -Slides may have different dimensions. `deck.diagnose()` aggregates each slide's [diagnostics](diagnostics.md) (each tagged with its `slide_index`) and adds a `mixed-slide-size` warning when they differ, because PPTX uses the first slide's size for the whole presentation. Decks round-trip through JSON with `deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. +Slides may have different dimensions. `deck.diagnose()` aggregates each slide's [diagnostics](diagnostics.md) (each tagged with its `slide_index`) and adds a `mixed-slide-size` warning when they differ. The PDF path sizes each page to its slide, but PPTX has a single presentation size taken from the first slide, so slides larger than the first are clipped by PowerPoint — keep slides a uniform size when targeting `.pptx`. Decks round-trip through JSON with `deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. !!! note "Contact sheet" `contact_sheet()` renders every slide and letterboxes it into a uniform grid cell sized from the first slide's aspect ratio, returning a normal `Canvas` you can render to any raster format. diff --git a/quickthumb/_export_pptx.py b/quickthumb/_export_pptx.py index 50e52a3..53a8a52 100644 --- a/quickthumb/_export_pptx.py +++ b/quickthumb/_export_pptx.py @@ -122,7 +122,8 @@ def _build(self, canvases: list[Canvas]): canvas._validate_image_paths() canvas._ctx.begin_render_pass() self._canvas = canvas - self._validate_slide_dimensions() + if canvas is not first: # the first slide was already validated above + self._validate_slide_dimensions() self._slide = presentation.slides.add_slide(presentation.slide_layouts[6]) prefix, rest = split_backdrop_prefix(flatten_layers(canvas)) diff --git a/quickthumb/deck.py b/quickthumb/deck.py index 6c26843..7f3dafe 100644 --- a/quickthumb/deck.py +++ b/quickthumb/deck.py @@ -48,7 +48,9 @@ def __init__(self, slides: list[Canvas] | None = None): @property def slides(self) -> list[Canvas]: - return self._slides + # Return a copy so callers cannot bypass the Canvas type guard in + # _append_slide by mutating the internal list directly. + return list(self._slides) def __len__(self) -> int: return len(self._slides) @@ -102,12 +104,23 @@ def render( "Quality parameter is only supported for JPEG and WEBP formats, " f"not {extension} output." ) + if format is not None: + raise RenderingError( + "format override is only supported for raster output, " + f"not {extension} documents." + ) self._render_document(output_path, extension) return [output_path] - if extension in _RASTER_EXTENSIONS or format is not None: + if extension in _RASTER_EXTENSIONS: return self._render_sequence(output_path, format, quality) + if extension == ".svg": + raise RenderingError( + "A deck cannot render to a single .svg file (SVG has no multi-page form). " + "Render slides individually with Canvas.to_svg(), or use .pdf or .pptx." + ) + raise RenderingError( f"Unsupported deck output format: {extension or output_path!r}.\n" "Use .pdf, .pptx, or a raster extension (.png, .jpg, .jpeg, .webp)." @@ -234,6 +247,9 @@ def draw_grid(base: Image.Image) -> Image.Image: return Canvas(sheet_w, sheet_h).custom(draw_grid) def _slide_thumbnail(self, canvas: Canvas, cell_w: int, cell_h: int) -> Image.Image: + # Validate up front so a missing image fails with a clean FileNotFoundError, + # matching render()/to_pdf()/to_pptx() rather than crashing mid-render. + canvas._validate_image_paths() image = canvas._render_to_image() scale = min(cell_w / image.width, cell_h / image.height) size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) diff --git a/tests/test_deck.py b/tests/test_deck.py index 18b1860..f73bdc4 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -60,6 +60,37 @@ def test_should_reject_rendering_an_empty_deck(self, tmp_path: Path): deck.render(str(tmp_path / "out.pdf")) +class TestRenderDispatch: + """render() dispatches on the output extension and rejects bad combinations.""" + + def test_should_reject_single_svg_output(self, tmp_path: Path): + """A deck has no single-file SVG form and says so explicitly.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(RenderingError, match="single .svg"): + deck.render(str(tmp_path / "deck.svg")) + + def test_should_reject_unknown_extension(self, tmp_path: Path): + """An unrecognized extension is rejected rather than guessed.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(RenderingError, match="Unsupported deck output format"): + deck.render(str(tmp_path / "deck.gif")) + + def test_should_reject_format_override_for_document_output(self, tmp_path: Path): + """A raster format override is meaningless for document output.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(RenderingError, match="format override"): + deck.render(str(tmp_path / "deck.pdf"), format="PNG") + + class TestRasterSequence: """Rendering a deck to per-slide raster images.""" @@ -138,6 +169,17 @@ def test_should_expose_pdf_bytes_with_a_page_per_slide(self): # then assert len(pdfium.PdfDocument(BytesIO(data))) == 2 + def test_should_expose_pptx_bytes_with_a_slide_per_slide(self): + """to_pptx() returns multi-slide presentation bytes.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) + + # when + data = deck.to_pptx() + + # then + assert len(Presentation(BytesIO(data)).slides) == 3 + def test_should_reject_quality_for_document_output(self, tmp_path: Path): """Document formats do not accept the raster-only quality argument.""" # given @@ -227,6 +269,19 @@ def test_should_lay_out_slides_in_a_grid_canvas(self, tmp_path: Path): assert (sheet.width, sheet.height) == (expected_width, expected_height) assert Image.open(output).size == (expected_width, expected_height) + def test_should_clamp_columns_to_slide_count(self): + """Requesting more columns than slides collapses to a single row.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2")) + + # when: 4 columns requested for 2 slides + sheet = deck.contact_sheet(columns=4, thumb_width=400, gap=20, padding=20) + + # then: only 2 cells wide, one row tall + cell_h = round(400 * 720 / 1280) + assert sheet.width == 20 * 2 + 2 * 400 + 20 + assert sheet.height == 20 * 2 + cell_h + def test_should_reject_non_positive_columns(self): """A contact sheet needs at least one column.""" # given From f43b8efc4311050a90246c6246e94b2e445a1f69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 13:26:20 +0000 Subject: [PATCH 3/6] Let deck slides inherit a default size from the deck Add an optional default size to Deck (Deck(width, height) and Deck.from_aspect_ratio) so slides can be written as bare Canvas() that inherit it, instead of repeating dimensions on every slide. An explicitly sized canvas keeps its own size. Canvas now accepts being constructed without a size: it stays "unsized" (accepting layer builders but refusing render/diagnose/serialize with a clear error) until a size is assigned directly or injected when the canvas is added to a sized deck. The Deck constructor takes width/height first; pre-built canvases now pass via Deck(slides=[...]). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- README.md | 26 +++++++++++++--------- docs/api/canvas.md | 8 ++++--- docs/api/deck.md | 27 ++++++++++++++++++----- docs/exports.md | 9 +++++++- quickthumb/canvas.py | 47 +++++++++++++++++++++++++++++++++++---- quickthumb/deck.py | 37 +++++++++++++++++++++++++++++-- tests/test_deck.py | 52 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 180 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 225ee31..baa21bf 100644 --- a/README.md +++ b/README.md @@ -387,21 +387,22 @@ Layers the target format can express (backgrounds, gradients, outlines, shapes, ### Decks: multiple images and slides -A `Deck` is an ordered collection of canvases ("slides"). Each slide is a full -`Canvas`, so it renders exactly as it would on its own. The deck adds -multi-output export on top: +A `Deck` is an ordered collection of canvases ("slides"). Give the deck a size +once and each slide can be written as a bare `Canvas()` that inherits it — no +need to repeat the dimensions per slide: ```python from quickthumb import Canvas, Deck -cover = Canvas.from_aspect_ratio("16:9", 1280).background(color="#101820").text( - content="Episode 12", size=120, color="#B8FF00", position=("50%", "50%"), align="center" +deck = ( + Deck(1280, 720) # default slide size; Deck.from_aspect_ratio("16:9", 1280) also works + .slide(Canvas().background(color="#101820").text( + content="Episode 12", size=120, color="#B8FF00", position=("50%", "50%"), align="center" + )) + .slide(Canvas().background(color="#101820").text( + content="Show notes", size=96, color="#FFFFFF", position=("50%", "50%"), align="center" + )) ) -body = Canvas.from_aspect_ratio("16:9", 1280).background(color="#101820").text( - content="Show notes", size=96, color="#FFFFFF", position=("50%", "50%"), align="center" -) - -deck = Deck().slide(cover).slide(body) # or Deck([cover, body]) / deck.add(a, b, c) deck.render("deck.pdf") # one multi-page PDF (a page per slide) deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) @@ -413,6 +414,11 @@ pdf_bytes = deck.to_pdf() # requires quickthumb[pdf] pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] ``` +An unsized `Canvas()` inherits the deck's size when added; a canvas built with an +explicit size (`Canvas(1080, 1080)`) keeps its own. You can still pass fully +built canvases too — `Deck(slides=[cover, body])`, `deck.add(a, b, c)`. A bare +`Canvas()` cannot be rendered until it is given a size (directly or by a deck). + Raster formats have no native multi-page container, so `render()` writes one file per slide as a zero-padded numbered sequence and returns the written paths. `.pdf` and `.pptx` produce a single document. Slides may have different diff --git a/docs/api/canvas.md b/docs/api/canvas.md index 66bffad..d3242cd 100644 --- a/docs/api/canvas.md +++ b/docs/api/canvas.md @@ -8,7 +8,7 @@ The `Canvas` is the root object. It holds dimensions and an ordered list of laye ## Creation -### `Canvas(width, height)` +### `Canvas(width=None, height=None)` ```python from quickthumb import Canvas @@ -18,8 +18,10 @@ canvas = Canvas(1280, 720) | Parameter | Type | Description | | --- | --- | --- | -| `width` | `int` | Canvas width in pixels. Must be a positive integer. | -| `height` | `int` | Canvas height in pixels. Must be a positive integer. | +| `width` | `int \| None` | Canvas width in pixels. Must be a positive integer. | +| `height` | `int \| None` | Canvas height in pixels. Must be a positive integer. | + +Width and height must be given together or both omitted. A canvas built **without** a size (`Canvas()`) is *unsized*: it accepts layer builders but cannot be rendered, diagnosed, or serialized until it gets a size — either directly or by being added to a sized [`Deck`](deck.md), which injects its default size. Check `canvas.has_size` to tell the difference. ### `Canvas.from_aspect_ratio(ratio, base_width)` diff --git a/docs/api/deck.md b/docs/api/deck.md index ad21e2c..46be966 100644 --- a/docs/api/deck.md +++ b/docs/api/deck.md @@ -8,22 +8,33 @@ A `Deck` is an ordered collection of `Canvas` slides. Each slide renders exactly ## Creation -### `Deck(slides=None)` +### `Deck(width=None, height=None, slides=None)` ```python from quickthumb import Canvas, Deck -deck = Deck() # empty -deck = Deck([cover, body]) # from a list of canvases +deck = Deck() # empty, no default size +deck = Deck(1280, 720) # default slide size for unsized slides +deck = Deck(slides=[cover, body]) # from pre-built canvases ``` | Parameter | Type | Description | | --- | --- | --- | +| `width` | `int \| None` | Default slide width. Provide with `height` or omit both. | +| `height` | `int \| None` | Default slide height. | | `slides` | `list[Canvas] \| None` | Initial slides, in order. Each must be a `Canvas`. | +### `Deck.from_aspect_ratio(ratio, base_width)` + +Creates a deck whose default slide size comes from an aspect ratio string, mirroring `Canvas.from_aspect_ratio`. + +```python +deck = Deck.from_aspect_ratio("16:9", 1280) # default 1280×720 +``` + ## Adding slides -Both builders mutate the deck and return `self` for chaining. +Both builders mutate the deck and return `self` for chaining. When the deck has a default size, an **unsized** `Canvas()` inherits it; a canvas built with an explicit size keeps its own (and triggers a `mixed-slide-size` warning when it differs). | Method | Description | | --- | --- | @@ -31,9 +42,15 @@ Both builders mutate the deck and return `self` for chaining. | `.add(*canvases)` | Append several canvases at once | ```python -deck = Deck().slide(cover).add(body, outro) +deck = ( + Deck(1280, 720) + .slide(Canvas().background(color="#101820").text(content="Cover", ...)) + .slide(Canvas().background(color="#1A1A2E").text(content="Body", ...)) +) ``` +Adding an unsized canvas to a deck with no default size raises `ValidationError`: give the deck a size or size the canvas. + A deck is also a sequence: `len(deck)`, `deck[i]`, and iteration over slides all work, and `deck.slides` returns the underlying list. ## Export methods diff --git a/docs/exports.md b/docs/exports.md index d2feb8b..b62ad4c 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -92,10 +92,17 @@ with open("promo.pdf", "wb") as f: A `Deck` is an ordered collection of canvases. Each slide is a full `Canvas` and renders exactly as it would on its own, so a deck is just a multi-output container on top of the same pipeline. See the [Deck API reference](api/deck.md) for the full method list. +Give the deck a size once and each slide can be a bare `Canvas()` that inherits it: + ```python from quickthumb import Canvas, Deck -deck = Deck().slide(cover).slide(body) # or Deck([cover, body]) / deck.add(a, b, c) +deck = ( + Deck(1280, 720) # default slide size; Deck.from_aspect_ratio("16:9", 1280) also works + .slide(Canvas().background(color="#101820").text(content="Cover", ...)) + .slide(Canvas().background(color="#1A1A2E").text(content="Body", ...)) +) +# pre-built canvases work too: Deck(slides=[cover, body]) / deck.add(a, b, c) deck.render("deck.pdf") # one multi-page PDF (a page per slide) deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index c76b719..ae37262 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -103,6 +103,11 @@ class Canvas: _BUILTIN_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") + _UNSIZED_MESSAGE = ( + "This Canvas has no size yet. Construct it as Canvas(width, height), " + "or add it to a Deck created with a size (Deck(width, height))." + ) + @classmethod def register_layer_fn(cls, name: str, fn: Callable[..., Image.Image | None]) -> None: cls._custom_layer_registry[name] = fn @@ -119,13 +124,24 @@ def register_template(cls, name: str, path: str) -> None: def unregister_template(cls, name: str) -> None: cls._template_registry.pop(name, None) - def __init__(self, width: int, height: int, layers: list[RenderableLayer] | None = None): - if width <= 0: + def __init__( + self, + width: int | None = None, + height: int | None = None, + layers: list[RenderableLayer] | None = None, + ): + if (width is None) != (height is None): + raise ValidationError("Provide both width and height, or neither.") + if width is not None and width <= 0: raise ValidationError("width must be > 0") - if height <= 0: + if height is not None and height <= 0: raise ValidationError("height must be > 0") - self._ctx = RenderContext(width, height) + # An unsized canvas defers its dimensions until a Deck injects them. Layer + # builders never need a size (coordinates resolve at render time), so the + # placeholder ctx stays valid; render/diagnose/serialize guard on _has_size. + self._has_size = width is not None + self._ctx = RenderContext(width or 0, height or 0) self._layers: list[RenderableLayer] = layers or [] self._effects = EffectsEngine() @@ -138,8 +154,27 @@ def __init__(self, width: int, height: int, layers: list[RenderableLayer] | None self._ctx, self, self._effects, self._fonts, self._text, self._groups ) + @property + def has_size(self) -> bool: + """Whether the canvas has concrete dimensions (False until a Deck assigns them).""" + return self._has_size + + def _inherit_size(self, width: int, height: int) -> None: + """Assign a size to an unsized canvas; no-op if it already has one. + + Used by Deck so an unsized slide picks up the deck's default size while an + explicitly sized canvas keeps its own dimensions. + """ + if self._has_size: + return + self._ctx.width = width + self._ctx.height = height + self._has_size = True + @property def width(self) -> int: + if not self._has_size: + raise ValidationError(self._UNSIZED_MESSAGE) return self._ctx.width @width.setter @@ -147,9 +182,12 @@ def width(self, value: int): if value <= 0: raise ValidationError("width must be > 0") self._ctx.width = value + self._has_size = self._ctx.height > 0 @property def height(self) -> int: + if not self._has_size: + raise ValidationError(self._UNSIZED_MESSAGE) return self._ctx.height @height.setter @@ -157,6 +195,7 @@ def height(self, value: int): if value <= 0: raise ValidationError("height must be > 0") self._ctx.height = value + self._has_size = self._ctx.width > 0 @property def layers(self) -> list[RenderableLayer]: diff --git a/quickthumb/deck.py b/quickthumb/deck.py index 7f3dafe..43d53f8 100644 --- a/quickthumb/deck.py +++ b/quickthumb/deck.py @@ -39,13 +39,39 @@ class DeckDiagnostic: class Deck: - """An ordered collection of Canvas slides with multi-output export.""" + """An ordered collection of Canvas slides with multi-output export. - def __init__(self, slides: list[Canvas] | None = None): + A deck can carry a default slide size. Unsized canvases added to it inherit + that size, so slides can be written as bare ``Canvas()`` without repeating the + dimensions; an explicitly sized canvas keeps its own size. + """ + + def __init__( + self, + width: int | None = None, + height: int | None = None, + slides: list[Canvas] | None = None, + ): + if (width is None) != (height is None): + raise ValidationError("Provide both width and height, or neither.") + if width is not None and width <= 0: + raise ValidationError("width must be > 0") + if height is not None and height <= 0: + raise ValidationError("height must be > 0") + + self._width = width + self._height = height self._slides: list[Canvas] = [] for slide in slides or []: self._append_slide(slide) + @classmethod + def from_aspect_ratio(cls, ratio: str, base_width: int) -> Self: + """Create a deck whose default slide size comes from an aspect ratio.""" + width_ratio, height_ratio = ratio.split(":") + calculated_height = int(base_width * int(height_ratio) / int(width_ratio)) + return cls(base_width, calculated_height) + @property def slides(self) -> list[Canvas]: # Return a copy so callers cannot bypass the Canvas type guard in @@ -75,6 +101,13 @@ def add(self, *canvases: Canvas) -> Self: def _append_slide(self, canvas: Canvas) -> None: if not isinstance(canvas, Canvas): raise ValidationError("Deck slides must be Canvas instances.") + if not canvas.has_size: + if self._width is None or self._height is None: + raise ValidationError( + "This slide has no size and the deck has no default size. " + "Give the deck a size (Deck(width, height)) or size the Canvas." + ) + canvas._inherit_size(self._width, self._height) self._slides.append(canvas) def _require_slides(self) -> None: diff --git a/tests/test_deck.py b/tests/test_deck.py index f73bdc4..e06c6df 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -34,7 +34,7 @@ def test_should_collect_slides_via_constructor_and_chaining(self): third = make_slide("3") # when - deck = Deck([first]).slide(second).add(third) + deck = Deck(slides=[first]).slide(second).add(third) # then assert list(deck) == [first, second, third] @@ -60,6 +60,56 @@ def test_should_reject_rendering_an_empty_deck(self, tmp_path: Path): deck.render(str(tmp_path / "out.pdf")) +class TestDefaultSize: + """A deck size lets slides be written as bare, unsized canvases.""" + + def test_should_inject_deck_size_into_unsized_slides(self): + """An unsized Canvas added to a sized deck inherits the deck dimensions.""" + # given a deck with a default size and bare canvases + deck = ( + Deck(1280, 720) + .slide(Canvas().background(color="#101820")) + .slide(Canvas().background(color="#1A1A2E")) + ) + + # then both slides take the deck size + assert [(c.width, c.height) for c in deck] == [(1280, 720), (1280, 720)] + + def test_should_keep_explicit_slide_size_over_deck_default(self): + """An explicitly sized canvas keeps its own dimensions inside a sized deck.""" + # given + deck = Deck(1280, 720).slide(Canvas(1080, 1080).background(color="#101820")) + + # then the explicit size wins + assert (deck[0].width, deck[0].height) == (1080, 1080) + + def test_should_derive_default_size_from_aspect_ratio(self): + """from_aspect_ratio sets the deck default that unsized slides inherit.""" + # given + deck = Deck.from_aspect_ratio("16:9", 1280).slide(Canvas().background(color="#101820")) + + # then + assert (deck[0].width, deck[0].height) == (1280, 720) + + def test_should_reject_unsized_slide_without_a_deck_size(self): + """An unsized slide needs either a deck size or its own size.""" + # given a deck with no default size + deck = Deck() + + # when / then + with pytest.raises(ValidationError, match="no default size"): + deck.slide(Canvas().background(color="#101820")) + + def test_should_reject_rendering_an_unsized_canvas_directly(self, tmp_path: Path): + """A bare Canvas cannot be rendered until it is given a size.""" + # given + canvas = Canvas().background(color="#101820") + + # when / then + with pytest.raises(ValidationError, match="no size yet"): + canvas.render(str(tmp_path / "out.png")) + + class TestRenderDispatch: """render() dispatches on the output extension and rejects bad combinations.""" From dba23cbd50327e846b34efcd83d1a18ebb88e08d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 15:43:11 +0000 Subject: [PATCH 4/6] Address Deck review: exporter API, JSON parity, robustness - Drop the dummy first-slide arg from the multi-canvas exporter calls by making PdfExporter/PptxExporter accept an optional canvas; single-canvas save()/export_bytes() now guard for it explicitly. - Preserve a deck's default size and a deck-level theme across to_json/ from_json; the shared theme resolves $theme.* tokens in every slide. - Share one aspect_ratio_dimensions() helper between Canvas and Deck and raise ValidationError for malformed ratios. - Pre-validate all slide assets before writing a raster sequence so a failing slide leaves no partial output on disk. - contact_sheet() accepts an (R,G,B[,A]) tuple and validates the background eagerly instead of failing opaquely at render time. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- docs/api/deck.md | 9 ++-- quickthumb/_base.py | 23 +++++++++ quickthumb/_export_pdf.py | 6 ++- quickthumb/_export_pptx.py | 10 ++-- quickthumb/canvas.py | 7 ++- quickthumb/deck.py | 101 +++++++++++++++++++++++++++++-------- tests/test_deck.py | 96 +++++++++++++++++++++++++++++++++++ 7 files changed, 217 insertions(+), 35 deletions(-) diff --git a/docs/api/deck.md b/docs/api/deck.md index 46be966..88e6ffa 100644 --- a/docs/api/deck.md +++ b/docs/api/deck.md @@ -8,7 +8,7 @@ A `Deck` is an ordered collection of `Canvas` slides. Each slide renders exactly ## Creation -### `Deck(width=None, height=None, slides=None)` +### `Deck(width=None, height=None, slides=None, theme=None)` ```python from quickthumb import Canvas, Deck @@ -23,6 +23,7 @@ deck = Deck(slides=[cover, body]) # from pre-built canvases | `width` | `int \| None` | Default slide width. Provide with `height` or omit both. | | `height` | `int \| None` | Default slide height. | | `slides` | `list[Canvas] \| None` | Initial slides, in order. Each must be a `Canvas`. | +| `theme` | `dict \| None` | Token groups shared with every slide's `$theme.*` references (slide-level themes win). Preserved across `to_json()`/`from_json()`. | ### `Deck.from_aspect_ratio(ratio, base_width)` @@ -51,7 +52,7 @@ deck = ( Adding an unsized canvas to a deck with no default size raises `ValidationError`: give the deck a size or size the canvas. -A deck is also a sequence: `len(deck)`, `deck[i]`, and iteration over slides all work, and `deck.slides` returns the underlying list. +A deck is also a sequence: `len(deck)`, `deck[i]`, and iteration over slides all work, and `deck.slides` returns a copy of the slide list (mutating it does not change the deck). ## Export methods @@ -105,7 +106,7 @@ deck.contact_sheet(columns=2).render("grid.png") | `thumb_width` | `int` | `480` | Cell width in pixels | | `gap` | `int` | `24` | Pixels between cells | | `padding` | `int` | `24` | Outer padding around the grid | -| `background` | `str` | `"#FFFFFF"` | Sheet background color | +| `background` | `str \| tuple` | `"#FFFFFF"` | Sheet background color (hex/named string or `(R, G, B[, A])` tuple) | ## `.diagnose()` @@ -130,7 +131,7 @@ A `mixed-slide-size` warning is added when slides do not all share the same dime ### `.to_json()` / `Deck.from_json(json_str)` -Round-trips the deck through JSON, reusing each canvas's serialization. The shape is `{"slides": [, ...]}`. +Round-trips the deck through JSON, reusing each canvas's serialization. The shape is `{"width": ..., "height": ..., "theme": {...}, "slides": [, ...]}`, where `width`/`height` (the default slide size) and `theme` are emitted only when set. A top-level `theme` is shared with every slide so slides can use `$theme.*` tokens, exactly like `Canvas.from_json`. ```python spec = deck.to_json() diff --git a/quickthumb/_base.py b/quickthumb/_base.py index 06936da..b6704ac 100644 --- a/quickthumb/_base.py +++ b/quickthumb/_base.py @@ -3,6 +3,7 @@ from PIL import ImageFont +from quickthumb.errors import ValidationError from quickthumb.models import Align FileFormat = Literal["JPEG", "WEBP", "PNG"] @@ -43,6 +44,28 @@ def parse_coordinate(value: int | str, dimension: int) -> int: return int(dimension * percentage / 100) +def aspect_ratio_dimensions(ratio: str, base_width: int) -> tuple[int, int]: + """Resolve an aspect ratio like "16:9" and a base width into (width, height). + + Raises ValidationError for malformed ratios so callers get the library's + error type instead of a raw ValueError/ZeroDivisionError. + """ + parts = ratio.split(":") + if len(parts) != 2: + raise ValidationError( + f"Aspect ratio must look like 'W:H' (for example '16:9'), got {ratio!r}." + ) + try: + width_ratio, height_ratio = int(parts[0]), int(parts[1]) + except ValueError: + raise ValidationError(f"Aspect ratio parts must be integers, got {ratio!r}.") from None + if width_ratio <= 0 or height_ratio <= 0: + raise ValidationError(f"Aspect ratio parts must be positive, got {ratio!r}.") + if base_width <= 0: + raise ValidationError("base_width must be > 0") + return base_width, int(base_width * height_ratio / width_ratio) + + def is_url(path: str) -> bool: return path.startswith("http://") or path.startswith("https://") diff --git a/quickthumb/_export_pdf.py b/quickthumb/_export_pdf.py index 46cb826..b36bebf 100644 --- a/quickthumb/_export_pdf.py +++ b/quickthumb/_export_pdf.py @@ -63,8 +63,10 @@ def _require_reportlab(): class PdfExporter: - def __init__(self, canvas: Canvas): + def __init__(self, canvas: Canvas | None = None): _require_reportlab() + # canvas is the single-canvas convenience target; the multi-page paths + # take their canvases explicitly and leave this None. self._canvas = canvas self._font_names: dict[str, str] = {} # font path -> registered reportlab name @@ -74,6 +76,8 @@ def export_bytes(self) -> bytes: return buffer.getvalue() def save(self, path_or_stream) -> None: + if self._canvas is None: + raise RenderingError("PdfExporter() needs a canvas for single-canvas export.") self.save_canvases([self._canvas], path_or_stream) def save_canvases(self, canvases: list[Canvas], path_or_stream) -> None: diff --git a/quickthumb/_export_pptx.py b/quickthumb/_export_pptx.py index 53a8a52..7fb350a 100644 --- a/quickthumb/_export_pptx.py +++ b/quickthumb/_export_pptx.py @@ -78,8 +78,10 @@ def _require_pptx(): class PptxExporter: - def __init__(self, canvas: Canvas): + def __init__(self, canvas: Canvas | None = None): _require_pptx() + # canvas is the single-slide convenience target; the multi-slide paths + # take their canvases explicitly and leave this None. self._canvas = canvas def export_bytes(self) -> bytes: @@ -88,6 +90,8 @@ def export_bytes(self) -> bytes: return buffer.getvalue() def save(self, path_or_stream) -> None: + if self._canvas is None: + raise RenderingError("PptxExporter() needs a canvas for single-slide export.") presentation = self._build([self._canvas]) presentation.save(path_or_stream) @@ -488,9 +492,7 @@ def _add_run(self, paragraph, run_layout: TextRunLayout, layer_opacity: float): stroke.width, ) if run_layout.glows or run_layout.shadows: - self._append_effects( - rpr, run_layout.glows, run_layout.shadows, opacity=layer_opacity - ) + self._append_effects(rpr, run_layout.glows, run_layout.shadows, opacity=layer_opacity) @staticmethod def _weight_is_bold(weight: int | str | None) -> bool: diff --git a/quickthumb/canvas.py b/quickthumb/canvas.py index ae37262..b50af07 100644 --- a/quickthumb/canvas.py +++ b/quickthumb/canvas.py @@ -8,7 +8,7 @@ from PIL import Image, ImageDraw from typing_extensions import Self -from quickthumb._base import FileFormat, RenderContext, is_url +from quickthumb._base import FileFormat, RenderContext, aspect_ratio_dimensions, is_url from quickthumb._diagnostics import DiagnosticsEngine from quickthumb._effects import EffectsEngine from quickthumb._fonts import FontEngine @@ -215,9 +215,8 @@ def diagnose(self) -> list: @classmethod def from_aspect_ratio(cls, ratio: str, base_width: int) -> Self: - width_ratio, height_ratio = ratio.split(":") - calculated_height = int(base_width * int(height_ratio) / int(width_ratio)) - return cls(base_width, calculated_height) + width, height = aspect_ratio_dimensions(ratio, base_width) + return cls(width, height) def background( self, diff --git a/quickthumb/deck.py b/quickthumb/deck.py index 43d53f8..509d421 100644 --- a/quickthumb/deck.py +++ b/quickthumb/deck.py @@ -14,7 +14,7 @@ from PIL import Image from typing_extensions import Self -from quickthumb._base import FileFormat +from quickthumb._base import FileFormat, aspect_ratio_dimensions from quickthumb.canvas import Canvas from quickthumb.errors import RenderingError, ValidationError @@ -51,6 +51,7 @@ def __init__( width: int | None = None, height: int | None = None, slides: list[Canvas] | None = None, + theme: dict | None = None, ): if (width is None) != (height is None): raise ValidationError("Provide both width and height, or neither.") @@ -58,9 +59,12 @@ def __init__( raise ValidationError("width must be > 0") if height is not None and height <= 0: raise ValidationError("height must be > 0") + if theme is not None and not isinstance(theme, dict): + raise ValidationError("theme must be a dict of token groups.") self._width = width self._height = height + self._theme = theme or {} self._slides: list[Canvas] = [] for slide in slides or []: self._append_slide(slide) @@ -68,9 +72,8 @@ def __init__( @classmethod def from_aspect_ratio(cls, ratio: str, base_width: int) -> Self: """Create a deck whose default slide size comes from an aspect ratio.""" - width_ratio, height_ratio = ratio.split(":") - calculated_height = int(base_width * int(height_ratio) / int(width_ratio)) - return cls(base_width, calculated_height) + width, height = aspect_ratio_dimensions(ratio, base_width) + return cls(width, height) @property def slides(self) -> list[Canvas]: @@ -126,7 +129,8 @@ def render( Raster extensions (``.png``, ``.jpg``, ``.jpeg``, ``.webp``) write one file per slide as a zero-padded numbered sequence derived from ``output_path`` (e.g. ``slides.png`` -> ``slides_01.png``, - ``slides_02.png``). Returns the list of written file paths. + ``slides_02.png``). Returns the list of written file paths (unlike + ``Canvas.render``, which returns None). """ self._require_slides() extension = os.path.splitext(output_path)[1].lower() @@ -163,16 +167,22 @@ def _render_document(self, output_path: str, extension: str) -> None: if extension == ".pdf": from quickthumb._export_pdf import PdfExporter - PdfExporter(self._slides[0]).save_canvases(self._slides, output_path) + PdfExporter().save_canvases(self._slides, output_path) return from quickthumb._export_pptx import PptxExporter - PptxExporter(self._slides[0]).save_canvases(self._slides, output_path) + PptxExporter().save_canvases(self._slides, output_path) def _render_sequence( self, output_path: str, format: FileFormat | None, quality: int | None ) -> list[str]: + # Validate every slide's assets up front so a missing image fails before + # any file is written, leaving no partial sequence on disk (matching the + # all-or-nothing behaviour of the PDF/PPTX/contact-sheet paths). + for canvas in self._slides: + canvas._validate_image_paths() + stem, extension = os.path.splitext(output_path) pad = max(2, len(str(len(self._slides)))) written: list[str] = [] @@ -187,14 +197,14 @@ def to_pdf(self) -> bytes: self._require_slides() from quickthumb._export_pdf import PdfExporter - return PdfExporter(self._slides[0]).export_bytes_canvases(self._slides) + return PdfExporter().export_bytes_canvases(self._slides) def to_pptx(self) -> bytes: """Render the deck to a multi-slide PPTX as bytes (requires quickthumb[pptx]).""" self._require_slides() from quickthumb._export_pptx import PptxExporter - return PptxExporter(self._slides[0]).export_bytes_canvases(self._slides) + return PptxExporter().export_bytes_canvases(self._slides) def diagnose(self) -> list[DeckDiagnostic]: """Collect per-slide diagnostics plus deck-wide layout warnings. @@ -206,6 +216,9 @@ def diagnose(self) -> list[DeckDiagnostic]: """ findings: list[DeckDiagnostic] = [] + # Every slide is guaranteed sized: _append_slide is the only way into + # _slides and it either inherits the deck size or rejects an unsized + # canvas, so reading .width/.height here cannot raise. sizes = {(canvas.width, canvas.height) for canvas in self._slides} if len(sizes) > 1: findings.append( @@ -239,19 +252,22 @@ def contact_sheet( thumb_width: int = 480, gap: int = 24, padding: int = 24, - background: str = "#FFFFFF", + background: str | tuple = "#FFFFFF", ) -> Canvas: """Compose the slides into a single grid image, returned as a Canvas. Each slide is rendered and contained (letterboxed) into a uniform cell - sized from the first slide's aspect ratio. The returned Canvas can be - rendered to any raster format like a normal canvas. + sized from the first slide's aspect ratio. ``background`` accepts a hex/ + named color string or an (R, G, B[, A]) tuple, like Canvas.background. + The returned Canvas can be rendered to any raster format like a normal + canvas. """ self._require_slides() if columns < 1: raise ValidationError("columns must be >= 1") if thumb_width < 1: raise ValidationError("thumb_width must be >= 1") + background_rgba = self._resolve_background(background) first = self._slides[0] cell_w = thumb_width @@ -265,9 +281,7 @@ def contact_sheet( thumbnails = [self._slide_thumbnail(canvas, cell_w, cell_h) for canvas in self._slides] def draw_grid(base: Image.Image) -> Image.Image: - from PIL import ImageColor - - sheet = Image.new("RGBA", base.size, ImageColor.getrgb(background) + (255,)) + sheet = Image.new("RGBA", base.size, background_rgba) for index, thumb in enumerate(thumbnails): row, col = divmod(index, cols) cell_x = padding + col * (cell_w + gap) @@ -279,6 +293,27 @@ def draw_grid(base: Image.Image) -> Image.Image: return Canvas(sheet_w, sheet_h).custom(draw_grid) + @staticmethod + def _resolve_background(background: str | tuple) -> tuple[int, int, int, int]: + # Resolve eagerly so an invalid color raises ValidationError from + # contact_sheet() instead of an opaque RenderingError at render time. + from PIL import ImageColor + + if isinstance(background, str): + try: + rgb = ImageColor.getrgb(background) + except ValueError as e: + raise ValidationError( + f"Invalid contact_sheet background {background!r}: {e}" + ) from e + else: + rgb = tuple(background) + if len(rgb) not in (3, 4) or not all(isinstance(c, int) for c in rgb): + raise ValidationError( + "contact_sheet background tuple must be (R, G, B) or (R, G, B, A) integers." + ) + return rgb if len(rgb) == 4 else rgb + (255,) + def _slide_thumbnail(self, canvas: Canvas, cell_w: int, cell_h: int) -> Image.Image: # Validate up front so a missing image fails with a clean FileNotFoundError, # matching render()/to_pdf()/to_pptx() rather than crashing mid-render. @@ -289,21 +324,43 @@ def _slide_thumbnail(self, canvas: Canvas, cell_w: int, cell_h: int) -> Image.Im return image.resize(size, Image.LANCZOS) def to_json(self) -> str: - import json as _json + import json - slides = [_json.loads(canvas.to_json()) for canvas in self._slides] - return _json.dumps({"slides": slides}) + payload: dict = {} + if self._width is not None: + payload["width"] = self._width + payload["height"] = self._height + if self._theme: + payload["theme"] = self._theme + payload["slides"] = [json.loads(canvas.to_json()) for canvas in self._slides] + return json.dumps(payload) @classmethod def from_json(cls, data: str) -> Self: - import json as _json + import json - raw = _json.loads(data) + raw = json.loads(data) if not isinstance(raw, dict) or "slides" not in raw: raise ValidationError("Deck JSON must be an object with a 'slides' list.") slides_raw = raw["slides"] if not isinstance(slides_raw, list): raise ValidationError("Deck 'slides' must be a list of canvas specs.") - slides = [Canvas.from_json(_json.dumps(slide)) for slide in slides_raw] - return cls(slides=slides) + theme = raw.get("theme", {}) + if not isinstance(theme, dict): + raise ValidationError("Deck 'theme' must be an object of token groups.") + + slides = [] + for slide in slides_raw: + # Share the deck-level theme with each slide so $theme.* tokens + # resolve; a slide's own theme block takes precedence. + if theme and isinstance(slide, dict): + slide = {**slide, "theme": {**theme, **slide.get("theme", {})}} + slides.append(Canvas.from_json(json.dumps(slide))) + + return cls( + width=raw.get("width"), + height=raw.get("height"), + slides=slides, + theme=theme or None, + ) diff --git a/tests/test_deck.py b/tests/test_deck.py index e06c6df..9ac7876 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -1,5 +1,6 @@ """Tests for Deck: multi-slide / multi-image collections of Canvas objects.""" +import json from io import BytesIO from pathlib import Path @@ -91,6 +92,12 @@ def test_should_derive_default_size_from_aspect_ratio(self): # then assert (deck[0].width, deck[0].height) == (1280, 720) + def test_should_reject_a_malformed_aspect_ratio(self): + """A malformed ratio raises the library's ValidationError, not a raw error.""" + # when / then + with pytest.raises(ValidationError, match="Aspect ratio"): + Deck.from_aspect_ratio("16-9", 1280) + def test_should_reject_unsized_slide_without_a_deck_size(self): """An unsized slide needs either a deck size or its own size.""" # given a deck with no default size @@ -176,6 +183,18 @@ def test_should_pass_quality_through_for_jpeg_sequence(self, tmp_path: Path): assert len(written) == 2 assert all(Path(path).exists() for path in written) + def test_should_not_write_partial_sequence_when_a_slide_fails(self, tmp_path: Path): + """A missing asset on a later slide fails before any file is written.""" + # given a deck whose second slide references a missing image + broken = make_slide("b").image(str(tmp_path / "missing.png"), position=(0, 0)) + deck = Deck().add(make_slide("a"), broken, make_slide("c")) + output = tmp_path / "slides.png" + + # when / then: rendering raises and leaves no partial output behind + with pytest.raises(FileNotFoundError): + deck.render(str(output)) + assert list(tmp_path.glob("slides_*.png")) == [] + class TestDocumentExport: """Rendering a deck to multi-page PDF and multi-slide PPTX documents.""" @@ -253,6 +272,23 @@ def test_should_keep_first_slide_size_for_mixed_pptx(self, tmp_path: Path): assert presentation.slide_width == 1280 * 9525 assert presentation.slide_height == 720 * 9525 + def test_should_size_each_pdf_page_to_its_own_slide(self, tmp_path: Path): + """Unlike PPTX, a mixed-size PDF sizes every page to its slide.""" + # given a deck with two differently shaped slides + pdfium = require_pypdfium2() + deck = Deck().add(make_slide("wide", 1280, 720), make_slide("square", 800, 800)) + output = tmp_path / "deck.pdf" + + # when + deck.render(str(output)) + + # then each page keeps its slide's pixel dimensions (1pt per pixel) + document = pdfium.PdfDocument(str(output)) + assert [tuple(round(v) for v in document[i].get_size()) for i in range(len(document))] == [ + (1280, 720), + (800, 800), + ] + class TestDiagnose: """Deck-level diagnostics aggregate per-slide findings and deck-wide issues.""" @@ -341,6 +377,28 @@ def test_should_reject_non_positive_columns(self): with pytest.raises(ValidationError, match="columns"): deck.contact_sheet(columns=0) + def test_should_accept_an_rgb_tuple_background(self, tmp_path: Path): + """contact_sheet() accepts an (R, G, B) tuple like Canvas.background.""" + # given + deck = Deck().add(make_slide("1"), make_slide("2")) + + # when + sheet = deck.contact_sheet(background=(10, 20, 30)) + output = tmp_path / "grid.png" + sheet.render(str(output)) + + # then the corner pixel carries the requested background color + assert Image.open(output).convert("RGB").getpixel((0, 0)) == (10, 20, 30) + + def test_should_reject_an_invalid_background_eagerly(self): + """An invalid background raises from contact_sheet(), not at render time.""" + # given + deck = Deck().slide(make_slide("1")) + + # when / then + with pytest.raises(ValidationError, match="background"): + deck.contact_sheet(background="not-a-color") + class TestJsonRoundTrip: """Decks serialize to and from JSON via the underlying canvas specs.""" @@ -362,3 +420,41 @@ def test_should_reject_json_without_slides(self): # when / then with pytest.raises(ValidationError, match="slides"): Deck.from_json('{"pages": []}') + + def test_should_preserve_default_size_across_json(self): + """A restored deck keeps its default size so bare slides still inherit it.""" + # given a sized deck round-tripped through JSON + deck = Deck(1280, 720).add(make_slide("1")) + restored = Deck.from_json(deck.to_json()) + + # when a bare, unsized canvas is added to the restored deck + restored.slide(Canvas().background(color="#101820")) + + # then it inherits the deck default size instead of raising + assert (restored[-1].width, restored[-1].height) == (1280, 720) + + def test_should_share_a_deck_level_theme_with_each_slide(self): + """A top-level theme resolves $theme.* tokens inside every slide.""" + # given a deck spec with a shared theme and a slide that references it + spec = json.dumps( + { + "width": 1280, + "height": 720, + "theme": {"brand": "#B8FF00"}, + "slides": [ + { + "width": 1280, + "height": 720, + "layers": [{"type": "background", "color": "$theme.brand"}], + } + ], + } + ) + + # when + deck = Deck.from_json(spec) + + # then the slide's token resolved to the deck-level theme value + assert deck[0].layers[0].color == "#B8FF00" + # and the theme survives another round-trip + assert json.loads(deck.to_json())["theme"] == {"brand": "#B8FF00"} From e02ed7c49b2a7edcebdc9ffc51efd8697709fb8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 14:26:10 +0000 Subject: [PATCH 5/6] Remove Deck.contact_sheet The contact-sheet grid was speculative surface area: nothing in the library, examples, or workflows used it, and a proof-sheet image is a separate concern from the multi-page/multi-slide/sequence outputs the deck exists for. Drop the method, its private helpers, the now-unused PIL import, and the related tests and docs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- README.md | 4 +-- docs/api/deck.md | 18 +--------- docs/api/index.md | 2 +- docs/exports.md | 5 --- quickthumb/deck.py | 86 +++------------------------------------------- tests/test_deck.py | 65 ----------------------------------- 6 files changed, 7 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index baa21bf..2700710 100644 --- a/README.md +++ b/README.md @@ -408,8 +408,6 @@ deck.render("deck.pdf") # one multi-page PDF (a page per slide) deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) deck.render("slides.png") # numbered sequence: slides_01.png, slides_02.png, … -deck.contact_sheet(columns=2).render("grid.png") # all slides in one grid image - pdf_bytes = deck.to_pdf() # requires quickthumb[pdf] pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] ``` @@ -540,7 +538,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto" | Diagnostics | `canvas.diagnose()` and `quickthumb lint`: off-canvas, tiny text, overflow, low contrast | | Export | PNG, JPEG, WebP, file output, base64, data URLs | | Document renderers | SVG (`to_svg()`), editable PPTX via `quickthumb[pptx]`, PDF via `quickthumb[pdf]` | -| Decks | `Deck` of multiple slides: multi-page PDF, multi-slide PPTX, numbered image sequences, contact-sheet grids, per-slide diagnostics | +| Decks | `Deck` of multiple slides: multi-page PDF, multi-slide PPTX, numbered image sequences, per-slide diagnostics | | Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers | ## Real Example Scripts diff --git a/docs/api/deck.md b/docs/api/deck.md index 88e6ffa..3c583e0 100644 --- a/docs/api/deck.md +++ b/docs/api/deck.md @@ -1,5 +1,5 @@ --- -description: Reference for quickthumb Deck — collecting canvases into multi-page PDFs, multi-slide PPTX, numbered image sequences, and contact-sheet grids. +description: Reference for quickthumb Deck — collecting canvases into multi-page PDFs, multi-slide PPTX, and numbered image sequences. --- # Deck @@ -92,22 +92,6 @@ with open("deck.pdf", "wb") as f: pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] ``` -### `.contact_sheet(columns=3, thumb_width=480, gap=24, padding=24, background="#FFFFFF")` - -Renders every slide and letterboxes it into a uniform grid cell sized from the first slide's aspect ratio, returning a normal `Canvas` you can render to any raster format. - -```python -deck.contact_sheet(columns=2).render("grid.png") -``` - -| Parameter | Type | Default | Description | -| --- | --- | --- | --- | -| `columns` | `int` | `3` | Grid columns (clamped to the slide count) | -| `thumb_width` | `int` | `480` | Cell width in pixels | -| `gap` | `int` | `24` | Pixels between cells | -| `padding` | `int` | `24` | Outer padding around the grid | -| `background` | `str \| tuple` | `"#FFFFFF"` | Sheet background color (hex/named string or `(R, G, B[, A])` tuple) | - ## `.diagnose()` Aggregates each slide's [`Canvas.diagnose()`](canvas.md#diagnose) findings and adds deck-wide checks. Returns a list of `DeckDiagnostic` entries: diff --git a/docs/api/index.md b/docs/api/index.md index e7f917f..ffa3559 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -35,7 +35,7 @@ from quickthumb import ( | Page | What it covers | | --- | --- | | [Canvas](canvas.md) | `Canvas` creation, layer builders, diagnostics, and export methods | -| [Deck](deck.md) | `Deck` — multiple slides to PDF, PPTX, image sequences, and contact sheets | +| [Deck](deck.md) | `Deck` — multiple slides to PDF, PPTX, and image sequences | | [Background](background.md) | `.background()` — solid colors, gradients, and images | | [Text](text.md) | `.text()` and `TextPart` — text layers and rich text | | [Image](image.md) | `.image()` — overlay images and cutouts | diff --git a/docs/exports.md b/docs/exports.md index b62ad4c..6a0c435 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -108,8 +108,6 @@ deck.render("deck.pdf") # one multi-page PDF (a page per slide) deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) deck.render("slides.png") # numbered sequence: slides_01.png, slides_02.png, … -deck.contact_sheet(columns=2).render("grid.png") # all slides in one grid image - pdf_bytes = deck.to_pdf() pptx_bytes = deck.to_pptx() ``` @@ -118,9 +116,6 @@ pptx_bytes = deck.to_pptx() Slides may have different dimensions. `deck.diagnose()` aggregates each slide's [diagnostics](diagnostics.md) (each tagged with its `slide_index`) and adds a `mixed-slide-size` warning when they differ. The PDF path sizes each page to its slide, but PPTX has a single presentation size taken from the first slide, so slides larger than the first are clipped by PowerPoint — keep slides a uniform size when targeting `.pptx`. Decks round-trip through JSON with `deck.to_json()` / `Deck.from_json(...)`, reusing the per-canvas serialization. -!!! note "Contact sheet" - `contact_sheet()` renders every slide and letterboxes it into a uniform grid cell sized from the first slide's aspect ratio, returning a normal `Canvas` you can render to any raster format. - ## CLI The CLI picks the format from the output extension too: diff --git a/quickthumb/deck.py b/quickthumb/deck.py index 509d421..1cc2953 100644 --- a/quickthumb/deck.py +++ b/quickthumb/deck.py @@ -1,9 +1,9 @@ """Multi-slide / multi-image decks built on top of Canvas. A Deck is an ordered collection of Canvas objects ("slides"). It renders to a -multi-page PDF, a multi-slide PPTX, a numbered sequence of raster images, or a -single contact-sheet grid, reusing the exact same per-canvas render pipeline so -every slide looks identical to rendering that Canvas on its own. +multi-page PDF, a multi-slide PPTX, or a numbered sequence of raster images, +reusing the exact same per-canvas render pipeline so every slide looks +identical to rendering that Canvas on its own. """ from __future__ import annotations @@ -11,7 +11,6 @@ import os from dataclasses import dataclass -from PIL import Image from typing_extensions import Self from quickthumb._base import FileFormat, aspect_ratio_dimensions @@ -179,7 +178,7 @@ def _render_sequence( ) -> list[str]: # Validate every slide's assets up front so a missing image fails before # any file is written, leaving no partial sequence on disk (matching the - # all-or-nothing behaviour of the PDF/PPTX/contact-sheet paths). + # all-or-nothing behaviour of the PDF/PPTX paths). for canvas in self._slides: canvas._validate_image_paths() @@ -246,83 +245,6 @@ def diagnose(self) -> list[DeckDiagnostic]: ) return findings - def contact_sheet( - self, - columns: int = 3, - thumb_width: int = 480, - gap: int = 24, - padding: int = 24, - background: str | tuple = "#FFFFFF", - ) -> Canvas: - """Compose the slides into a single grid image, returned as a Canvas. - - Each slide is rendered and contained (letterboxed) into a uniform cell - sized from the first slide's aspect ratio. ``background`` accepts a hex/ - named color string or an (R, G, B[, A]) tuple, like Canvas.background. - The returned Canvas can be rendered to any raster format like a normal - canvas. - """ - self._require_slides() - if columns < 1: - raise ValidationError("columns must be >= 1") - if thumb_width < 1: - raise ValidationError("thumb_width must be >= 1") - background_rgba = self._resolve_background(background) - - first = self._slides[0] - cell_w = thumb_width - cell_h = max(1, round(thumb_width * first.height / first.width)) - - cols = min(columns, len(self._slides)) - rows = -(-len(self._slides) // cols) # ceil division - sheet_w = padding * 2 + cols * cell_w + (cols - 1) * gap - sheet_h = padding * 2 + rows * cell_h + (rows - 1) * gap - - thumbnails = [self._slide_thumbnail(canvas, cell_w, cell_h) for canvas in self._slides] - - def draw_grid(base: Image.Image) -> Image.Image: - sheet = Image.new("RGBA", base.size, background_rgba) - for index, thumb in enumerate(thumbnails): - row, col = divmod(index, cols) - cell_x = padding + col * (cell_w + gap) - cell_y = padding + row * (cell_h + gap) - offset_x = cell_x + (cell_w - thumb.width) // 2 - offset_y = cell_y + (cell_h - thumb.height) // 2 - sheet.alpha_composite(thumb, (offset_x, offset_y)) - return sheet - - return Canvas(sheet_w, sheet_h).custom(draw_grid) - - @staticmethod - def _resolve_background(background: str | tuple) -> tuple[int, int, int, int]: - # Resolve eagerly so an invalid color raises ValidationError from - # contact_sheet() instead of an opaque RenderingError at render time. - from PIL import ImageColor - - if isinstance(background, str): - try: - rgb = ImageColor.getrgb(background) - except ValueError as e: - raise ValidationError( - f"Invalid contact_sheet background {background!r}: {e}" - ) from e - else: - rgb = tuple(background) - if len(rgb) not in (3, 4) or not all(isinstance(c, int) for c in rgb): - raise ValidationError( - "contact_sheet background tuple must be (R, G, B) or (R, G, B, A) integers." - ) - return rgb if len(rgb) == 4 else rgb + (255,) - - def _slide_thumbnail(self, canvas: Canvas, cell_w: int, cell_h: int) -> Image.Image: - # Validate up front so a missing image fails with a clean FileNotFoundError, - # matching render()/to_pdf()/to_pptx() rather than crashing mid-render. - canvas._validate_image_paths() - image = canvas._render_to_image() - scale = min(cell_w / image.width, cell_h / image.height) - size = (max(1, round(image.width * scale)), max(1, round(image.height * scale))) - return image.resize(size, Image.LANCZOS) - def to_json(self) -> str: import json diff --git a/tests/test_deck.py b/tests/test_deck.py index 9ac7876..a418286 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -335,71 +335,6 @@ def test_should_not_warn_when_slides_share_a_size(self): assert not [f for f in findings if f.code == "mixed-slide-size"] -class TestContactSheet: - """Composing slides into a single grid image.""" - - def test_should_lay_out_slides_in_a_grid_canvas(self, tmp_path: Path): - """contact_sheet() returns a renderable Canvas sized for the grid.""" - # given - deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) - - # when - sheet = deck.contact_sheet(columns=2, thumb_width=400, gap=20, padding=20) - output = tmp_path / "grid.png" - sheet.render(str(output)) - - # then: 2 columns, 2 rows for 3 slides - cell_h = round(400 * 720 / 1280) - expected_width = 20 * 2 + 2 * 400 + 20 - expected_height = 20 * 2 + 2 * cell_h + 20 - assert (sheet.width, sheet.height) == (expected_width, expected_height) - assert Image.open(output).size == (expected_width, expected_height) - - def test_should_clamp_columns_to_slide_count(self): - """Requesting more columns than slides collapses to a single row.""" - # given - deck = Deck().add(make_slide("1"), make_slide("2")) - - # when: 4 columns requested for 2 slides - sheet = deck.contact_sheet(columns=4, thumb_width=400, gap=20, padding=20) - - # then: only 2 cells wide, one row tall - cell_h = round(400 * 720 / 1280) - assert sheet.width == 20 * 2 + 2 * 400 + 20 - assert sheet.height == 20 * 2 + cell_h - - def test_should_reject_non_positive_columns(self): - """A contact sheet needs at least one column.""" - # given - deck = Deck().slide(make_slide("1")) - - # when / then - with pytest.raises(ValidationError, match="columns"): - deck.contact_sheet(columns=0) - - def test_should_accept_an_rgb_tuple_background(self, tmp_path: Path): - """contact_sheet() accepts an (R, G, B) tuple like Canvas.background.""" - # given - deck = Deck().add(make_slide("1"), make_slide("2")) - - # when - sheet = deck.contact_sheet(background=(10, 20, 30)) - output = tmp_path / "grid.png" - sheet.render(str(output)) - - # then the corner pixel carries the requested background color - assert Image.open(output).convert("RGB").getpixel((0, 0)) == (10, 20, 30) - - def test_should_reject_an_invalid_background_eagerly(self): - """An invalid background raises from contact_sheet(), not at render time.""" - # given - deck = Deck().slide(make_slide("1")) - - # when / then - with pytest.raises(ValidationError, match="background"): - deck.contact_sheet(background="not-a-color") - - class TestJsonRoundTrip: """Decks serialize to and from JSON via the underlying canvas specs.""" From 02ab8c6e3a2c1b91e0c4d0bebfb401739a40f7f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 15:40:42 +0000 Subject: [PATCH 6/6] Remove Deck.add in favor of the constructor and slide() Deck(slides=[...]) already covers seeding several slides at once and .slide() covers appending one, so a separate variadic add() was redundant surface area. Drop it and update tests and docs to use the constructor or chained slide() calls. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RLVAwDncqBSdzXnid1YMFB --- README.md | 6 +++--- docs/api/deck.md | 3 +-- docs/exports.md | 2 +- quickthumb/deck.py | 6 ------ tests/test_deck.py | 32 ++++++++++++++++---------------- 5 files changed, 21 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 2700710..38ee10b 100644 --- a/README.md +++ b/README.md @@ -413,9 +413,9 @@ pptx_bytes = deck.to_pptx() # requires quickthumb[pptx] ``` An unsized `Canvas()` inherits the deck's size when added; a canvas built with an -explicit size (`Canvas(1080, 1080)`) keeps its own. You can still pass fully -built canvases too — `Deck(slides=[cover, body])`, `deck.add(a, b, c)`. A bare -`Canvas()` cannot be rendered until it is given a size (directly or by a deck). +explicit size (`Canvas(1080, 1080)`) keeps its own. You can also seed a deck with +fully built canvases up front — `Deck(slides=[cover, body])`. A bare `Canvas()` +cannot be rendered until it is given a size (directly or by a deck). Raster formats have no native multi-page container, so `render()` writes one file per slide as a zero-padded numbered sequence and returns the written diff --git a/docs/api/deck.md b/docs/api/deck.md index 3c583e0..da7a0c6 100644 --- a/docs/api/deck.md +++ b/docs/api/deck.md @@ -35,12 +35,11 @@ deck = Deck.from_aspect_ratio("16:9", 1280) # default 1280×720 ## Adding slides -Both builders mutate the deck and return `self` for chaining. When the deck has a default size, an **unsized** `Canvas()` inherits it; a canvas built with an explicit size keeps its own (and triggers a `mixed-slide-size` warning when it differs). +Pass initial slides to the constructor (`Deck(slides=[...])`) and/or append them one at a time with `.slide(canvas)`, which mutates the deck and returns `self` for chaining. When the deck has a default size, an **unsized** `Canvas()` inherits it; a canvas built with an explicit size keeps its own (and triggers a `mixed-slide-size` warning when it differs). | Method | Description | | --- | --- | | `.slide(canvas)` | Append one `Canvas` as the next slide | -| `.add(*canvases)` | Append several canvases at once | ```python deck = ( diff --git a/docs/exports.md b/docs/exports.md index 6a0c435..3536e7a 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -102,7 +102,7 @@ deck = ( .slide(Canvas().background(color="#101820").text(content="Cover", ...)) .slide(Canvas().background(color="#1A1A2E").text(content="Body", ...)) ) -# pre-built canvases work too: Deck(slides=[cover, body]) / deck.add(a, b, c) +# pre-built canvases work too: Deck(slides=[cover, body]) deck.render("deck.pdf") # one multi-page PDF (a page per slide) deck.render("deck.pptx") # one multi-slide PPTX (a slide per slide) diff --git a/quickthumb/deck.py b/quickthumb/deck.py index 1cc2953..60c0326 100644 --- a/quickthumb/deck.py +++ b/quickthumb/deck.py @@ -94,12 +94,6 @@ def slide(self, canvas: Canvas) -> Self: self._append_slide(canvas) return self - def add(self, *canvases: Canvas) -> Self: - """Append several Canvas slides at once (chainable).""" - for canvas in canvases: - self._append_slide(canvas) - return self - def _append_slide(self, canvas: Canvas) -> None: if not isinstance(canvas, Canvas): raise ValidationError("Deck slides must be Canvas instances.") diff --git a/tests/test_deck.py b/tests/test_deck.py index a418286..e21fa07 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -28,14 +28,14 @@ class TestDeckComposition: """Building a deck and inspecting its slides.""" def test_should_collect_slides_via_constructor_and_chaining(self): - """slide() and add() append canvases while staying chainable.""" + """The constructor seeds slides and slide() appends more, staying chainable.""" # given first = make_slide("1") second = make_slide("2") third = make_slide("3") # when - deck = Deck(slides=[first]).slide(second).add(third) + deck = Deck(slides=[first]).slide(second).slide(third) # then assert list(deck) == [first, second, third] @@ -154,7 +154,7 @@ class TestRasterSequence: def test_should_write_zero_padded_numbered_sequence(self, tmp_path: Path): """Raster output writes one file per slide with a zero-padded index.""" # given - deck = Deck().add(make_slide("a"), make_slide("b"), make_slide("c")) + deck = Deck(slides=[make_slide("a"), make_slide("b"), make_slide("c")]) output = tmp_path / "slides.png" # when @@ -173,7 +173,7 @@ def test_should_write_zero_padded_numbered_sequence(self, tmp_path: Path): def test_should_pass_quality_through_for_jpeg_sequence(self, tmp_path: Path): """Raster sequences honor the quality argument for JPEG output.""" # given - deck = Deck().add(make_slide("a"), make_slide("b")) + deck = Deck(slides=[make_slide("a"), make_slide("b")]) output = tmp_path / "slides.jpg" # when @@ -187,7 +187,7 @@ def test_should_not_write_partial_sequence_when_a_slide_fails(self, tmp_path: Pa """A missing asset on a later slide fails before any file is written.""" # given a deck whose second slide references a missing image broken = make_slide("b").image(str(tmp_path / "missing.png"), position=(0, 0)) - deck = Deck().add(make_slide("a"), broken, make_slide("c")) + deck = Deck(slides=[make_slide("a"), broken, make_slide("c")]) output = tmp_path / "slides.png" # when / then: rendering raises and leaves no partial output behind @@ -203,7 +203,7 @@ def test_should_render_one_pdf_page_per_slide(self, tmp_path: Path): """A PDF deck produces a document with a page for every slide.""" # given pdfium = require_pypdfium2() - deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) + deck = Deck(slides=[make_slide("1"), make_slide("2"), make_slide("3")]) output = tmp_path / "deck.pdf" # when @@ -216,7 +216,7 @@ def test_should_render_one_pdf_page_per_slide(self, tmp_path: Path): def test_should_render_one_pptx_slide_per_slide(self, tmp_path: Path): """A PPTX deck produces a presentation with a slide for every slide.""" # given - deck = Deck().add(make_slide("1"), make_slide("2")) + deck = Deck(slides=[make_slide("1"), make_slide("2")]) output = tmp_path / "deck.pptx" # when @@ -230,7 +230,7 @@ def test_should_expose_pdf_bytes_with_a_page_per_slide(self): """to_pdf() returns multi-page PDF bytes.""" # given pdfium = require_pypdfium2() - deck = Deck().add(make_slide("1"), make_slide("2")) + deck = Deck(slides=[make_slide("1"), make_slide("2")]) # when data = deck.to_pdf() @@ -241,7 +241,7 @@ def test_should_expose_pdf_bytes_with_a_page_per_slide(self): def test_should_expose_pptx_bytes_with_a_slide_per_slide(self): """to_pptx() returns multi-slide presentation bytes.""" # given - deck = Deck().add(make_slide("1"), make_slide("2"), make_slide("3")) + deck = Deck(slides=[make_slide("1"), make_slide("2"), make_slide("3")]) # when data = deck.to_pptx() @@ -261,7 +261,7 @@ def test_should_reject_quality_for_document_output(self, tmp_path: Path): def test_should_keep_first_slide_size_for_mixed_pptx(self, tmp_path: Path): """With mixed sizes the PPTX page size comes from the first slide.""" # given - deck = Deck().add(make_slide("wide", 1280, 720), make_slide("square", 800, 800)) + deck = Deck(slides=[make_slide("wide", 1280, 720), make_slide("square", 800, 800)]) output = tmp_path / "deck.pptx" # when @@ -276,7 +276,7 @@ def test_should_size_each_pdf_page_to_its_own_slide(self, tmp_path: Path): """Unlike PPTX, a mixed-size PDF sizes every page to its slide.""" # given a deck with two differently shaped slides pdfium = require_pypdfium2() - deck = Deck().add(make_slide("wide", 1280, 720), make_slide("square", 800, 800)) + deck = Deck(slides=[make_slide("wide", 1280, 720), make_slide("square", 800, 800)]) output = tmp_path / "deck.pdf" # when @@ -299,7 +299,7 @@ def test_should_tag_slide_findings_with_slide_index(self): offending = Canvas(1280, 720).text( content="hi", size=64, color="#FFFFFF", position=("200%", "50%") ) - deck = Deck().add(make_slide("ok"), offending) + deck = Deck(slides=[make_slide("ok"), offending]) # when findings = deck.diagnose() @@ -312,7 +312,7 @@ def test_should_tag_slide_findings_with_slide_index(self): def test_should_warn_when_slides_have_mixed_sizes(self): """Differing slide dimensions raise a single deck-wide warning.""" # given - deck = Deck().add(make_slide("a", 1280, 720), make_slide("b", 800, 800)) + deck = Deck(slides=[make_slide("a", 1280, 720), make_slide("b", 800, 800)]) # when findings = deck.diagnose() @@ -326,7 +326,7 @@ def test_should_warn_when_slides_have_mixed_sizes(self): def test_should_not_warn_when_slides_share_a_size(self): """Uniformly sized slides produce no mixed-size warning.""" # given - deck = Deck().add(make_slide("a"), make_slide("b")) + deck = Deck(slides=[make_slide("a"), make_slide("b")]) # when findings = deck.diagnose() @@ -341,7 +341,7 @@ class TestJsonRoundTrip: def test_should_round_trip_through_json(self): """from_json(to_json()) reproduces every slide's spec.""" # given - deck = Deck().add(make_slide("1"), make_slide("2")) + deck = Deck(slides=[make_slide("1"), make_slide("2")]) # when restored = Deck.from_json(deck.to_json()) @@ -359,7 +359,7 @@ def test_should_reject_json_without_slides(self): def test_should_preserve_default_size_across_json(self): """A restored deck keeps its default size so bare slides still inherit it.""" # given a sized deck round-tripped through JSON - deck = Deck(1280, 720).add(make_slide("1")) + deck = Deck(1280, 720, slides=[make_slide("1")]) restored = Deck.from_json(deck.to_json()) # when a bare, unsized canvas is added to the restored deck