Skip to content

Commit 65e2c46

Browse files
sjquantclaude
andauthored
✨ Add gradient and image-filled text (Feature 3) (#4)
* feat: add gradient and image-filled text (Feature 3) Adds the ability to fill text glyphs with LinearGradient, RadialGradient, or a TextFillImage instead of a flat color. - New `TextFillImage` model with `path` and `fit` fields - New `fill` field on `TextLayer` and `TextPart` - `Canvas.text()` accepts a `fill` parameter - Per-part fill overrides layer-level fill for that segment - Block-level fill for multiline text: gradient/image spans the entire text block rather than repeating per line - Full JSON round-trip support via Pydantic discriminated union - Effects (shadow, glow, stroke, background) compose correctly with fill - 25 new tests covering models, JSON serialization, and rendering https://claude.ai/code/session_01QK5iq4VqPsjWK8R9zJp1cg * fix: resolve review bugs in gradient/image-filled text (Feature 3) - Fix letter_spacing silently dropped when TextPart has both fill and letter_spacing set (_render_text_part now uses letter-spaced mask path) - Fix letter_spacing silently dropped in multiline fill path (_render_multiline_text now pre-computes spaced widths for block bounds and draws stroke/mask character-by-character when letter_spacing is set) - Add TextFillImage path validation in _validate_image_paths (checks layer fill and per-part fills before rendering) - Add rendering tests for TextFillImage and TextPart fill+letter_spacing https://claude.ai/code/session_01QK5iq4VqPsjWK8R9zJp1cg * fix: exclude fill=None from TextLayer/TextPart JSON serialization Adding fill to TextLayer and TextPart caused "fill": null to appear in all serialized text layers, breaking existing snapshot tests. Fix by adding a model_serializer to both classes that omits the fill key when it is None. JSON round-trips still work because fill defaults to None when the key is absent. Also reverts the snapshot changes in test_canvas.py and test_text_layers.py that were patching around the serialization noise. https://claude.ai/code/session_01QK5iq4VqPsjWK8R9zJp1cg * Revert "fix: exclude fill=None from TextLayer/TextPart JSON serialization" This reverts commit 1300bf4. * test: replace test_text_fill with rendering snapshots and validation tests - Delete tests/test_text_fill.py (model/rendering smoke tests that were too trivial to be worth maintaining separately) - Add 7 snapshot tests to test_rendering.py covering: linear gradient fill, radial gradient fill, image fill (TextFillImage), fill + stroke, fill + letter spacing, multiline block fill, and rich text per-part fill - Add TestTextFill class to test_text_layers.py with 8 validation and serialization tests: JSON serialization for all 3 fill types, null fill serialization, JSON round-trip, TextPart fill serialization, and FileNotFoundError for missing TextFillImage paths (layer-level and part-level) https://claude.ai/code/session_01QK5iq4VqPsjWK8R9zJp1cg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 84b2158 commit 65e2c46

59 files changed

Lines changed: 609 additions & 23 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

quickthumb/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
ShapeLayer,
1919
Stroke,
2020
TextEffect,
21+
TextFillImage,
2122
TextLayer,
2223
TextPart,
2324
)
@@ -44,6 +45,7 @@
4445
"ShapeLayer",
4546
"Stroke",
4647
"TextEffect",
48+
"TextFillImage",
4749
"TextLayer",
4850
"TextPart",
4951
]

quickthumb/canvas.py

Lines changed: 272 additions & 16 deletions
Large diffs are not rendered by default.

quickthumb/models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,17 @@ class RadialGradient(QuickThumbModel):
173173
center: tuple[float, float] = (0.5, 0.5)
174174

175175

176+
class TextFillImage(QuickThumbModel):
177+
type: Literal["image"] = "image"
178+
path: str
179+
fit: Annotated[
180+
FitMode, AfterValidator(lambda v: enum_converter(FitMode)(v) if v else FitMode.COVER)
181+
] = FitMode.COVER
182+
183+
184+
TextFill = Annotated[LinearGradient | RadialGradient | TextFillImage, Discriminator("type")]
185+
186+
176187
class Stroke(QuickThumbModel):
177188
type: Literal["stroke"] = "stroke"
178189
width: PositiveInt
@@ -262,6 +273,7 @@ def validate_saturation(cls, v: float) -> float:
262273
class TextPart(QuickThumbModel):
263274
text: str
264275
color: HexColor | None = None
276+
fill: TextFill | None = None
265277
effects: list[TextEffect] = []
266278
size: PositiveInt | None = None
267279
bold: bool | None = None
@@ -318,6 +330,7 @@ class TextLayer(QuickThumbModel):
318330
font: str | None = None
319331
size: PositiveInt | None = None
320332
color: HexColor | None = None
333+
fill: TextFill | None = None
321334
position: tuple | None = None
322335
align: AlignWithHVTuple = None
323336
bold: bool = False
-12 Bytes
-1 Bytes
9 Bytes
0 Bytes
72 Bytes
-8 Bytes
-3 Bytes

0 commit comments

Comments
 (0)