quickthumb 0.3.1__tar.gz → 0.4.0__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 (287) hide show
  1. {quickthumb-0.3.1 → quickthumb-0.4.0}/.github/workflows/docs.yml +4 -0
  2. {quickthumb-0.3.1 → quickthumb-0.4.0}/DESIGN.md +39 -6
  3. {quickthumb-0.3.1 → quickthumb-0.4.0}/PKG-INFO +4 -4
  4. {quickthumb-0.3.1 → quickthumb-0.4.0}/README.md +3 -3
  5. quickthumb-0.4.0/docs/assets/examples/instagram_news_card.png +0 -0
  6. quickthumb-0.4.0/docs/assets/examples/podcast_interview_promo.png +0 -0
  7. quickthumb-0.4.0/docs/assets/examples/shorts_cover_agent.png +0 -0
  8. quickthumb-0.4.0/docs/assets/examples/youtube_reaction.png +0 -0
  9. quickthumb-0.4.0/docs/assets/examples/youtube_talking_head.png +0 -0
  10. {quickthumb-0.3.1 → quickthumb-0.4.0/docs/assets}/examples/youtube_thumbnail_02.png +0 -0
  11. quickthumb-0.4.0/docs/assets/examples/youtube_tutorial_explainer.png +0 -0
  12. quickthumb-0.4.0/examples/instagram_news_card.png +0 -0
  13. quickthumb-0.4.0/examples/podcast_interview_promo.png +0 -0
  14. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/shorts_cover_agent.json +3 -2
  15. quickthumb-0.4.0/examples/shorts_cover_agent.png +0 -0
  16. quickthumb-0.4.0/examples/youtube_reaction.png +0 -0
  17. quickthumb-0.4.0/examples/youtube_talking_head.png +0 -0
  18. {quickthumb-0.3.1/docs/assets → quickthumb-0.4.0}/examples/youtube_thumbnail_02.png +0 -0
  19. quickthumb-0.4.0/examples/youtube_tutorial_explainer.png +0 -0
  20. {quickthumb-0.3.1 → quickthumb-0.4.0}/pyproject.toml +1 -1
  21. {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/canvas.py +109 -49
  22. quickthumb-0.4.0/tests/snapshots/custom_layer_with_kwargs.png +0 -0
  23. quickthumb-0.4.0/tests/snapshots/explicit_newlines_preserved_with_max_width.png +0 -0
  24. quickthumb-0.4.0/tests/snapshots/image_with_border_radius.png +0 -0
  25. quickthumb-0.4.0/tests/snapshots/image_with_rotation.png +0 -0
  26. quickthumb-0.4.0/tests/snapshots/multiline_text_with_background.png +0 -0
  27. quickthumb-0.4.0/tests/snapshots/multiple_images.png +0 -0
  28. quickthumb-0.4.0/tests/snapshots/rich_text_with_background.png +0 -0
  29. quickthumb-0.4.0/tests/snapshots/shape_ellipse_basic.png +0 -0
  30. quickthumb-0.4.0/tests/snapshots/shape_rectangle_with_border_radius.png +0 -0
  31. quickthumb-0.4.0/tests/snapshots/shape_with_effects.png +0 -0
  32. quickthumb-0.4.0/tests/snapshots/shape_with_rotation.png +0 -0
  33. quickthumb-0.4.0/tests/snapshots/text_with_background_and_effects.png +0 -0
  34. quickthumb-0.4.0/tests/snapshots/text_with_background_basic.png +0 -0
  35. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_canvas.py +113 -15
  36. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_rendering.py +30 -0
  37. {quickthumb-0.3.1 → quickthumb-0.4.0}/uv.lock +1 -1
  38. quickthumb-0.3.1/PROGRESS.md +0 -125
  39. quickthumb-0.3.1/docs/assets/examples/instagram_news_card.png +0 -0
  40. quickthumb-0.3.1/docs/assets/examples/podcast_interview_promo.png +0 -0
  41. quickthumb-0.3.1/docs/assets/examples/shorts_cover_agent.png +0 -0
  42. quickthumb-0.3.1/docs/assets/examples/youtube_reaction.png +0 -0
  43. quickthumb-0.3.1/docs/assets/examples/youtube_talking_head.png +0 -0
  44. quickthumb-0.3.1/docs/assets/examples/youtube_tutorial_explainer.png +0 -0
  45. quickthumb-0.3.1/examples/instagram_news_card.png +0 -0
  46. quickthumb-0.3.1/examples/podcast_interview_promo.png +0 -0
  47. quickthumb-0.3.1/examples/shorts_cover_agent.png +0 -0
  48. quickthumb-0.3.1/examples/youtube_reaction.png +0 -0
  49. quickthumb-0.3.1/examples/youtube_talking_head.png +0 -0
  50. quickthumb-0.3.1/examples/youtube_tutorial_explainer.png +0 -0
  51. quickthumb-0.3.1/tests/snapshots/explicit_newlines_preserved_with_max_width.png +0 -0
  52. quickthumb-0.3.1/tests/snapshots/image_with_border_radius.png +0 -0
  53. quickthumb-0.3.1/tests/snapshots/image_with_rotation.png +0 -0
  54. quickthumb-0.3.1/tests/snapshots/multiline_text_with_background.png +0 -0
  55. quickthumb-0.3.1/tests/snapshots/multiple_images.png +0 -0
  56. quickthumb-0.3.1/tests/snapshots/rich_text_with_background.png +0 -0
  57. quickthumb-0.3.1/tests/snapshots/shape_ellipse_basic.png +0 -0
  58. quickthumb-0.3.1/tests/snapshots/shape_rectangle_with_border_radius.png +0 -0
  59. quickthumb-0.3.1/tests/snapshots/shape_with_effects.png +0 -0
  60. quickthumb-0.3.1/tests/snapshots/shape_with_rotation.png +0 -0
  61. quickthumb-0.3.1/tests/snapshots/text_with_background_and_effects.png +0 -0
  62. quickthumb-0.3.1/tests/snapshots/text_with_background_basic.png +0 -0
  63. {quickthumb-0.3.1 → quickthumb-0.4.0}/.claude/settings.json +0 -0
  64. {quickthumb-0.3.1 → quickthumb-0.4.0}/.claude/skills/tdd/SKILL.md +0 -0
  65. {quickthumb-0.3.1 → quickthumb-0.4.0}/.github/workflows/publish.yaml +0 -0
  66. {quickthumb-0.3.1 → quickthumb-0.4.0}/.gitignore +0 -0
  67. {quickthumb-0.3.1 → quickthumb-0.4.0}/.vscode/extensions.json +0 -0
  68. {quickthumb-0.3.1 → quickthumb-0.4.0}/.vscode/settings.json +0 -0
  69. {quickthumb-0.3.1 → quickthumb-0.4.0}/CLAUDE.md +0 -0
  70. {quickthumb-0.3.1 → quickthumb-0.4.0}/LICENSE +0 -0
  71. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Black.ttf +0 -0
  72. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-BlackItalic.ttf +0 -0
  73. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Bold.ttf +0 -0
  74. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-BoldItalic.ttf +0 -0
  75. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraBold.ttf +0 -0
  76. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraBoldItalic.ttf +0 -0
  77. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraLight.ttf +0 -0
  78. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraLightItalic.ttf +0 -0
  79. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Italic.ttf +0 -0
  80. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Light.ttf +0 -0
  81. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-LightItalic.ttf +0 -0
  82. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Medium.ttf +0 -0
  83. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-MediumItalic.ttf +0 -0
  84. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Regular.ttf +0 -0
  85. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-SemiBold.ttf +0 -0
  86. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-SemiBoldItalic.ttf +0 -0
  87. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Thin.ttf +0 -0
  88. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ThinItalic.ttf +0 -0
  89. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Black.ttf +0 -0
  90. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-BlackItalic.ttf +0 -0
  91. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Bold.ttf +0 -0
  92. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-BoldItalic.ttf +0 -0
  93. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraBold.ttf +0 -0
  94. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraBoldItalic.ttf +0 -0
  95. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraLight.ttf +0 -0
  96. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraLightItalic.ttf +0 -0
  97. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Italic.ttf +0 -0
  98. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Light.ttf +0 -0
  99. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-LightItalic.ttf +0 -0
  100. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Medium.ttf +0 -0
  101. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-MediumItalic.ttf +0 -0
  102. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Regular.ttf +0 -0
  103. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-SemiBold.ttf +0 -0
  104. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-SemiBoldItalic.ttf +0 -0
  105. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Thin.ttf +0 -0
  106. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ThinItalic.ttf +0 -0
  107. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Black.ttf +0 -0
  108. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-BlackItalic.ttf +0 -0
  109. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Bold.ttf +0 -0
  110. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-BoldItalic.ttf +0 -0
  111. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraBold.ttf +0 -0
  112. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraBoldItalic.ttf +0 -0
  113. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraLight.ttf +0 -0
  114. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraLightItalic.ttf +0 -0
  115. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Italic.ttf +0 -0
  116. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Light.ttf +0 -0
  117. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-LightItalic.ttf +0 -0
  118. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Medium.ttf +0 -0
  119. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-MediumItalic.ttf +0 -0
  120. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Regular.ttf +0 -0
  121. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-SemiBold.ttf +0 -0
  122. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-SemiBoldItalic.ttf +0 -0
  123. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Thin.ttf +0 -0
  124. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ThinItalic.ttf +0 -0
  125. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/c-g-JgDUVGAXsso-unsplash.jpg +0 -0
  126. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/denise-jans-WIRvXd1PYlg-unsplash.jpg +0 -0
  127. {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
  128. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/background.md +0 -0
  129. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/canvas.md +0 -0
  130. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/effects.md +0 -0
  131. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/enums.md +0 -0
  132. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/image.md +0 -0
  133. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/index.md +0 -0
  134. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/outline.md +0 -0
  135. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/shape.md +0 -0
  136. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/text.md +0 -0
  137. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/assets/examples/youtube_thumbnail_01.png +0 -0
  138. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/changelog.md +0 -0
  139. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/concepts.md +0 -0
  140. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/ai-workflow.md +0 -0
  141. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/index.md +0 -0
  142. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/instagram-card.md +0 -0
  143. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/podcast-promo.md +0 -0
  144. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/shorts-cover.md +0 -0
  145. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/webfonts-rembg.md +0 -0
  146. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/youtube-thumbnail.md +0 -0
  147. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/faq.md +0 -0
  148. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/getting-started.md +0 -0
  149. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/index.md +0 -0
  150. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/installation.md +0 -0
  151. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/json-schema.md +0 -0
  152. {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/stylesheets/extra.css +0 -0
  153. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/README.md +0 -0
  154. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/instagram_news_card.py +0 -0
  155. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/podcast_interview_promo.py +0 -0
  156. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/shorts_cover_agent.py +0 -0
  157. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_reaction.py +0 -0
  158. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_talking_head.py +0 -0
  159. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_01.png +0 -0
  160. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_01.py +0 -0
  161. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_02.py +0 -0
  162. {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_tutorial_explainer.py +0 -0
  163. {quickthumb-0.3.1 → quickthumb-0.4.0}/mkdocs.yml +0 -0
  164. {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/__init__.py +0 -0
  165. {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/errors.py +0 -0
  166. {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/font_cache.py +0 -0
  167. {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/models.py +0 -0
  168. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/__init__.py +0 -0
  169. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/conftest.py +0 -0
  170. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/ivana-cajina-_7LbC5J-jw4-unsplash.jpg +0 -0
  171. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/sample_image.jpg +0 -0
  172. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
  173. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_bold.png +0 -0
  174. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_bold_italic.png +0 -0
  175. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_italic.png +0 -0
  176. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/auto_scale_rich_text.png +0 -0
  177. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/auto_scale_simple_text.png +0 -0
  178. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_blur_heavy.png +0 -0
  179. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_blur_soft.png +0 -0
  180. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_combined_filters.png +0 -0
  181. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_contrast_high.png +0 -0
  182. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_contrast_low.png +0 -0
  183. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_filter_effect.png +0 -0
  184. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_saturation_grayscale.png +0 -0
  185. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_saturation_vivid.png +0 -0
  186. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_darken.png +0 -0
  187. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_lighten.png +0 -0
  188. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_multiply.png +0 -0
  189. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_normal.png +0 -0
  190. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_overlay.png +0 -0
  191. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_screen.png +0 -0
  192. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/bold_italic_with_named_font.png +0 -0
  193. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/bold_with_named_font.png +0 -0
  194. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/custom_canvas_layer.png +0 -0
  195. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/font_name_works.png +0 -0
  196. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_background_basic.png +0 -0
  197. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_background_with_opacity.png +0 -0
  198. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_basic.png +0 -0
  199. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_darken.png +0 -0
  200. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_lighten.png +0 -0
  201. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_multiply.png +0 -0
  202. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_normal.png +0 -0
  203. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_overlay.png +0 -0
  204. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_screen.png +0 -0
  205. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_brightness_decrease.png +0 -0
  206. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_brightness_increase.png +0 -0
  207. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_filter_effect.png +0 -0
  208. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_contain.png +0 -0
  209. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_cover.png +0 -0
  210. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_fill.png +0 -0
  211. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_on_background_image.png +0 -0
  212. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_contain.png +0 -0
  213. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_cover.png +0 -0
  214. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_fill.png +0 -0
  215. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_percentage_position.png +0 -0
  216. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_drop_shadow.png +0 -0
  217. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_glow.png +0 -0
  218. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_opacity.png +0 -0
  219. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_remove_background.png +0 -0
  220. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_stroke.png +0 -0
  221. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_text_overlay.png +0 -0
  222. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/italic_with_named_font.png +0 -0
  223. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/jpeg_format.jpg +0 -0
  224. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_brightness_decrease.png +0 -0
  225. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_brightness_increase.png +0 -0
  226. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_diagonal.png +0 -0
  227. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_horizontal.png +0 -0
  228. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_with_alpha.png +0 -0
  229. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_with_opacity.png +0 -0
  230. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/multiline_text_with_glow.png +0 -0
  231. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/multiline_text_with_shadow.png +0 -0
  232. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/outline_basic.png +0 -0
  233. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/outline_with_offset.png +0 -0
  234. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/percentage_with_alignment.png +0 -0
  235. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_brightness_decrease.png +0 -0
  236. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_brightness_increase.png +0 -0
  237. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_centered.png +0 -0
  238. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_with_opacity.png +0 -0
  239. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_advanced_styles.png +0 -0
  240. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_alignment.png +0 -0
  241. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_different_colors.png +0 -0
  242. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_mixed_fonts.png +0 -0
  243. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_mixed_styles.png +0 -0
  244. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_with_effects.png +0 -0
  245. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_bold.png +0 -0
  246. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_bold_italic.png +0 -0
  247. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_italic.png +0 -0
  248. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/shape_rectangle_basic.png +0 -0
  249. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/shape_with_opacity.png +0 -0
  250. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_background.png +0 -0
  251. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_color_brightness_decrease.png +0 -0
  252. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_color_brightness_increase.png +0 -0
  253. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_alignment.png +0 -0
  254. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_and_outline_with_opacity.png +0 -0
  255. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_bold_and_italic.png +0 -0
  256. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_no_wrapping.png +0 -0
  257. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rendering.png +0 -0
  258. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_basic.png +0 -0
  259. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_centered.png +0 -0
  260. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_with_effects.png +0 -0
  261. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_weight_black.png +0 -0
  262. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_weight_light.png +0 -0
  263. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_basic_shadow.png +0 -0
  264. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_blurred_shadow.png +0 -0
  265. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_glow.png +0 -0
  266. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing.png +0 -0
  267. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing_glow.png +0 -0
  268. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing_shadow.png +0 -0
  269. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_line_height.png +0 -0
  270. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_multiple_glows.png +0 -0
  271. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_stroke.png +0 -0
  272. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_center_aligned.png +0 -0
  273. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_center_aligned_percentage.png +0 -0
  274. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_left_aligned.png +0 -0
  275. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_right_aligned.png +0 -0
  276. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_bold.png +0 -0
  277. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_bold_italic.png +0 -0
  278. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_italic.png +0 -0
  279. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/url_image_background_basic.png +0 -0
  280. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/webfont_basic_rendering.png +0 -0
  281. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/webp_format.webp +0 -0
  282. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_background_layers.py +0 -0
  283. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_font_cache.py +0 -0
  284. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_image_layers.py +0 -0
  285. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_outline_layers.py +0 -0
  286. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_shape_layers.py +0 -0
  287. {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_text_layers.py +0 -0
@@ -4,6 +4,10 @@ on:
4
4
  push:
5
5
  tags:
6
6
  - "v*.*.*"
7
+ branches: [main]
8
+ paths:
9
+ - "docs/**"
10
+ - "mkdocs.yml"
7
11
  workflow_dispatch:
8
12
 
9
13
  permissions:
@@ -9,7 +9,7 @@ It is intended to be the reliable reference for writing QuickThumb code, generat
9
9
  - Layers render in call order: first added is the backmost layer.
10
10
  - All layer builder methods mutate the canvas and return `self`.
11
11
  - Most canvases can round-trip through JSON with `to_json()` and `from_json()`.
12
- - Custom callback layers are intentionally excluded from JSON serialization.
12
+ - Named custom callback layers can round-trip through JSON via a registry. Unnamed custom layers cannot be serialized.
13
13
 
14
14
  ## Public Imports
15
15
 
@@ -58,6 +58,9 @@ canvas.outline(...)
58
58
  canvas.shape(...)
59
59
  canvas.image(...)
60
60
  canvas.custom(fn)
61
+ canvas.custom(fn, name="...", kwargs={...})
62
+ Canvas.register_layer_fn(name, fn)
63
+ Canvas.unregister_layer_fn(name)
61
64
  ```
62
65
 
63
66
  ### Export Methods
@@ -294,13 +297,40 @@ def add_badge(image):
294
297
  canvas = Canvas(512, 512).custom(add_badge)
295
298
  ```
296
299
 
300
+ Named custom layers can be serialized to JSON by registering the function in a global registry:
301
+
302
+ ```python
303
+ from PIL import ImageDraw
304
+ from quickthumb import Canvas
305
+
306
+ def draw_bar(image, *, color: str = "#000000", height: int = 40) -> None:
307
+ ImageDraw.Draw(image).rectangle((0, 0, image.width, height), fill=color)
308
+
309
+ Canvas.register_layer_fn("draw_bar", draw_bar)
310
+
311
+ canvas = Canvas(512, 512).custom(draw_bar, name="draw_bar", kwargs={"color": "#E74C3C", "height": 60})
312
+
313
+ json_str = canvas.to_json()
314
+ # {"type": "custom", "name": "draw_bar", "kwargs": {"color": "#E74C3C", "height": 60}}
315
+
316
+ recreated = Canvas.from_json(json_str) # register_layer_fn must be called first
317
+ ```
318
+
319
+ Parameters:
320
+
321
+ - `fn`: required callable; receives a `PIL.Image.Image` as the first argument
322
+ - `name`: optional string; required for JSON serialization
323
+ - `kwargs`: optional dict of keyword arguments forwarded to `fn` at render time; must be JSON-serializable when `name` is set
324
+
297
325
  Rules:
298
326
 
299
327
  - `fn` must be callable.
300
- - The callback receives a `PIL.Image.Image`.
328
+ - The callback receives a `PIL.Image.Image` as the first positional argument, followed by any `kwargs`.
301
329
  - The callback may mutate and return the same image, return a new image of the same size, or return `None`.
302
330
  - Exceptions from the callback are wrapped as `RenderingError`.
303
- - Custom layers cannot be serialized to JSON.
331
+ - Unnamed custom layers (`name=None`) cannot be serialized to JSON.
332
+ - `Canvas.register_layer_fn(name, fn)` must be called before `Canvas.from_json()` for any canvas containing a custom layer with that name.
333
+ - `Canvas.unregister_layer_fn(name)` removes a name from the registry.
304
334
 
305
335
  ## Helpers, Enums, and Effects
306
336
 
@@ -478,7 +508,8 @@ Serialization notes:
478
508
  - Enum-like fields serialize to strings.
479
509
  - Positions serialize as JSON arrays.
480
510
  - `align` serializes to a single string such as `"center"` or `"top-left"`.
481
- - `canvas.custom(...)` layers are not serializable.
511
+ - Named custom layers serialize as `{"type": "custom", "name": "...", "kwargs": {...}}`.
512
+ - Unnamed custom layers are not serializable.
482
513
 
483
514
  ## Validation Rules and Gotchas
484
515
 
@@ -517,8 +548,10 @@ Serialization notes:
517
548
  ### JSON
518
549
 
519
550
  - `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.
551
+ - `Canvas.to_json()` raises `ValidationError` when the canvas contains unnamed custom layers.
552
+ - Named custom layers round-trip through JSON; `Canvas.register_layer_fn(name, fn)` must be called before `Canvas.from_json()`.
553
+ - `Canvas.from_json()` raises `ValidationError` if a custom layer name is not in the registry.
554
+ - Round-trip JSON works for all built-in layer types, effects, and named custom layers.
522
555
 
523
556
  ## End-to-End Example
524
557
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickthumb
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Programmatic thumbnail and social image generation with layered Python and JSON APIs
5
5
  Project-URL: Homepage, https://github.com/sjquant/quickthumb
6
6
  Project-URL: Repository, https://github.com/sjquant/quickthumb
@@ -244,7 +244,7 @@ serialized = canvas.to_json()
244
244
  Notes:
245
245
 
246
246
  - JSON uses top-level `width`, `height`, and `layers`
247
- - Custom layers added with `canvas.custom(fn)` are not JSON-serializable
247
+ - Named custom layers added with `canvas.custom(fn, name="...", kwargs={...})` are JSON-serializable via the registry; unnamed custom layers are not
248
248
  - Enum-like values such as `blend_mode`, `fit`, and `align` can be passed as strings
249
249
 
250
250
  ## AI-Friendly Workflows
@@ -305,7 +305,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto"
305
305
  | Image effects | Stroke, shadow, glow, filter effects, border radius, background removal |
306
306
  | Shapes | Rectangle and ellipse primitives with stroke/shadow/glow support |
307
307
  | Export | PNG, JPEG, WebP, file output, base64, data URLs |
308
- | Serialization | `to_json()` / `from_json()` for built-in layer types |
308
+ | Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers |
309
309
 
310
310
  ## Real Example Scripts
311
311
 
@@ -320,7 +320,7 @@ See the shipped examples in [`examples/README.md`](examples/README.md):
320
320
  - `weight` and `bold=True` are mutually exclusive on text layers and `TextPart`
321
321
  - `auto_scale=True` requires `max_width`
322
322
  - `position` percentage values must be strings like `"50%"`
323
- - `canvas.custom(fn)` runs during render order and cannot be serialized to JSON
323
+ - `canvas.custom(fn)` without a `name` runs during render order but cannot be serialized to JSON; pass `name=` and register the function with `Canvas.register_layer_fn()` to enable serialization
324
324
 
325
325
  ## Development
326
326
 
@@ -213,7 +213,7 @@ serialized = canvas.to_json()
213
213
  Notes:
214
214
 
215
215
  - JSON uses top-level `width`, `height`, and `layers`
216
- - Custom layers added with `canvas.custom(fn)` are not JSON-serializable
216
+ - Named custom layers added with `canvas.custom(fn, name="...", kwargs={...})` are JSON-serializable via the registry; unnamed custom layers are not
217
217
  - Enum-like values such as `blend_mode`, `fit`, and `align` can be passed as strings
218
218
 
219
219
  ## AI-Friendly Workflows
@@ -274,7 +274,7 @@ os.environ["QUICKTHUMB_DEFAULT_FONT"] = "Roboto"
274
274
  | Image effects | Stroke, shadow, glow, filter effects, border radius, background removal |
275
275
  | Shapes | Rectangle and ellipse primitives with stroke/shadow/glow support |
276
276
  | Export | PNG, JPEG, WebP, file output, base64, data URLs |
277
- | Serialization | `to_json()` / `from_json()` for built-in layer types |
277
+ | Serialization | `to_json()` / `from_json()` for built-in layer types and named custom layers |
278
278
 
279
279
  ## Real Example Scripts
280
280
 
@@ -289,7 +289,7 @@ See the shipped examples in [`examples/README.md`](examples/README.md):
289
289
  - `weight` and `bold=True` are mutually exclusive on text layers and `TextPart`
290
290
  - `auto_scale=True` requires `max_width`
291
291
  - `position` percentage values must be strings like `"50%"`
292
- - `canvas.custom(fn)` runs during render order and cannot be serialized to JSON
292
+ - `canvas.custom(fn)` without a `name` runs during render order but cannot be serialized to JSON; pass `name=` and register the function with `Canvas.register_layer_fn()` to enable serialization
293
293
 
294
294
  ## Development
295
295
 
@@ -80,10 +80,11 @@
80
80
  "font": "Roboto",
81
81
  "size": 36,
82
82
  "color": "#FFFFFF",
83
- "position": ["23.4%", "10.4%"],
84
- "align": "center",
83
+ "position": ["23.4%", "10.9%"],
84
+ "align": ["center", "middle"],
85
85
  "weight": 900,
86
86
  "letter_spacing": 2,
87
+ "rotation": -3,
87
88
  "effects": [
88
89
  {
89
90
  "type": "shadow",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickthumb"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Programmatic thumbnail and social image generation with layered Python and JSON APIs"
5
5
  authors = [{name = "Seonu Jang"}]
6
6
  license = {text = "MIT"}
@@ -4,7 +4,7 @@ import math
4
4
  import os
5
5
  import warnings
6
6
  from collections.abc import Callable
7
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
8
8
  from io import BytesIO
9
9
  from typing import Literal, TypedDict, cast
10
10
  from urllib.request import urlopen
@@ -20,7 +20,6 @@ from quickthumb.models import (
20
20
  BackgroundEffect,
21
21
  BackgroundLayer,
22
22
  BlendMode,
23
- CanvasModel,
24
23
  Filter,
25
24
  FitMode,
26
25
  Glow,
@@ -73,13 +72,25 @@ class TextPartMetadata(TypedDict):
73
72
 
74
73
  @dataclass
75
74
  class CustomLayer:
76
- fn: Callable[[Image.Image], Image.Image | None]
75
+ fn: Callable[..., Image.Image | None]
76
+ name: str | None = None
77
+ kwargs: dict = field(default_factory=dict)
77
78
 
78
79
 
79
80
  RenderableLayer = LayerType | CustomLayer
80
81
 
81
82
 
82
83
  class Canvas:
84
+ _custom_layer_registry: dict[str, Callable[..., Image.Image | None]] = {}
85
+
86
+ @classmethod
87
+ def register_layer_fn(cls, name: str, fn: Callable[..., Image.Image | None]) -> None:
88
+ cls._custom_layer_registry[name] = fn
89
+
90
+ @classmethod
91
+ def unregister_layer_fn(cls, name: str) -> None:
92
+ cls._custom_layer_registry.pop(name, None)
93
+
83
94
  def __init__(self, width: int, height: int, layers: list[RenderableLayer] | None = None):
84
95
  if width <= 0:
85
96
  raise ValidationError("width must be > 0")
@@ -260,12 +271,17 @@ class Canvas:
260
271
  self._layers.append(layer)
261
272
  return self
262
273
 
263
- def custom(self, fn: Callable[[Image.Image], Image.Image | None]) -> Self:
274
+ def custom(
275
+ self,
276
+ fn: Callable[..., Image.Image | None],
277
+ name: str | None = None,
278
+ kwargs: dict | None = None,
279
+ ) -> Self:
264
280
  """Add a custom callback layer that can draw directly onto the canvas image."""
265
281
  if not callable(fn):
266
282
  raise ValidationError("fn must be callable")
267
283
 
268
- self._layers.append(CustomLayer(fn=fn))
284
+ self._layers.append(CustomLayer(fn=fn, name=name, kwargs=kwargs or {}))
269
285
  return self
270
286
 
271
287
  def render(
@@ -279,21 +295,57 @@ class Canvas:
279
295
  self._save_to_file(image, output_path, quality, format=format)
280
296
 
281
297
  def to_json(self) -> str:
282
- if any(isinstance(layer, CustomLayer) for layer in self._layers):
283
- raise ValidationError("Custom layers cannot be serialized to JSON")
298
+ import json as _json
284
299
 
285
- return CanvasModel(
286
- width=self.width, height=self.height, layers=cast(list[LayerType], self._layers)
287
- ).model_dump_json()
300
+ layers_json = []
301
+ for layer in self._layers:
302
+ if isinstance(layer, CustomLayer):
303
+ if layer.name is None:
304
+ raise ValidationError("Custom layers cannot be serialized to JSON")
305
+ try:
306
+ _json.dumps(layer.kwargs)
307
+ except (TypeError, ValueError) as e:
308
+ raise ValidationError(
309
+ f"Custom layer '{layer.name}' has kwargs that are not JSON-serializable:"
310
+ f" {e}"
311
+ ) from e
312
+ layers_json.append({"type": "custom", "name": layer.name, "kwargs": layer.kwargs})
313
+ else:
314
+ layers_json.append(_json.loads(layer.model_dump_json()))
315
+
316
+ return _json.dumps({"width": self.width, "height": self.height, "layers": layers_json})
288
317
 
289
318
  @classmethod
290
319
  def from_json(cls, data: str) -> Self:
291
- canvas_model = CanvasModel.model_validate_json(data)
292
- return cls(
293
- width=canvas_model.width,
294
- height=canvas_model.height,
295
- layers=cast(list[RenderableLayer], canvas_model.layers),
296
- )
320
+ import json as _json
321
+
322
+ from pydantic import TypeAdapter
323
+
324
+ from quickthumb.models import CanvasModel, LayerType
325
+
326
+ raw = _json.loads(data)
327
+ layers_raw = raw.get("layers", [])
328
+
329
+ if not isinstance(layers_raw, list):
330
+ CanvasModel.model_validate_json(data) # raises ValidationError with good message
331
+
332
+ layer_adapter: TypeAdapter[LayerType] = TypeAdapter(LayerType)
333
+ renderable_layers: list[RenderableLayer] = []
334
+ for layer_dict in layers_raw:
335
+ if isinstance(layer_dict, dict) and layer_dict.get("type") == "custom":
336
+ name = layer_dict.get("name")
337
+ fn = cls._custom_layer_registry.get(name)
338
+ if fn is None:
339
+ raise ValidationError(
340
+ f"Custom layer '{name}' is not registered. "
341
+ f"Call Canvas.register_layer_fn('{name}', fn) before deserializing."
342
+ )
343
+ kwargs = layer_dict.get("kwargs") or {}
344
+ renderable_layers.append(CustomLayer(fn=fn, name=name, kwargs=kwargs))
345
+ else:
346
+ renderable_layers.append(layer_adapter.validate_python(layer_dict))
347
+
348
+ return cls(width=raw["width"], height=raw["height"], layers=renderable_layers)
297
349
 
298
350
  def to_base64(self, format: FileFormat = "PNG", quality: int | None = None) -> str:
299
351
  import base64
@@ -345,7 +397,7 @@ class Canvas:
345
397
 
346
398
  def _render_custom_layer(self, image: Image.Image, layer: CustomLayer):
347
399
  try:
348
- result = layer.fn(image)
400
+ result = layer.fn(image, **layer.kwargs)
349
401
  except Exception as e:
350
402
  raise RenderingError(f"Custom layer callback failed: {e}") from e
351
403
 
@@ -593,21 +645,28 @@ class Canvas:
593
645
 
594
646
  fill_color = self._parse_color(layer.color)
595
647
 
596
- # Draw shape on a temp image (shape-sized) so rotation/opacity work cleanly
597
- shape_img = Image.new("RGBA", (layer.width, layer.height), (0, 0, 0, 0))
598
- draw = ImageDraw.Draw(shape_img)
599
- bbox = [0, 0, layer.width - 1, layer.height - 1]
648
+ # Draw at 4x and keep at 4x through rotation so both curved edges and
649
+ # rotation edges are anti-aliased when downscaled with LANCZOS.
650
+ scale = 4
651
+ shape_w, shape_h = layer.width * scale, layer.height * scale
652
+ shape_big = Image.new("RGBA", (shape_w, shape_h), (0, 0, 0, 0))
653
+ draw = ImageDraw.Draw(shape_big)
654
+ bbox = [0, 0, shape_w - 1, shape_h - 1]
600
655
 
601
656
  if layer.shape == "rectangle":
602
- draw.rounded_rectangle(bbox, radius=layer.border_radius, fill=fill_color)
657
+ draw.rounded_rectangle(bbox, radius=layer.border_radius * scale, fill=fill_color)
603
658
  else: # ellipse
604
659
  draw.ellipse(bbox, fill=fill_color)
605
660
 
606
661
  if layer.rotation != 0:
607
- shape_img = shape_img.rotate(
662
+ shape_big = shape_big.rotate(
608
663
  -layer.rotation, expand=True, resample=Image.Resampling.BICUBIC
609
664
  )
610
665
 
666
+ final_w = round(shape_big.width / scale)
667
+ final_h = round(shape_big.height / scale)
668
+ shape_img = shape_big.resize((final_w, final_h), Image.Resampling.LANCZOS)
669
+
611
670
  if layer.opacity < 1.0:
612
671
  shape_img = self._apply_opacity(shape_img, layer.opacity)
613
672
 
@@ -646,7 +705,13 @@ class Canvas:
646
705
  img = self._apply_border_radius(img, layer.border_radius)
647
706
 
648
707
  if layer.rotation != 0:
649
- img = img.rotate(-layer.rotation, expand=True, resample=Image.Resampling.BICUBIC)
708
+ scale = 4
709
+ large = img.resize((img.width * scale, img.height * scale), Image.Resampling.LANCZOS)
710
+ large = large.rotate(-layer.rotation, expand=True, resample=Image.Resampling.BICUBIC)
711
+ img = large.resize(
712
+ (round(large.width / scale), round(large.height / scale)),
713
+ Image.Resampling.LANCZOS,
714
+ )
650
715
 
651
716
  if layer.opacity < 1.0:
652
717
  img = self._apply_opacity(img, layer.opacity)
@@ -775,12 +840,18 @@ class Canvas:
775
840
  canvas.alpha_composite(patch, (dst_x, dst_y))
776
841
 
777
842
  def _apply_border_radius(self, img: Image.Image, radius: int) -> Image.Image:
778
- """Clip image to a rounded rectangle mask."""
843
+ """Clip image to a rounded rectangle mask with anti-aliased corners via supersampling."""
779
844
  w, h = img.size
780
- mask = Image.new("L", (w, h), 0)
781
- draw = ImageDraw.Draw(mask)
782
- draw.rounded_rectangle([0, 0, w - 1, h - 1], radius=radius, fill=255)
845
+ scale = 4
846
+ mask_big = Image.new("L", (w * scale, h * scale), 0)
847
+ draw = ImageDraw.Draw(mask_big)
848
+ draw.rounded_rectangle(
849
+ [0, 0, w * scale - 1, h * scale - 1], radius=radius * scale, fill=255
850
+ )
851
+ mask = mask_big.resize((w, h), Image.Resampling.LANCZOS)
783
852
  result = img.copy()
853
+ if result.mode != "RGBA":
854
+ result = result.convert("RGBA")
784
855
  result.putalpha(mask)
785
856
  return result
786
857
 
@@ -2039,23 +2110,24 @@ class Canvas:
2039
2110
  bg_width = content_width + pad_left + pad_right
2040
2111
  bg_height = content_height + pad_top + pad_bottom
2041
2112
 
2042
- # Create background layer and apply color with opacity
2043
- bg_layer = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 0))
2044
- bg_draw = ImageDraw.Draw(bg_layer)
2045
2113
  bg_color = self._apply_opacity_to_color(
2046
2114
  self._parse_color(background.color), background.opacity
2047
2115
  )
2048
2116
 
2049
- # Draw background shape (rounded rectangle or normal rectangle)
2050
- # Use (bg_width - 1, bg_height - 1) so arcs are not clipped at image boundary
2117
+ # Draw background shape; supersample rounded corners for anti-aliased edges.
2051
2118
  if background.border_radius > 0:
2052
- self._draw_rounded_rectangle(
2053
- bg_draw,
2054
- [(0, 0), (bg_width - 1, bg_height - 1)],
2055
- background.border_radius,
2119
+ scale = 4
2120
+ bg_big = Image.new("RGBA", (bg_width * scale, bg_height * scale), (0, 0, 0, 0))
2121
+ bg_draw_big = ImageDraw.Draw(bg_big)
2122
+ bg_draw_big.rounded_rectangle(
2123
+ [0, 0, bg_width * scale - 1, bg_height * scale - 1],
2124
+ radius=background.border_radius * scale,
2056
2125
  fill=bg_color,
2057
2126
  )
2127
+ bg_layer = bg_big.resize((bg_width, bg_height), Image.Resampling.LANCZOS)
2058
2128
  else:
2129
+ bg_layer = Image.new("RGBA", (bg_width, bg_height), (0, 0, 0, 0))
2130
+ bg_draw = ImageDraw.Draw(bg_layer)
2059
2131
  bg_draw.rectangle([(0, 0), (bg_width - 1, bg_height - 1)], fill=bg_color)
2060
2132
 
2061
2133
  paste_x = content_left - pad_left
@@ -2086,18 +2158,6 @@ class Canvas:
2086
2158
  else: # len(padding) == 4
2087
2159
  return cast(tuple[int, int, int, int], padding)
2088
2160
 
2089
- def _draw_rounded_rectangle(
2090
- self,
2091
- draw: ImageDraw.ImageDraw,
2092
- coords: list[tuple[int, int]],
2093
- radius: int,
2094
- fill: tuple[int, ...] | None = None,
2095
- ):
2096
- """Draw a rounded rectangle on the given draw context."""
2097
- x0, y0 = coords[0]
2098
- x1, y1 = coords[1]
2099
- draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=fill)
2100
-
2101
2161
  def _load_font(self, layer: TextLayer) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
2102
2162
  return self._load_font_variant(
2103
2163
  layer.font,