Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs/api/canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ for finding in canvas.diagnose():
print(finding.severity, finding.code, finding.message)
```

Finding codes: `off-canvas`, `tiny-text`, `text-overflow`, `low-contrast`. See [Diagnostics & CLI](../diagnostics.md) for details and the `quickthumb lint` equivalent.
Finding codes: `off-canvas`, `tiny-text`, `text-overflow`, `text-clipped`, `missing-glyph`,
`low-contrast`, `layer-overlap`, `layer-hidden`, and `edge-crowding`. See
[Diagnostics & CLI](../diagnostics.md) for details and the `quickthumb lint` (or
`quickthumb diagnose`) equivalent.

## Export methods

Expand Down
8 changes: 7 additions & 1 deletion docs/api/deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,14 +183,20 @@ for finding in deck.diagnose():
| `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 |
| `layer_id` | `str \| None` | Stable originating layer id, including group-child ids |
| `layer_name` | `str \| None` | Originating layer name when present |
| `bbox` | `dict \| None` | Measured canvas-space bounding box |
| `related_layers` | `list[str]` | Layer ids involved in the finding |
| `measured` | `dict` | Rule-specific measured values |
| `suggestion` | `str \| None` | Repair hint when available |

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. Per-slide `transition`, `audio`, `duration`, and `notes` fields are preserved alongside the canvas fields. A top-level `theme` is shared with every slide so slides can use `$theme.*` tokens, exactly like `Canvas.from_json`.
Round-trips the deck through JSON, reusing each canvas's serialization. The shape is `{"kind": "deck", "width": ..., "height": ..., "theme": {...}, "slides": [<canvas spec>, ...]}`, where `width`/`height` (the default slide size) and `theme` are emitted only when set. Per-slide `transition`, `audio`, `duration`, and `notes` fields are preserved alongside the canvas fields. 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()
Expand Down
3 changes: 2 additions & 1 deletion docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ JSON specs can define brand tokens once in a top-level `theme` block and referen

