quickthumb 0.2.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. quickthumb-0.3.1/.claude/skills/tdd/SKILL.md +29 -0
  2. quickthumb-0.3.1/.github/workflows/docs.yml +26 -0
  3. {quickthumb-0.2.0 → quickthumb-0.3.1}/.gitignore +6 -0
  4. quickthumb-0.3.1/CLAUDE.md +30 -0
  5. quickthumb-0.3.1/DESIGN.md +573 -0
  6. quickthumb-0.3.1/PKG-INFO +337 -0
  7. quickthumb-0.3.1/PROGRESS.md +125 -0
  8. quickthumb-0.3.1/README.md +306 -0
  9. quickthumb-0.3.1/assets/images/denise-jans-WIRvXd1PYlg-unsplash.jpg +0 -0
  10. quickthumb-0.3.1/assets/images/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
  11. quickthumb-0.3.1/docs/api/background.md +78 -0
  12. quickthumb-0.3.1/docs/api/canvas.md +141 -0
  13. quickthumb-0.3.1/docs/api/effects.md +195 -0
  14. quickthumb-0.3.1/docs/api/enums.md +194 -0
  15. quickthumb-0.3.1/docs/api/image.md +131 -0
  16. quickthumb-0.3.1/docs/api/index.md +43 -0
  17. quickthumb-0.3.1/docs/api/outline.md +61 -0
  18. quickthumb-0.3.1/docs/api/shape.md +110 -0
  19. quickthumb-0.3.1/docs/api/text.md +194 -0
  20. quickthumb-0.3.1/docs/assets/examples/instagram_news_card.png +0 -0
  21. quickthumb-0.3.1/docs/assets/examples/podcast_interview_promo.png +0 -0
  22. quickthumb-0.3.1/docs/assets/examples/shorts_cover_agent.png +0 -0
  23. quickthumb-0.3.1/docs/assets/examples/youtube_reaction.png +0 -0
  24. quickthumb-0.3.1/docs/assets/examples/youtube_talking_head.png +0 -0
  25. quickthumb-0.3.1/docs/assets/examples/youtube_thumbnail_02.png +0 -0
  26. quickthumb-0.3.1/docs/assets/examples/youtube_tutorial_explainer.png +0 -0
  27. quickthumb-0.3.1/docs/changelog.md +5 -0
  28. quickthumb-0.3.1/docs/concepts.md +255 -0
  29. quickthumb-0.3.1/docs/cookbook/ai-workflow.md +182 -0
  30. quickthumb-0.3.1/docs/cookbook/index.md +46 -0
  31. quickthumb-0.3.1/docs/cookbook/instagram-card.md +129 -0
  32. quickthumb-0.3.1/docs/cookbook/podcast-promo.md +214 -0
  33. quickthumb-0.3.1/docs/cookbook/shorts-cover.md +208 -0
  34. quickthumb-0.3.1/docs/cookbook/webfonts-rembg.md +199 -0
  35. quickthumb-0.3.1/docs/cookbook/youtube-thumbnail.md +348 -0
  36. quickthumb-0.3.1/docs/faq.md +170 -0
  37. quickthumb-0.3.1/docs/getting-started.md +206 -0
  38. quickthumb-0.3.1/docs/index.md +59 -0
  39. quickthumb-0.3.1/docs/installation.md +56 -0
  40. quickthumb-0.3.1/docs/json-schema.md +365 -0
  41. quickthumb-0.3.1/docs/stylesheets/extra.css +83 -0
  42. quickthumb-0.3.1/examples/README.md +155 -0
  43. quickthumb-0.3.1/examples/instagram_news_card.png +0 -0
  44. quickthumb-0.3.1/examples/instagram_news_card.py +94 -0
  45. quickthumb-0.3.1/examples/podcast_interview_promo.png +0 -0
  46. quickthumb-0.3.1/examples/podcast_interview_promo.py +146 -0
  47. quickthumb-0.3.1/examples/shorts_cover_agent.json +301 -0
  48. quickthumb-0.3.1/examples/shorts_cover_agent.png +0 -0
  49. quickthumb-0.3.1/examples/shorts_cover_agent.py +33 -0
  50. quickthumb-0.3.1/examples/youtube_reaction.png +0 -0
  51. quickthumb-0.3.1/examples/youtube_reaction.py +103 -0
  52. quickthumb-0.3.1/examples/youtube_talking_head.png +0 -0
  53. quickthumb-0.3.1/examples/youtube_talking_head.py +102 -0
  54. quickthumb-0.3.1/examples/youtube_thumbnail_01.png +0 -0
  55. {quickthumb-0.2.0 → quickthumb-0.3.1}/examples/youtube_thumbnail_01.py +11 -5
  56. quickthumb-0.3.1/examples/youtube_thumbnail_02.png +0 -0
  57. quickthumb-0.3.1/examples/youtube_thumbnail_02.py +109 -0
  58. quickthumb-0.3.1/examples/youtube_tutorial_explainer.png +0 -0
  59. quickthumb-0.3.1/examples/youtube_tutorial_explainer.py +136 -0
  60. quickthumb-0.3.1/mkdocs.yml +87 -0
  61. {quickthumb-0.2.0 → quickthumb-0.3.1}/pyproject.toml +39 -2
  62. {quickthumb-0.2.0 → quickthumb-0.3.1}/quickthumb/__init__.py +16 -0
  63. quickthumb-0.3.1/quickthumb/canvas.py +2406 -0
  64. quickthumb-0.3.1/quickthumb/errors.py +19 -0
  65. quickthumb-0.3.1/quickthumb/models.py +497 -0
  66. quickthumb-0.3.1/tests/fixtures/ivana-cajina-_7LbC5J-jw4-unsplash.jpg +0 -0
  67. quickthumb-0.3.1/tests/fixtures/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
  68. quickthumb-0.3.1/tests/snapshots/auto_scale_rich_text.png +0 -0
  69. quickthumb-0.3.1/tests/snapshots/auto_scale_simple_text.png +0 -0
  70. quickthumb-0.3.1/tests/snapshots/background_blur_heavy.png +0 -0
  71. quickthumb-0.3.1/tests/snapshots/background_blur_soft.png +0 -0
  72. quickthumb-0.3.1/tests/snapshots/background_combined_filters.png +0 -0
  73. quickthumb-0.3.1/tests/snapshots/background_contrast_high.png +0 -0
  74. quickthumb-0.3.1/tests/snapshots/background_contrast_low.png +0 -0
  75. quickthumb-0.3.1/tests/snapshots/background_filter_effect.png +0 -0
  76. quickthumb-0.3.1/tests/snapshots/background_saturation_grayscale.png +0 -0
  77. quickthumb-0.3.1/tests/snapshots/background_saturation_vivid.png +0 -0
  78. quickthumb-0.3.1/tests/snapshots/custom_canvas_layer.png +0 -0
  79. quickthumb-0.3.1/tests/snapshots/explicit_newlines_preserved_with_max_width.png +0 -0
  80. quickthumb-0.3.1/tests/snapshots/image_basic.png +0 -0
  81. quickthumb-0.3.1/tests/snapshots/image_blend_mode_darken.png +0 -0
  82. quickthumb-0.3.1/tests/snapshots/image_blend_mode_lighten.png +0 -0
  83. quickthumb-0.3.1/tests/snapshots/image_blend_mode_multiply.png +0 -0
  84. quickthumb-0.3.1/tests/snapshots/image_blend_mode_normal.png +0 -0
  85. quickthumb-0.3.1/tests/snapshots/image_blend_mode_overlay.png +0 -0
  86. quickthumb-0.3.1/tests/snapshots/image_blend_mode_screen.png +0 -0
  87. quickthumb-0.3.1/tests/snapshots/image_filter_effect.png +0 -0
  88. quickthumb-0.3.1/tests/snapshots/image_on_background_image.png +0 -0
  89. quickthumb-0.3.1/tests/snapshots/image_overlay_fit_contain.png +0 -0
  90. quickthumb-0.3.1/tests/snapshots/image_overlay_fit_cover.png +0 -0
  91. quickthumb-0.3.1/tests/snapshots/image_overlay_fit_fill.png +0 -0
  92. quickthumb-0.3.1/tests/snapshots/image_percentage_position.png +0 -0
  93. quickthumb-0.3.1/tests/snapshots/image_with_border_radius.png +0 -0
  94. quickthumb-0.3.1/tests/snapshots/image_with_drop_shadow.png +0 -0
  95. quickthumb-0.3.1/tests/snapshots/image_with_glow.png +0 -0
  96. quickthumb-0.3.1/tests/snapshots/image_with_opacity.png +0 -0
  97. quickthumb-0.3.1/tests/snapshots/image_with_remove_background.png +0 -0
  98. quickthumb-0.3.1/tests/snapshots/image_with_rotation.png +0 -0
  99. quickthumb-0.3.1/tests/snapshots/image_with_stroke.png +0 -0
  100. quickthumb-0.3.1/tests/snapshots/image_with_text_overlay.png +0 -0
  101. quickthumb-0.3.1/tests/snapshots/linear_gradient_with_alpha.png +0 -0
  102. quickthumb-0.3.1/tests/snapshots/multiline_text_with_background.png +0 -0
  103. quickthumb-0.3.1/tests/snapshots/multiline_text_with_glow.png +0 -0
  104. quickthumb-0.3.1/tests/snapshots/multiline_text_with_shadow.png +0 -0
  105. quickthumb-0.3.1/tests/snapshots/multiple_images.png +0 -0
  106. quickthumb-0.3.1/tests/snapshots/rich_text_with_background.png +0 -0
  107. quickthumb-0.3.1/tests/snapshots/shape_ellipse_basic.png +0 -0
  108. quickthumb-0.3.1/tests/snapshots/shape_rectangle_basic.png +0 -0
  109. quickthumb-0.3.1/tests/snapshots/shape_rectangle_with_border_radius.png +0 -0
  110. quickthumb-0.3.1/tests/snapshots/shape_with_effects.png +0 -0
  111. quickthumb-0.3.1/tests/snapshots/shape_with_opacity.png +0 -0
  112. quickthumb-0.3.1/tests/snapshots/shape_with_rotation.png +0 -0
  113. quickthumb-0.3.1/tests/snapshots/text_and_outline_with_opacity.png +0 -0
  114. quickthumb-0.3.1/tests/snapshots/text_rotation_basic.png +0 -0
  115. quickthumb-0.3.1/tests/snapshots/text_rotation_centered.png +0 -0
  116. quickthumb-0.3.1/tests/snapshots/text_rotation_with_effects.png +0 -0
  117. quickthumb-0.3.1/tests/snapshots/text_with_background_and_effects.png +0 -0
  118. quickthumb-0.3.1/tests/snapshots/text_with_background_basic.png +0 -0
  119. quickthumb-0.3.1/tests/snapshots/text_with_letter_spacing_glow.png +0 -0
  120. quickthumb-0.3.1/tests/snapshots/text_with_letter_spacing_shadow.png +0 -0
  121. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/test_background_layers.py +53 -44
  122. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/test_canvas.py +92 -146
  123. quickthumb-0.3.1/tests/test_image_layers.py +555 -0
  124. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/test_outline_layers.py +5 -4
  125. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/test_rendering.py +1148 -9
  126. quickthumb-0.3.1/tests/test_shape_layers.py +280 -0
  127. quickthumb-0.3.1/tests/test_text_layers.py +1449 -0
  128. quickthumb-0.3.1/uv.lock +2042 -0
  129. quickthumb-0.2.0/.claude/agents/tdd-agent.md +0 -10
  130. quickthumb-0.2.0/CLAUDE.md +0 -57
  131. quickthumb-0.2.0/DESIGN.md +0 -480
  132. quickthumb-0.2.0/PKG-INFO +0 -286
  133. quickthumb-0.2.0/PROGRESS.md +0 -79
  134. quickthumb-0.2.0/README.md +0 -273
  135. quickthumb-0.2.0/examples/README.md +0 -18
  136. quickthumb-0.2.0/quickthumb/canvas.py +0 -1362
  137. quickthumb-0.2.0/quickthumb/errors.py +0 -10
  138. quickthumb-0.2.0/quickthumb/models.py +0 -379
  139. quickthumb-0.2.0/tests/test_text_effects.py +0 -413
  140. quickthumb-0.2.0/tests/test_text_layers.py +0 -613
  141. quickthumb-0.2.0/uv.lock +0 -626
  142. {quickthumb-0.2.0 → quickthumb-0.3.1}/.claude/settings.json +0 -0
  143. {quickthumb-0.2.0 → quickthumb-0.3.1}/.github/workflows/publish.yaml +0 -0
  144. {quickthumb-0.2.0 → quickthumb-0.3.1}/.vscode/extensions.json +0 -0
  145. {quickthumb-0.2.0 → quickthumb-0.3.1}/.vscode/settings.json +0 -0
  146. {quickthumb-0.2.0 → quickthumb-0.3.1}/LICENSE +0 -0
  147. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Black.ttf +0 -0
  148. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-BlackItalic.ttf +0 -0
  149. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Bold.ttf +0 -0
  150. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-BoldItalic.ttf +0 -0
  151. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-ExtraBold.ttf +0 -0
  152. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-ExtraBoldItalic.ttf +0 -0
  153. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-ExtraLight.ttf +0 -0
  154. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-ExtraLightItalic.ttf +0 -0
  155. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Italic.ttf +0 -0
  156. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Light.ttf +0 -0
  157. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-LightItalic.ttf +0 -0
  158. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Medium.ttf +0 -0
  159. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-MediumItalic.ttf +0 -0
  160. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Regular.ttf +0 -0
  161. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-SemiBold.ttf +0 -0
  162. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-SemiBoldItalic.ttf +0 -0
  163. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-Thin.ttf +0 -0
  164. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSans-ThinItalic.ttf +0 -0
  165. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Black.ttf +0 -0
  166. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-BlackItalic.ttf +0 -0
  167. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Bold.ttf +0 -0
  168. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-BoldItalic.ttf +0 -0
  169. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-ExtraBold.ttf +0 -0
  170. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-ExtraBoldItalic.ttf +0 -0
  171. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-ExtraLight.ttf +0 -0
  172. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-ExtraLightItalic.ttf +0 -0
  173. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Italic.ttf +0 -0
  174. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Light.ttf +0 -0
  175. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-LightItalic.ttf +0 -0
  176. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Medium.ttf +0 -0
  177. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-MediumItalic.ttf +0 -0
  178. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Regular.ttf +0 -0
  179. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-SemiBold.ttf +0 -0
  180. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-SemiBoldItalic.ttf +0 -0
  181. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-Thin.ttf +0 -0
  182. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/NotoSerif-ThinItalic.ttf +0 -0
  183. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Black.ttf +0 -0
  184. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-BlackItalic.ttf +0 -0
  185. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Bold.ttf +0 -0
  186. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-BoldItalic.ttf +0 -0
  187. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-ExtraBold.ttf +0 -0
  188. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-ExtraBoldItalic.ttf +0 -0
  189. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-ExtraLight.ttf +0 -0
  190. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-ExtraLightItalic.ttf +0 -0
  191. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Italic.ttf +0 -0
  192. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Light.ttf +0 -0
  193. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-LightItalic.ttf +0 -0
  194. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Medium.ttf +0 -0
  195. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-MediumItalic.ttf +0 -0
  196. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Regular.ttf +0 -0
  197. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-SemiBold.ttf +0 -0
  198. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-SemiBoldItalic.ttf +0 -0
  199. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-Thin.ttf +0 -0
  200. {quickthumb-0.2.0 → quickthumb-0.3.1}/assets/fonts/Roboto-ThinItalic.ttf +0 -0
  201. {quickthumb-0.2.0/examples/assets → quickthumb-0.3.1/assets/images}/c-g-JgDUVGAXsso-unsplash.jpg +0 -0
  202. {quickthumb-0.2.0 → quickthumb-0.3.1/docs/assets}/examples/youtube_thumbnail_01.png +0 -0
  203. {quickthumb-0.2.0 → quickthumb-0.3.1}/quickthumb/font_cache.py +0 -0
  204. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/__init__.py +0 -0
  205. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/conftest.py +0 -0
  206. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/fixtures/sample_image.jpg +0 -0
  207. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/arial_bold.png +0 -0
  208. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/arial_bold_italic.png +0 -0
  209. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/arial_italic.png +0 -0
  210. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_darken.png +0 -0
  211. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_lighten.png +0 -0
  212. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_multiply.png +0 -0
  213. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_normal.png +0 -0
  214. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_overlay.png +0 -0
  215. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/blend_mode_screen.png +0 -0
  216. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/bold_italic_with_named_font.png +0 -0
  217. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/bold_with_named_font.png +0 -0
  218. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/font_name_works.png +0 -0
  219. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_background_basic.png +0 -0
  220. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_background_with_opacity.png +0 -0
  221. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_brightness_decrease.png +0 -0
  222. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_brightness_increase.png +0 -0
  223. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_fit_contain.png +0 -0
  224. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_fit_cover.png +0 -0
  225. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/image_fit_fill.png +0 -0
  226. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/italic_with_named_font.png +0 -0
  227. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/jpeg_format.jpg +0 -0
  228. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/linear_gradient_brightness_decrease.png +0 -0
  229. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/linear_gradient_brightness_increase.png +0 -0
  230. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/linear_gradient_diagonal.png +0 -0
  231. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/linear_gradient_horizontal.png +0 -0
  232. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/linear_gradient_with_opacity.png +0 -0
  233. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/outline_basic.png +0 -0
  234. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/outline_with_offset.png +0 -0
  235. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/percentage_with_alignment.png +0 -0
  236. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/radial_gradient_brightness_decrease.png +0 -0
  237. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/radial_gradient_brightness_increase.png +0 -0
  238. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/radial_gradient_centered.png +0 -0
  239. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/radial_gradient_with_opacity.png +0 -0
  240. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_advanced_styles.png +0 -0
  241. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_alignment.png +0 -0
  242. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_different_colors.png +0 -0
  243. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_mixed_fonts.png +0 -0
  244. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_mixed_styles.png +0 -0
  245. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/rich_text_with_effects.png +0 -0
  246. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/roboto_bold.png +0 -0
  247. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/roboto_bold_italic.png +0 -0
  248. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/roboto_italic.png +0 -0
  249. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/solid_background.png +0 -0
  250. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/solid_color_brightness_decrease.png +0 -0
  251. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/solid_color_brightness_increase.png +0 -0
  252. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_alignment.png +0 -0
  253. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_bold_and_italic.png +0 -0
  254. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_no_wrapping.png +0 -0
  255. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_rendering.png +0 -0
  256. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_weight_black.png +0 -0
  257. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_weight_light.png +0 -0
  258. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_basic_shadow.png +0 -0
  259. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_blurred_shadow.png +0 -0
  260. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_glow.png +0 -0
  261. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_letter_spacing.png +0 -0
  262. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_line_height.png +0 -0
  263. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_multiple_glows.png +0 -0
  264. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_with_stroke.png +0 -0
  265. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_wrapping_center_aligned.png +0 -0
  266. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_wrapping_center_aligned_percentage.png +0 -0
  267. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_wrapping_left_aligned.png +0 -0
  268. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/text_wrapping_right_aligned.png +0 -0
  269. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/times_new_roman_bold.png +0 -0
  270. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/times_new_roman_bold_italic.png +0 -0
  271. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/times_new_roman_italic.png +0 -0
  272. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/url_image_background_basic.png +0 -0
  273. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/webfont_basic_rendering.png +0 -0
  274. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/snapshots/webp_format.webp +0 -0
  275. {quickthumb-0.2.0 → quickthumb-0.3.1}/tests/test_font_cache.py +0 -0
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: tdd
3
+ description: Implement features or fix bugs using the TDD cycle. Use when you're about to add a new feature or fix a bug—start here before writing implementation code.
4
+ disable-model-invocation: true
5
+ ---
6
+
7
+ ## Workflow
8
+
9
+ ```mermaid
10
+ graph TD
11
+ RED[RED: Write failing test] --> REVIEW[Spawn subagent: devil's advocate test review]
12
+ REVIEW -->|Pass| GREEN[GREEN: Write minimal code to pass]
13
+ REVIEW -->|Fail| RED
14
+ GREEN --> REFACTOR[REFACTOR: Improve code/tests or state 'No refactoring needed']
15
+ REFACTOR --> RED
16
+ ```
17
+
18
+ The devil's advocate subagent reviews test quality against the checklists below before implementation begins.
19
+
20
+ ## Checklists
21
+
22
+ 1. Follow the "Detroit School" of TDD (Classical TDD). Never mock internal methods or nearby collaborators.
23
+ 2. Use `inline_snapshot` for complex object state verification instead of partial field assertions.
24
+ 3. Prefer fewer, higher-value tests: One real integration test beats three mock-based unit tests.
25
+ 4. Drop tests covered by type checker/linter
26
+ 5. Parametrize `ValidationError` cases into one test.
27
+ 6. Tests belong in their domain file, not a per-feature file. Visual effect snapshots go in `test_rendering.py`; use real image fixtures so differences are obvious to reviewers.
28
+ 7. Snapshot tests subsume pixel-assertion behavioral tests — drop the latter when snapshots exist. Before adding a test, check existing coverage; only add if a genuinely new contract is introduced.
29
+ 8. Extend an existing relevant test to cover a new concern rather than adding a new test — new tests are justified only when no existing test can naturally absorb the change.
@@ -0,0 +1,26 @@
1
+ name: Deploy docs
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: write
11
+
12
+ jobs:
13
+ deploy:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: astral-sh/setup-uv@v5
19
+ with:
20
+ enable-cache: true
21
+
22
+ - name: Install docs dependencies
23
+ run: uv sync --group docs
24
+
25
+ - name: Deploy to GitHub Pages
26
+ run: uv run --group docs mkdocs gh-deploy --force
@@ -1,3 +1,9 @@
1
+ # AI agent directories
2
+ .agents
3
+
4
+ # macOS Finder metadata
5
+ .DS_Store
6
+
1
7
  # Byte-compiled / optimized / DLL files
