Skip to content

Commit 656c4d0

Browse files
authored
✨ Add Template System (Canvas.from_template + built-in templates) (#1)
1 parent eeb0397 commit 656c4d0

8 files changed

Lines changed: 486 additions & 21 deletions

File tree

quickthumb/canvas.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ class CustomLayer:
8383

8484
class Canvas:
8585
_custom_layer_registry: dict[str, Callable[..., Image.Image | None]] = {}
86+
_template_registry: dict[str, str] = {}
87+
88+
_BUILTIN_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
8689

8790
@classmethod
8891
def register_layer_fn(cls, name: str, fn: Callable[..., Image.Image | None]) -> None:
@@ -92,6 +95,14 @@ def register_layer_fn(cls, name: str, fn: Callable[..., Image.Image | None]) ->
9295
def unregister_layer_fn(cls, name: str) -> None:
9396
cls._custom_layer_registry.pop(name, None)
9497

98+
@classmethod
99+
def register_template(cls, name: str, path: str) -> None:
100+
cls._template_registry[name] = path
101+
102+
@classmethod
103+
def unregister_template(cls, name: str) -> None:
104+
cls._template_registry.pop(name, None)
105+
95106
def __init__(self, width: int, height: int, layers: list[RenderableLayer] | None = None):
96107
if width <= 0:
97108
raise ValidationError("width must be > 0")
@@ -352,6 +363,49 @@ def from_json(cls, data: str) -> Self:
352363

353364
return cls(width=raw["width"], height=raw["height"], layers=renderable_layers)
354365

366+
@classmethod
367+
def _read_template_file(cls, path: str) -> str:
368+
try:
369+
with open(path) as f:
370+
return f.read()
371+
except OSError as e:
372+
raise ValidationError(f"Cannot read template file '{path}': {e}") from e
373+
374+
@classmethod
375+
def _resolve_template_string(cls, spec_or_path: str) -> str:
376+
if spec_or_path.lstrip().startswith("{"):
377+
return spec_or_path
378+
if spec_or_path in cls._template_registry:
379+
return cls._read_template_file(cls._template_registry[spec_or_path])
380+
builtin_path = os.path.join(cls._BUILTIN_TEMPLATES_DIR, f"{spec_or_path}.json")
381+
if os.path.exists(builtin_path):
382+
return cls._read_template_file(builtin_path)
383+
if os.path.exists(spec_or_path):
384+
return cls._read_template_file(spec_or_path)
385+
raise ValidationError(
386+
f"Template '{spec_or_path}' is not a registered template name, "
387+
f"built-in template name, or valid file path."
388+
)
389+
390+
@classmethod
391+
def from_template(cls, spec_or_path: str, variables: dict[str, str] | None = None) -> Self:
392+
import json as _json
393+
import re
394+
395+
variables = variables or {}
396+
raw_spec = cls._resolve_template_string(spec_or_path)
397+
398+
def substitute(match: re.Match) -> str:
399+
key = match.group(1) or match.group(2)
400+
if key not in variables:
401+
raise ValidationError(
402+
f"Template placeholder '${key}' has no matching variable. "
403+
f"Provide variables={{'{key}': ...}} to Canvas.from_template()."
404+
)
405+
return _json.dumps(variables[key])[1:-1]
406+
407+
return cls.from_json(re.sub(r"\$\{(\w+)\}|\$(\w+)", substitute, raw_spec))
408+
355409
def to_base64(self, format: FileFormat = "PNG", quality: int | None = None) -> str:
356410
import base64
357411

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"width": 1080,
3+
"height": 1080,
4+
"layers": [
5+
{
6+
"type": "background",
7+
"color": "#111827"
8+
},
9+
{
10+
"type": "text",
11+
"content": "$title",
12+
"size": 88,
13+
"color": "#FFFFFF",
14+
"position": ["50%", "50%"],
15+
"align": ["center", "middle"]
16+
}
17+
]
18+
}

quickthumb/templates/og-image.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"width": 1200,
3+
"height": 630,
4+
"layers": [
5+
{
6+
"type": "background",
7+
"color": "#111827"
8+
},
9+
{
10+
"type": "text",
11+
"content": "$title",
12+
"size": 72,
13+
"color": "#FFFFFF",
14+
"position": ["50%", "50%"],
15+
"align": ["center", "middle"]
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"width": 1200,
3+
"height": 600,
4+
"layers": [
5+
{
6+
"type": "background",
7+
"color": "#111827"
8+
},
9+
{
10+
"type": "text",
11+
"content": "$title",
12+
"size": 72,
13+
"color": "#FFFFFF",
14+
"position": ["50%", "50%"],
15+
"align": ["center", "middle"]
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"width": 1280,
3+
"height": 720,
4+
"layers": [
5+
{
6+
"type": "background",
7+
"color": "#111827"
8+
},
9+
{
10+
"type": "text",
11+
"content": "$title",
12+
"size": 88,
13+
"color": "#FFFFFF",
14+
"position": ["8%", "50%"],
15+
"align": ["left", "middle"]
16+
}
17+
]
18+
}

specs/TASKS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424

2525
#### Template System
2626

27-
- [TODO] Implement `Canvas.from_template(spec_or_path, variables={})` with `$var` / `${var}` string substitution
28-
- [TODO] Raise `ValidationError` on unresolved placeholders before JSON parsing
29-
- [TODO] Add `Canvas.register_template(name, path)` and `Canvas.unregister_template(name)` registry
30-
- [TODO] Create `quickthumb/templates/` directory with starter templates: `youtube-16x9`, `instagram-square`, `twitter-card`, `og-image`
27+
- [DONE] Implement `Canvas.from_template(spec_or_path, variables={})` with `$var` / `${var}` string substitution
28+
- [DONE] Raise `ValidationError` on unresolved placeholders before JSON parsing
29+
- [DONE] Add `Canvas.register_template(name, path)` and `Canvas.unregister_template(name)` registry
30+
- [DONE] Create `quickthumb/templates/` directory with starter templates: `youtube-16x9`, `instagram-square`, `twitter-card`, `og-image`
3131

3232
#### Gradient / Image-Filled Text (Knockout Text)
3333

0 commit comments

Comments
 (0)