```json
{
"kind": "canvas",
"width": 1280,
"height": 720,
"theme": { "colors": { "primary": "#B8FF00" }, "sizes": { "title": 96 } },
Expand All @@ -284,7 +285,7 @@ Tokens are resolved at parse time; unknown tokens raise `ValidationError`. See [

## Diagnostics

`canvas.diagnose()` checks a composition for common problems — layers outside the canvas, illegibly small text, unwrappable words, and low text contrast — without writing a file. The CLI equivalent is `quickthumb lint spec.json`. See [Diagnostics & CLI](diagnostics.md).
`canvas.diagnose()` checks a composition for common problems — layers outside the canvas, illegibly small text, unwrappable or clipped text, missing glyphs, hidden or overlapping layers, safe-area crowding, and low text contrast — without writing a file. The CLI equivalents are `quickthumb lint spec.json` and `quickthumb diagnose spec.json`. See [Diagnostics & CLI](diagnostics.md).

## JSON round-trip

Expand Down
2 changes: 1 addition & 1 deletion docs/cookbook/ai-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ quickthumb is designed to be a reliable target for LLM-generated image specs. Th
Generate a quickthumb JSON config for a 1280×720 YouTube thumbnail.

Schema rules:
- Top-level fields: "width", "height", "layers"
- Top-level fields: "kind": "canvas", "width", "height", "layers"
- Every layer must have a "type": "background" | "text" | "image" | "shape" | "outline"
- Every effect must have a "type": "stroke" | "shadow" | "glow" | "filter" | "background"
- Positions are [x, y] JSON arrays — values can be integers (px) or percentage strings ("50%")
Expand Down
1 change: 1 addition & 0 deletions docs/cookbook/shorts-cover.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ That's it. The entire composition lives in the JSON file.

```json
{
"kind": "canvas",
"width": 1080,
"height": 1920,
"layers": [
Expand Down
25 changes: 22 additions & 3 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Each `Diagnostic` has stable human-readable fields and optional structured field

| Field | Type | Description |
| --- | --- | --- |
| `code` | `str` | One of `"off-canvas"`, `"tiny-text"`, `"text-overflow"`, `"text-clipped"`, `"missing-glyph"`, `"low-contrast"`, `"layer-overlap"`, `"layer-hidden"`, `"edge-crowding"`, `"near-alignment"` |
| `code` | `str` | One of `"off-canvas"`, `"tiny-text"`, `"text-overflow"`, `"text-clipped"`, `"missing-glyph"`, `"low-contrast"`, `"layer-overlap"`, `"layer-hidden"`, `"edge-crowding"`, or `"near-alignment"` |
| `severity` | `str` | `"warning"` or `"error"` |
| `layer_index` | `int` | Index of the offending layer in `canvas.layers` |
| `message` | `str` | Human-readable explanation with the measured values |
Expand Down Expand Up @@ -72,6 +72,7 @@ Checks a JSON spec for the same findings as `diagnose()`:
```bash
quickthumb lint spec.json
quickthumb lint spec.json --format json
quickthumb diagnose spec.json --format json
```

```text
Expand Down Expand Up @@ -160,6 +161,23 @@ Exit codes make it easy to gate CI or agent pipelines:
| `2` | Rendering failure |
| `3` | Findings reported |

`diagnose` is an alias for `lint`. Both commands accept the same automation options:

```bash
quickthumb lint spec.json --fail-on error
quickthumb lint spec.json --ignore edge-crowding --ignore missing-glyph
```

`--fail-on warning` (the default) exits 3 for any remaining finding. `--fail-on error`
allows warnings while still failing on errors, and `--fail-on never` always exits 0 after
printing the findings. `--ignore` removes matching diagnostic codes from both the output and
the exit-status calculation. Invalid specs with `--format json` emit an `error` object rather
than a traceback.

JSON inputs must declare a top-level `kind` discriminator (`canvas` or `deck`). Deck findings
include `slide_index` and retain
the originating layer id, bounding box, measurements, related layers, and suggestion.

### `quickthumb render`

Renders a JSON spec to an image file:
Expand Down Expand Up @@ -201,8 +219,9 @@ quickthumb serve deck.json --port 4040 --no-open
```

A Python source should define one module-level `Deck` or `Canvas` as `deck`,
`slides`, or `canvas`. A JSON source may be a canvas spec or a deck object with a
top-level `slides` list. Use `--host` and `--port` to change the listening
`slides`, or `canvas`. A JSON source must declare `kind: "canvas"` or `kind: "deck"`;
Canvas documents contain `layers`, while Deck documents contain a top-level `slides` list.
Use `--host` and `--port` to change the listening
address, `--no-open` to suppress the initial browser window, and repeat
`--var KEY=VALUE` for JSON template substitutions.

Expand Down
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A child's `align` is also ignored; use the group's `item_align` for cross-axis p

### How do I check a composition for problems before rendering?

Call `canvas.diagnose()` (or run `quickthumb lint spec.json`). It reports off-canvas layers, tiny text, unwrappable words, and low text contrast. See [Diagnostics & CLI](diagnostics.md).
Call `canvas.diagnose()` (or run `quickthumb lint spec.json` / `quickthumb diagnose spec.json`). It reports off-canvas layers, tiny or clipped text, unwrappable words, missing glyphs, hidden or overlapping layers, safe-area crowding, and low text contrast. See [Diagnostics & CLI](diagnostics.md).

---

Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ from quickthumb import Canvas

config = """
{
"kind": "canvas",
"width": 1280,
"height": 720,
"layers": [
Expand Down
26 changes: 20 additions & 6 deletions docs/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,22 @@ canvas = Canvas.from_json(json_str)

## JSON structure

A quickthumb JSON document has three required top-level fields, plus an optional `theme` block:
A Canvas JSON document has four required top-level fields, plus an optional `theme` block:

```json
{
"kind": "canvas",
"width": 1280,
"height": 720,
"theme": { ... },
"layers": [ ... ]
}
```

Every layer object requires a `"type"` discriminator field. Layers render in array order — first item is backmost.
The top-level `"kind"` discriminator is required by the CLI and must be `"canvas"` or `"deck"`.
Canvas documents carry `layers`; Deck documents carry `slides` and may also define a shared
default size, theme, and transitions. Every layer object requires its own `"type"`
discriminator field. Layers render in array order — first item is backmost.

## Published schema

Expand All @@ -46,18 +50,27 @@ Emit the current JSON Schema from the same Pydantic models used by `Canvas.from_
```bash
quickthumb schema > quickthumb.schema.json
quickthumb schema --output quickthumb.schema.json
quickthumb schema --document --output quickthumb.document-schema.json
```

The same schemas are available from Python as `canvas_json_schema()` and
`document_json_schema()`.

!!! note "Schema scope"
`quickthumb schema` describes concrete quickthumb specs for external tooling
and constrained generation. `Canvas.from_json()` remains the source of truth
`quickthumb schema` describes concrete Canvas specs for external tooling and
constrained generation. Use `quickthumb schema --document` for the combined
Canvas/Deck discriminator schema. `Canvas.from_json()` remains the source of truth
for what quickthumb can load.

The CLI and `serve` command require the top-level `kind` discriminator. The
direct `Canvas.from_json()` and `Deck.from_json()` APIs continue to accept
legacy strings without it; newly serialized documents always include `kind`.

Authoring conveniences such as `$theme.*` are resolved by quickthumb before
model validation, so generic JSON Schema validators may reject unresolved
authoring specs that quickthumb itself can load.

The command writes deterministic JSON only, so it can be checked into a repo, piped into an editor, or passed directly to a constrained-generation API. The schema includes the current canvas fields, built-in layer discriminators, effects, animations, supported platform presets, and the optional top-level `theme` block.
The command writes deterministic JSON only, so it can be checked into a repo, piped into an editor, or passed directly to a constrained-generation API. The Canvas schema includes the current canvas fields, built-in layer discriminators, effects, animations, supported platform presets, and the optional top-level `theme` block. The `--document` schema adds the Deck root and maps both document kinds through `kind`.

For constrained generation, prefer concrete resolved values in typed fields:

Expand All @@ -75,6 +88,7 @@ Define brand tokens once in a top-level `theme` block and reference them anywher

```json
{
"kind": "canvas",
"width": 1280,
"height": 720,
"theme": {
Expand Down Expand Up @@ -521,7 +535,7 @@ quickthumb JSON is well-suited for LLM generation because the schema is flat, ev
Generate a quickthumb JSON config for a 1280×720 YouTube thumbnail.

Rules:
- Top-level fields: "width", "height", "layers" (optional "theme" for shared tokens)
- Top-level fields: "kind": "canvas", "width", "height", "layers" (optional "theme" for shared tokens)
- Every layer must have a "type" field: "background", "text", "image", "shape", "svg", "group", or "outline"
- Every effect must have a "type" field: "stroke", "shadow", "glow", "filter", "background", or "grain"
- Positions are [x, y] arrays — values can be integers (px) or percentage strings like "50%"
Expand Down
1 change: 1 addition & 0 deletions examples/launch_announcement.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"kind": "canvas",
"width": 1280,
"height": 720,
"theme": {
Expand Down
1 change: 1 addition & 0 deletions examples/shorts_cover_agent.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"kind": "canvas",
"width": 1080,
"height": 1920,
"layers": [
Expand Down
3 changes: 2 additions & 1 deletion quickthumb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
Wheel,
Wipe,
)
from quickthumb.schema import canvas_json_schema
from quickthumb.schema import canvas_json_schema, document_json_schema
from quickthumb.transitions import Transition

__all__ = [
Expand Down Expand Up @@ -126,5 +126,6 @@
"VideoOptions",
"Transition",
"canvas_json_schema",
"document_json_schema",
"transitions",
]
23 changes: 18 additions & 5 deletions quickthumb/_diagnostics.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from collections.abc import Iterable
from itertools import islice
from math import ceil
Expand Down Expand Up @@ -91,9 +92,19 @@ def diagnose(self) -> list[Diagnostic]:
"""Check layers for layout and legibility issues without producing an output file.

Returns structured findings (off-canvas, tiny-text, text-overflow,
low-contrast, layer-overlap, near-alignment) that an agent or human can
act on before rendering.
text-clipped, missing-glyph, low-contrast, layer-overlap, layer-hidden,
edge-crowding, near-alignment) that an agent or human can act on before
rendering.
"""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"Word .* exceeds max_width and cannot be wrapped\.",
category=UserWarning,
)
return self._diagnose_impl()

def _diagnose_impl(self) -> list[Diagnostic]:
self._alpha_cache.clear()
self._canvas._validate_image_paths()
self._ctx.begin_render_pass()
Expand Down Expand Up @@ -123,10 +134,8 @@ def diagnose(self) -> list[Diagnostic]:
diagnostics.extend(self._diagnose_hidden_layers(measurements))
edge_ignored_layer_ids = set()
for finding in diagnostics:
if finding.code == "off-canvas" and finding.layer_id is not None:
if finding.code in {"off-canvas", "layer-hidden"} and finding.layer_id is not None:
edge_ignored_layer_ids.add(finding.layer_id)
elif finding.code == "layer-hidden":
edge_ignored_layer_ids.update(finding.related_layers)
diagnostics.extend(
self._diagnose_edge_crowding(measurements, ignored_layer_ids=edge_ignored_layer_ids)
)
Expand Down Expand Up @@ -539,6 +548,10 @@ def _render_layer_alpha_channel(self, measured: LayerMeasurement) -> Image.Image
box = require_bbox(measured)
image = Image.new("RGBA", (self._ctx.width, self._ctx.height), (0, 0, 0, 0))
layer = measured.raw_layer
if isinstance(layer, GroupLayer):
origin = measured.metadata.get("position")
if isinstance(origin, tuple):
layer = layer.model_copy(update={"position": origin, "align": None})
if not isinstance(layer, (GroupLayer, TextLayer, ImageLayer, SvgLayer, ShapeLayer)):
return Image.new("L", (box.width, box.height), 0)
self._canvas._render_layer(image, layer)
Expand Down
37 changes: 37 additions & 0 deletions quickthumb/_document.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Shared validation for top-level JSON document discriminators."""

from __future__ import annotations

import json
from typing import Literal, cast

from quickthumb.canvas import Canvas
from quickthumb.deck import Deck
from quickthumb.errors import ValidationError

DocumentKind = Literal["canvas", "deck"]


def require_document_kind(raw: object) -> DocumentKind:
"""Validate and return the top-level kind of a JSON document."""
if not isinstance(raw, dict):
raise ValidationError("JSON document must be an object with a 'kind' field.")

document = cast(dict[str, object], raw)
kind = document.get("kind")
if kind not in ("canvas", "deck"):
raise ValidationError("JSON document 'kind' must be either 'canvas' or 'deck'.")
if kind == "canvas" and "slides" in raw:
raise ValidationError("Canvas JSON must not contain a top-level 'slides' field.")
if kind == "deck" and "layers" in raw:
raise ValidationError("Deck JSON must not contain a top-level 'layers' field.")
return cast(DocumentKind, kind)


def load_document(text: str) -> Canvas | Deck:
"""Parse a discriminated Canvas or Deck JSON document."""
raw = json.loads(text)
kind = require_document_kind(raw)
if kind == "deck":
return Deck.from_json(text)
return Canvas.from_json(text)
7 changes: 2 additions & 5 deletions quickthumb/_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import json
import re
import runpy
import sys
Expand All @@ -20,6 +19,7 @@

from jinja2 import Environment, Template

from quickthumb._document import load_document
from quickthumb.canvas import _VAR_RE, Canvas, _is_theme_reference
from quickthumb.deck import Deck
from quickthumb.errors import ValidationError
Expand Down Expand Up @@ -287,10 +287,7 @@ def _render_json(self) -> str:
text = self.path.read_text(encoding="utf-8")
if self.variables:
text = _substitute_variables(text, self.variables)
payload = json.loads(text)
if isinstance(payload, dict) and "slides" in payload:
return Deck.from_json(text).to_html()
return Canvas.from_json(text).to_html()
return load_document(text).to_html()

def version(self) -> str:
"""Return a cheap change token used by the browser reload poller."""
Expand Down
20 changes: 20 additions & 0 deletions quickthumb/_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Shared validation helpers for public document models."""

from __future__ import annotations

from typing import Any

from quickthumb.errors import ValidationError


def validate_dimensions(width: Any, height: Any) -> None:
"""Validate an optional width/height pair used by Canvas and Deck."""
for name, value in (("width", width), ("height", height)):
if value is not None and (isinstance(value, bool) or not isinstance(value, int)):
raise ValidationError(f"{name} must be an integer")
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")
Loading