2
8
  __pycache__/
3
9
  *.py[cod]
@@ -0,0 +1,30 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code when working with this repository.
4
+
5
+ ## Project Overview
6
+
7
+ QuickThumb is a Python library for programmatic thumbnail generation. See @README.md for features, @DESIGN.md for API specifications.
8
+
9
+ ## Project Structure
10
+
11
+ ```
12
+ quickthumb/
13
+ ├── canvas.py # Canvas class with method chaining API
14
+ ├── models.py # Pydantic models (CanvasModel, BackgroundLayer, TextLayer, etc.)
15
+ ├── font_cache.py # Font loading and caching
16
+ └── errors.py # Custom exceptions
17
+ tests/ # Tests follow pattern: test_{component}.py
18
+ ```
19
+
20
+ ## Development Commands
21
+
22
+ ### Setup
23
+
24
+ ```bash
25
+ # Install dependencies
26
+ uv sync
27
+
28
+ # Tests
29
+ uv run pytest [args]
30
+ ```
@@ -0,0 +1,573 @@
1
+ # QuickThumb API Reference
2
+
3
+ This document reflects the current implemented API in this repository.
4
+ It is intended to be the reliable reference for writing QuickThumb code, generating QuickThumb JSON, and validating what the library actually supports today.
5
+
6
+ ## Core Model
7
+
8
+ - A `Canvas` is an ordered list of layers.
9
+ - Layers render in call order: first added is the backmost layer.
10
+ - All layer builder methods mutate the canvas and return `self`.
11
+ - Most canvases can round-trip through JSON with `to_json()` and `from_json()`.
12
+ - Custom callback layers are intentionally excluded from JSON serialization.
13
+
14
+ ## Public Imports
15
+
16
+ ```python
17
+ from quickthumb import (
18
+ Align,
19
+ Background,
20
+ BlendMode,
21
+ Canvas,
22
+ Filter,
23
+ FitMode,
24
+ Glow,
25
+ LinearGradient,
26
+ RadialGradient,
27
+ Shadow,
28
+ Stroke,
29
+ TextPart,
30
+ ValidationError,
31
+ )
32
+ ```
33
+
34
+ ## Canvas API
35
+
36
+ ### Creation
37
+
38
+ ```python
39
+ from quickthumb import Canvas
40
+
41
+ canvas = Canvas(1280, 720)
42
+ wide = Canvas.from_aspect_ratio("16:9", base_width=1280)
43
+ square = Canvas.from_aspect_ratio("1:1", base_width=1080)
44
+ vertical = Canvas.from_aspect_ratio("9:16", base_width=1080)
45
+ ```
46
+
47
+ Rules:
48
+
49
+ - `width` and `height` must be positive integers.
50
+ - `from_aspect_ratio()` expects a string like `"16:9"` plus `base_width`.
51
+
52
+ ### Layer Builders
53
+
54
+ ```python
55
+ canvas.background(...)
56
+ canvas.text(...)
57
+ canvas.outline(...)
58
+ canvas.shape(...)
59
+ canvas.image(...)
60
+ canvas.custom(fn)
61
+ ```
62
+
63
+ ### Export Methods
64
+
65
+ ```python
66
+ canvas.render("output.png")
67
+ canvas.render("output.webp", format="WEBP", quality=90)
68
+
69
+ png_base64 = canvas.to_base64(format="PNG")
70
+ jpeg_data_url = canvas.to_data_url(format="JPEG", quality=90)
71
+
72
+ json_data = canvas.to_json()
73
+ canvas = Canvas.from_json(json_data)
74
+ ```
75
+
76
+ Rules:
77
+
78
+ - Supported output formats are `PNG`, `JPEG`, and `WEBP`.
79
+ - `quality` is only valid for `JPEG` and `WEBP`.
80
+ - `Canvas.from_json()` accepts a JSON string, not a Python dict.
81
+
82
+ ## Layer Reference
83
+
84
+ ### Background Layer
85
+
86
+ Adds a full-canvas background layer.
87
+
88
+ ```python
89
+ from quickthumb import Canvas, Filter, LinearGradient
90
+
91
+ canvas = (
92
+ Canvas(1280, 720)
93
+ .background(color="#0F172A")
94
+ .background(
95
+ gradient=LinearGradient(
96
+ angle=120,
97
+ stops=[("#111827", 0.0), ("#11182700", 1.0)],
98
+ ),
99
+ opacity=0.7,
100
+ )
101
+ .background(
102
+ image="https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
103
+ fit="cover",
104
+ blend_mode="multiply",
105
+ effects=[Filter(blur=4, brightness=0.75, contrast=1.1, saturation=0.9)],
106
+ )
107
+ )
108
+ ```
109
+
110
+ Parameters:
111
+
112
+ - `color`: hex string (`"#RRGGBB"` or `"#RRGGBBAA"`) or RGB/RGBA tuple
113
+ - `gradient`: `LinearGradient` or `RadialGradient`
114
+ - `image`: local file path or remote URL
115
+ - `opacity`: float from `0.0` to `1.0`
116
+ - `blend_mode`: `multiply`, `overlay`, `screen`, `darken`, `lighten`, or `normal`
117
+ - `fit`: `cover`, `contain`, or `fill`
118
+ - `effects`: background effects list, currently `Filter` only
119
+
120
+ Notes:
121
+
122
+ - Background layers cover the full canvas.
123
+ - Background blend modes apply when compositing over previous layers.
124
+ - Backgrounds support local images and remote URLs.
125
+
126
+ ### Text Layer
127
+
128
+ Adds a text layer using either a plain string or rich text parts.
129
+
130
+ ```python
131
+ from quickthumb import Background, Canvas, Glow, Shadow, Stroke, TextPart
132
+
133
+ canvas = Canvas(1280, 720).text(
134
+ content=[
135
+ TextPart(text="BUILD ", color="#B8FF00", weight=900),
136
+ TextPart(text="FASTER", color="#FFFFFF", effects=[Stroke(width=4, color="#000000")]),
137
+ ],
138
+ font="Impact",
139
+ size=110,
140
+ color="#FFFFFF",
141
+ position=("8%", "50%"),
142
+ align=("left", "middle"),
143
+ max_width="60%",
144
+ line_height=1.0,
145
+ letter_spacing=1,
146
+ effects=[
147
+ Background(color="#111827CC", padding=(16, 24), border_radius=14),
148
+ Shadow(offset_x=4, offset_y=4, color="#000000", blur_radius=8),
149
+ Glow(color="#B8FF00", radius=16, opacity=0.25),
150
+ ],
151
+ rotation=0,
152
+ opacity=1.0,
153
+ )
154
+ ```
155
+
156
+ Parameters:
157
+
158
+ - `content`: required; either a string or `list[TextPart]`
159
+ - `font`: font family name, font file path, or webfont URL
160
+ - `size`: positive integer font size
161
+ - `color`: default text color for the layer
162
+ - `position`: optional `(x, y)` tuple using pixels or percentage strings
163
+ - `align`: optional `Align`, string alias, or `(horizontal, vertical)` tuple
164
+ - `bold`: legacy bold flag
165
+ - `italic`: italic flag
166
+ - `weight`: CSS-style font weight as int or name
167
+ - `max_width`: wrap width in pixels or percentage string
168
+ - `effects`: any mix of `Stroke`, `Shadow`, `Glow`, and text `Background`
169
+ - `line_height`: positive float
170
+ - `letter_spacing`: integer tracking adjustment
171
+ - `auto_scale`: shrink text until it fits `max_width`
172
+ - `rotation`: degrees
173
+ - `opacity`: float from `0.0` to `1.0`
174
+
175
+ Notes:
176
+
177
+ - `content` is required.
178
+ - `align` accepts values like `"center"`, `"top-left"`, `"bottom-right"`, or tuples like `("left", "middle")`.
179
+ - `font` may be a remote URL; QuickThumb downloads and caches it.
180
+ - `weight` is supported on both full text layers and individual `TextPart` entries.
181
+
182
+ ### Outline Layer
183
+
184
+ Adds a border around the full canvas.
185
+
186
+ ```python
187
+ canvas.outline(width=12, color="#B8FF00", offset=0, opacity=1.0)
188
+ ```
189
+
190
+ Parameters:
191
+
192
+ - `width`: positive integer
193
+ - `color`: hex string
194
+ - `offset`: non-negative integer inset/outset offset
195
+ - `opacity`: float from `0.0` to `1.0`
196
+
197
+ ### Shape Layer
198
+
199
+ Adds a positioned rectangle or ellipse.
200
+
201
+ ```python
202
+ from quickthumb import Canvas, Shadow, Stroke
203
+
204
+ canvas = Canvas(1280, 720).shape(
205
+ shape="rectangle",
206
+ position=(64, 64),
207
+ width=360,
208
+ height=120,
209
+ color="#CC0000",
210
+ border_radius=16,
211
+ opacity=0.95,
212
+ rotation=-4,
213
+ align=("left", "top"),
214
+ effects=[
215
+ Stroke(width=3, color="#FFFFFF"),
216
+ Shadow(offset_x=0, offset_y=10, color="#000000", blur_radius=18),
217
+ ],
218
+ )
219
+ ```
220
+
221
+ Parameters:
222
+
223
+ - `shape`: `"rectangle"` or `"ellipse"`
224
+ - `position`: required `(x, y)` tuple using pixels or percentage strings
225
+ - `width`: positive integer
226
+ - `height`: positive integer
227
+ - `color`: hex string
228
+ - `border_radius`: non-negative integer
229
+ - `opacity`: float from `0.0` to `1.0`
230
+ - `rotation`: degrees
231
+ - `align`: optional `Align`, string alias, or `(horizontal, vertical)` tuple
232
+ - `effects`: any mix of `Stroke`, `Shadow`, and `Glow`
233
+
234
+ ### Image Layer
235
+
236
+ Adds an overlay image or cutout.
237
+
238
+ ```python
239
+ from quickthumb import Canvas, Filter, Shadow
240
+
241
+ canvas = Canvas(1280, 720).image(
242
+ path="portrait.png",
243
+ position=("74%", "54%"),
244
+ width=420,
245
+ height=520,
246
+ fit="cover",
247
+ opacity=1.0,
248
+ rotation=0,
249
+ align=("center", "middle"),
250
+ remove_background=True,
251
+ border_radius=28,
252
+ blend_mode="normal",
253
+ effects=[
254
+ Filter(contrast=1.1, saturation=1.05),
255
+ Shadow(offset_x=0, offset_y=12, color="#000000", blur_radius=24),
256
+ ],
257
+ )
258
+ ```
259
+
260
+ Parameters:
261
+
262
+ - `path`: required local file path or remote URL
263
+ - `position`: required `(x, y)` tuple using pixels or percentage strings
264
+ - `width`: optional positive integer
265
+ - `height`: optional positive integer
266
+ - `fit`: `cover`, `contain`, or `fill` when width and height define a target box
267
+ - `opacity`: float from `0.0` to `1.0`
268
+ - `rotation`: degrees
269
+ - `align`: `Align`, string alias, or `(horizontal, vertical)` tuple
270
+ - `remove_background`: requires `quickthumb[rembg]`
271
+ - `border_radius`: non-negative integer
272
+ - `effects`: any mix of `Stroke`, `Shadow`, `Glow`, and `Filter`
273
+ - `blend_mode`: `multiply`, `overlay`, `screen`, `darken`, `lighten`, or `normal`
274
+
275
+ Notes:
276
+
277
+ - If only one of `width` or `height` is set, aspect ratio is preserved.
278
+ - `fit` matters when both `width` and `height` define a box.
279
+ - Image blend modes apply during compositing onto prior layers.
280
+
281
+ ### Custom Layer
282
+
283
+ Adds a callback that can draw directly onto the rendered Pillow image.
284
+
285
+ ```python
286
+ from PIL import ImageDraw
287
+ from quickthumb import Canvas
288
+
289
+ def add_badge(image):
290
+ draw = ImageDraw.Draw(image)
291
+ draw.polygon([(70, 70), (240, 70), (155, 180)], fill="#FF3B30")
292
+ return image
293
+
294
+ canvas = Canvas(512, 512).custom(add_badge)
295
+ ```
296
+
297
+ Rules:
298
+
299
+ - `fn` must be callable.
300
+ - The callback receives a `PIL.Image.Image`.
301
+ - The callback may mutate and return the same image, return a new image of the same size, or return `None`.
302
+ - Exceptions from the callback are wrapped as `RenderingError`.
303
+ - Custom layers cannot be serialized to JSON.
304
+
305
+ ## Helpers, Enums, and Effects
306
+
307
+ ### Gradients
308
+
309
+ ```python
310
+ from quickthumb import LinearGradient, RadialGradient
311
+
312
+ LinearGradient(
313
+ angle=45,
314
+ stops=[("#FF5733", 0.0), ("#3333FF", 1.0)],
315
+ )
316
+
317
+ RadialGradient(
318
+ stops=[("#FF5733", 0.0), ("#3333FF", 1.0)],
319
+ center=(0.5, 0.5),
320
+ )
321
+ ```
322
+
323
+ ### TextPart
324
+
325
+ `TextPart` supports per-segment overrides inside rich text content.
326
+
327
+ ```python
328
+ from quickthumb import Stroke, TextPart
329
+
330
+ TextPart(
331
+ text="HOT",
332
+ color="#FF3B30",
333
+ size=56,
334
+ font="Impact",
335
+ bold=None,
336
+ italic=None,
337
+ weight=900,
338
+ line_height=None,
339
+ letter_spacing=0,
340
+ effects=[Stroke(width=3, color="#000000")],
341
+ )
342
+ ```
343
+
344
+ Rules:
345
+
346
+ - `text` cannot be empty.
347
+ - `weight` and `bold=True` are mutually exclusive here too.
348
+
349
+ ### Enums
350
+
351
+ Blend modes:
352
+
353
+ - `BlendMode.MULTIPLY`
354
+ - `BlendMode.OVERLAY`
355
+ - `BlendMode.SCREEN`
356
+ - `BlendMode.DARKEN`
357
+ - `BlendMode.LIGHTEN`
358
+ - `BlendMode.NORMAL`
359
+
360
+ Fit modes:
361
+
362
+ - `FitMode.COVER`
363
+ - `FitMode.CONTAIN`
364
+ - `FitMode.FILL`
365
+
366
+ Align values:
367
+
368
+ - `Align.CENTER`
369
+ - `Align.TOP_LEFT`
370
+ - `Align.TOP_CENTER`
371
+ - `Align.TOP_RIGHT`
372
+ - `Align.LEFT`
373
+ - `Align.RIGHT`
374
+ - `Align.BOTTOM_LEFT`
375
+ - `Align.BOTTOM_CENTER`
376
+ - `Align.BOTTOM_RIGHT`
377
+
378
+ ### Effects by Layer Type
379
+
380
+ - Text layers: `Stroke`, `Shadow`, `Glow`, `Background`
381
+ - Image layers: `Stroke`, `Shadow`, `Glow`, `Filter`
382
+ - Shape layers: `Stroke`, `Shadow`, `Glow`
383
+ - Background layers: `Filter`
384
+
385
+ ## JSON Schema
386
+
387
+ QuickThumb serializes canvases as a JSON object with top-level `width`, `height`, and `layers`.
388
+
389
+ ```json
390
+ {
391
+ "width": 1280,
392
+ "height": 720,
393
+ "layers": [
394
+ {
395
+ "type": "background",
396
+ "color": "#0F172A",
397
+ "opacity": 1.0,
398
+ "blend_mode": null,
399
+ "fit": null,
400
+ "effects": []
401
+ },
402
+ {
403
+ "type": "shape",
404
+ "shape": "rectangle",
405
+ "position": [48, 48],
406
+ "width": 320,
407
+ "height": 96,
408
+ "color": "#CC0000",
409
+ "border_radius": 14,
410
+ "opacity": 1.0,
411
+ "rotation": 0.0,
412
+ "align": "top-left",
413
+ "effects": [
414
+ { "type": "stroke", "width": 2, "color": "#FFFFFF" }
415
+ ]
416
+ },
417
+ {
418
+ "type": "text",
419
+ "content": [
420
+ { "text": "HELLO ", "color": "#FFFFFF", "effects": [] },
421
+ {
422
+ "text": "WORLD",
423
+ "color": "#B8FF00",
424
+ "weight": 900,
425
+ "effects": [
426
+ { "type": "stroke", "width": 3, "color": "#000000" }
427
+ ]
428
+ }
429
+ ],
430
+ "size": 88,
431
+ "position": ["8%", "50%"],
432
+ "align": "left",
433
+ "max_width": "60%",
434
+ "auto_scale": false,
435
+ "rotation": 0.0,
436
+ "opacity": 1.0,
437
+ "effects": [
438
+ {
439
+ "type": "shadow",
440
+ "offset_x": 4,
441
+ "offset_y": 4,
442
+ "color": "#000000",
443
+ "blur_radius": 8
444
+ }
445
+ ]
446
+ },
447
+ {
448
+ "type": "image",
449
+ "path": "portrait.png",
450
+ "position": ["74%", "54%"],
451
+ "width": 420,
452
+ "height": 520,
453
+ "opacity": 1.0,
454
+ "rotation": 0.0,
455
+ "remove_background": false,
456
+ "align": "center",
457
+ "border_radius": 24,
458
+ "fit": "cover",
459
+ "blend_mode": "normal",
460
+ "effects": [
461
+ { "type": "filter", "blur": 0, "brightness": 1.0, "contrast": 1.05, "saturation": 1.0 }
462
+ ]
463
+ },
464
+ {
465
+ "type": "outline",
466
+ "width": 12,
467
+ "color": "#B8FF00",
468
+ "offset": 0,
469
+ "opacity": 1.0
470
+ }
471
+ ]
472
+ }
473
+ ```
474
+
475
+ Serialization notes:
476
+
477
+ - Every layer uses a `type` discriminator.
478
+ - Enum-like fields serialize to strings.
479
+ - Positions serialize as JSON arrays.
480
+ - `align` serializes to a single string such as `"center"` or `"top-left"`.
481
+ - `canvas.custom(...)` layers are not serializable.
482
+
483
+ ## Validation Rules and Gotchas
484
+
485
+ ### Text
486
+
487
+ - `content` is required for `canvas.text(...)`.
488
+ - Rich text lists cannot be empty.
489
+ - `TextPart.text` cannot be empty.
490
+ - `weight` and `bold=True` are mutually exclusive on both `TextLayer` and `TextPart`.
491
+ - `auto_scale=True` requires `max_width`.
492
+ - `max_width` must be a positive integer or positive percentage string.
493
+ - Percentage strings are validated for `position` and `max_width`.
494
+
495
+ ### Fonts
496
+
497
+ - `font` may be a font family name, font file path, or remote font URL.
498
+ - When `font` is a webfont URL, `bold`, `italic`, and `weight` flags are ignored.
499
+ - Provide separate font URLs for styled webfont variants.
500
+ - `QUICKTHUMB_FONT_DIR` can point QuickThumb to a custom font directory.
501
+ - `QUICKTHUMB_DEFAULT_FONT` can override the default fallback font.
502
+
503
+ ### Images and Shapes
504
+
505
+ - `image.position` and `shape.position` are required.
506
+ - Positions must be 2-item tuples or lists.
507
+ - Percentage positions may be negative, for example `("-10%", "50%")`.
508
+ - `border_radius` cannot be negative.
509
+ - `opacity` must be between `0.0` and `1.0`.
510
+
511
+ ### Rendering and Export
512
+
513
+ - `quality` is only supported for `JPEG` and `WEBP`; using it with `PNG` raises `RenderingError`.
514
+ - Unsupported file formats raise `RenderingError`.
515
+ - Invalid local paths or failed remote downloads raise rendering-time errors.
516
+
517
+ ### JSON
518
+
519
+ - `Canvas.from_json()` expects a JSON string.
520
+ - `Canvas.to_json()` raises `ValidationError` when the canvas contains custom layers.
521
+ - Round-trip JSON works for supported layer types and effects only.
522
+
523
+ ## End-to-End Example
524
+
525
+ ```python
526
+ from quickthumb import Background, Canvas, Filter, Shadow, Stroke, TextPart
527
+
528
+ canvas = (
529
+ Canvas.from_aspect_ratio("16:9", base_width=1280)
530
+ .background(
531
+ image="https://images.unsplash.com/photo-1516321318423-f06f85e504b3",
532
+ fit="cover",
533
+ effects=[Filter(brightness=0.6)],
534
+ )
535
+ .background(color="#000000", opacity=0.35)
536
+ .shape(
537
+ shape="rectangle",
538
+ position=(52, 52),
539
+ width=360,
540
+ height=96,
541
+ color="#CC0000",
542
+ border_radius=14,
543
+ )
544
+ .text(
545
+ content=[
546
+ TextPart(text="AI ", color="#B8FF00", weight=900),
547
+ TextPart(text="THUMBNAILS", color="#FFFFFF", weight=900),
548
+ ],
549
+ size=108,
550
+ position=("8%", "52%"),
551
+ align=("left", "middle"),
552
+ max_width="58%",
553
+ effects=[
554
+ Stroke(width=6, color="#000000"),
555
+ Shadow(offset_x=4, offset_y=4, color="#000000", blur_radius=8),
556
+ Background(color="#111827CC", padding=(16, 22), border_radius=12),
557
+ ],
558
+ )
559
+ .image(
560
+ path="portrait.png",
561
+ position=("75%", "55%"),
562
+ width=430,
563
+ height=540,
564
+ fit="cover",
565
+ align=("center", "middle"),
566
+ border_radius=24,
567
+ effects=[Shadow(offset_x=0, offset_y=14, color="#000000", blur_radius=24)],
568
+ )
569
+ .outline(width=12, color="#B8FF00")
570
+ )
571
+
572
+ canvas.render("thumbnail.png")
573
+ ```