Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/api/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)`

Expand Down
125 changes: 125 additions & 0 deletions docs/api/deck.md
Original file line number Diff line number Diff line change
@@ -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 `<stem>_NN<ext>` |
| `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": [<canvas spec>, ...]}`, 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.
2 changes: 2 additions & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ from quickthumb import (
Background,
BlendMode,
Canvas,
Deck,
Diagnostic,
Filter,
FitMode,
Expand All @@ -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 |
Expand Down
28 changes: 28 additions & 0 deletions docs/exports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions quickthumb/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -29,6 +30,8 @@

__all__ = [
"Canvas",
"Deck",
"DeckDiagnostic",
"QuickthumbError",
"RenderingError",
"ValidationError",
Expand Down
23 changes: 23 additions & 0 deletions quickthumb/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from PIL import ImageFont

from quickthumb.errors import ValidationError
from quickthumb.models import Align

FileFormat = Literal["JPEG", "WEBP", "PNG"]
Expand Down Expand Up @@ -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://")

Expand Down
39 changes: 31 additions & 8 deletions quickthumb/_export_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand All @@ -102,7 +126,6 @@ def _build(self, stream) -> None:
self._emit_layer(layer)

pdf.showPage()
pdf.save()

# ------------------------------------------------------------------ layers

Expand Down
Loading