diff --git a/README.md b/README.md index 9eaa8c8..38ee10b 100644 --- a/README.md +++ b/README.md @@ -385,6 +385,49 @@ 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"). 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 + +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" + )) +) + +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, … + +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 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 +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. 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 quickthumb can round-trip most canvases through JSON: @@ -495,6 +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, 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/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 new file mode 100644 index 0000000..da7a0c6 --- /dev/null +++ b/docs/api/deck.md @@ -0,0 +1,125 @@ +--- +description: Reference for quickthumb Deck — collecting canvases into multi-page PDFs, multi-slide PPTX, and numbered image sequences. +--- + +# 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(width=None, height=None, slides=None, theme=None)` + +```python +from quickthumb import Canvas, Deck + +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`. | +| `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)` + +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 + +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 | + +```python +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 a copy of the slide list (mutating it does not change the deck). + +## 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] +``` + +## `.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. 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 + +### `.to_json()` / `Deck.from_json(json_str)` + +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() +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..ffa3559 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, 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 e1c194a..3536e7a 100644 --- a/docs/exports.md +++ b/docs/exports.md @@ -88,6 +88,34 @@ 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. + +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(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.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, … + +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. 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. + ## 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/_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 c4f4559..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,24 +76,46 @@ 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: + """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 _build(self, stream) -> None: + 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], 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 +126,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..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,30 +90,53 @@ def export_bytes(self) -> bytes: return buffer.getvalue() def save(self, path_or_stream) -> None: - presentation = self._build() + 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) - 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 + 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)) + if prefix: + fragment = rasterize_layers(canvas, prefix) + if fragment: + self._add_fragment(fragment) + for layer in rest: + self._emit_layer(layer) return presentation @@ -467,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 c76b719..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 @@ -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]: @@ -176,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 new file mode 100644 index 0000000..60c0326 --- /dev/null +++ b/quickthumb/deck.py @@ -0,0 +1,282 @@ +"""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, 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 + +import os +from dataclasses import dataclass + +from typing_extensions import Self + +from quickthumb._base import FileFormat, aspect_ratio_dimensions +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. + + 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, + theme: dict | 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") + 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) + + @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, height = aspect_ratio_dimensions(ratio, base_width) + return cls(width, height) + + @property + def slides(self) -> list[Canvas]: + # 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) + + 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 _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: + 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 (unlike + ``Canvas.render``, which returns None). + """ + 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." + ) + 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: + 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)." + ) + + def _render_document(self, output_path: str, extension: str) -> None: + if extension == ".pdf": + from quickthumb._export_pdf import PdfExporter + + PdfExporter().save_canvases(self._slides, output_path) + return + + from quickthumb._export_pptx import PptxExporter + + 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 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] = [] + 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().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().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] = [] + + # 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( + 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 to_json(self) -> str: + import json + + 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 + + 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.") + + 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 new file mode 100644 index 0000000..e21fa07 --- /dev/null +++ b/tests/test_deck.py @@ -0,0 +1,395 @@ +"""Tests for Deck: multi-slide / multi-image collections of Canvas objects.""" + +import json +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): + """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).slide(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 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_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 + 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.""" + + 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.""" + + 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(slides=[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(slides=[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) + + 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(slides=[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.""" + + 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(slides=[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(slides=[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(slides=[make_slide("1"), make_slide("2")]) + + # when + data = deck.to_pdf() + + # 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(slides=[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 + 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(slides=[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 + + 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(slides=[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.""" + + 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(slides=[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(slides=[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(slides=[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 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(slides=[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": []}') + + 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, slides=[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"}