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.
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.github/workflows/docs.yml +4 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/DESIGN.md +39 -6
- {quickthumb-0.3.1 → quickthumb-0.4.0}/PKG-INFO +4 -4
- {quickthumb-0.3.1 → quickthumb-0.4.0}/README.md +3 -3
- quickthumb-0.4.0/docs/assets/examples/instagram_news_card.png +0 -0
- quickthumb-0.4.0/docs/assets/examples/podcast_interview_promo.png +0 -0
- quickthumb-0.4.0/docs/assets/examples/shorts_cover_agent.png +0 -0
- quickthumb-0.4.0/docs/assets/examples/youtube_reaction.png +0 -0
- quickthumb-0.4.0/docs/assets/examples/youtube_talking_head.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0/docs/assets}/examples/youtube_thumbnail_02.png +0 -0
- quickthumb-0.4.0/docs/assets/examples/youtube_tutorial_explainer.png +0 -0
- quickthumb-0.4.0/examples/instagram_news_card.png +0 -0
- quickthumb-0.4.0/examples/podcast_interview_promo.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/shorts_cover_agent.json +3 -2
- quickthumb-0.4.0/examples/shorts_cover_agent.png +0 -0
- quickthumb-0.4.0/examples/youtube_reaction.png +0 -0
- quickthumb-0.4.0/examples/youtube_talking_head.png +0 -0
- {quickthumb-0.3.1/docs/assets → quickthumb-0.4.0}/examples/youtube_thumbnail_02.png +0 -0
- quickthumb-0.4.0/examples/youtube_tutorial_explainer.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/pyproject.toml +1 -1
- {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/canvas.py +109 -49
- quickthumb-0.4.0/tests/snapshots/custom_layer_with_kwargs.png +0 -0
- quickthumb-0.4.0/tests/snapshots/explicit_newlines_preserved_with_max_width.png +0 -0
- quickthumb-0.4.0/tests/snapshots/image_with_border_radius.png +0 -0
- quickthumb-0.4.0/tests/snapshots/image_with_rotation.png +0 -0
- quickthumb-0.4.0/tests/snapshots/multiline_text_with_background.png +0 -0
- quickthumb-0.4.0/tests/snapshots/multiple_images.png +0 -0
- quickthumb-0.4.0/tests/snapshots/rich_text_with_background.png +0 -0
- quickthumb-0.4.0/tests/snapshots/shape_ellipse_basic.png +0 -0
- quickthumb-0.4.0/tests/snapshots/shape_rectangle_with_border_radius.png +0 -0
- quickthumb-0.4.0/tests/snapshots/shape_with_effects.png +0 -0
- quickthumb-0.4.0/tests/snapshots/shape_with_rotation.png +0 -0
- quickthumb-0.4.0/tests/snapshots/text_with_background_and_effects.png +0 -0
- quickthumb-0.4.0/tests/snapshots/text_with_background_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_canvas.py +113 -15
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_rendering.py +30 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/uv.lock +1 -1
- quickthumb-0.3.1/PROGRESS.md +0 -125
- quickthumb-0.3.1/docs/assets/examples/instagram_news_card.png +0 -0
- quickthumb-0.3.1/docs/assets/examples/podcast_interview_promo.png +0 -0
- quickthumb-0.3.1/docs/assets/examples/shorts_cover_agent.png +0 -0
- quickthumb-0.3.1/docs/assets/examples/youtube_reaction.png +0 -0
- quickthumb-0.3.1/docs/assets/examples/youtube_talking_head.png +0 -0
- quickthumb-0.3.1/docs/assets/examples/youtube_tutorial_explainer.png +0 -0
- quickthumb-0.3.1/examples/instagram_news_card.png +0 -0
- quickthumb-0.3.1/examples/podcast_interview_promo.png +0 -0
- quickthumb-0.3.1/examples/shorts_cover_agent.png +0 -0
- quickthumb-0.3.1/examples/youtube_reaction.png +0 -0
- quickthumb-0.3.1/examples/youtube_talking_head.png +0 -0
- quickthumb-0.3.1/examples/youtube_tutorial_explainer.png +0 -0
- quickthumb-0.3.1/tests/snapshots/explicit_newlines_preserved_with_max_width.png +0 -0
- quickthumb-0.3.1/tests/snapshots/image_with_border_radius.png +0 -0
- quickthumb-0.3.1/tests/snapshots/image_with_rotation.png +0 -0
- quickthumb-0.3.1/tests/snapshots/multiline_text_with_background.png +0 -0
- quickthumb-0.3.1/tests/snapshots/multiple_images.png +0 -0
- quickthumb-0.3.1/tests/snapshots/rich_text_with_background.png +0 -0
- quickthumb-0.3.1/tests/snapshots/shape_ellipse_basic.png +0 -0
- quickthumb-0.3.1/tests/snapshots/shape_rectangle_with_border_radius.png +0 -0
- quickthumb-0.3.1/tests/snapshots/shape_with_effects.png +0 -0
- quickthumb-0.3.1/tests/snapshots/shape_with_rotation.png +0 -0
- quickthumb-0.3.1/tests/snapshots/text_with_background_and_effects.png +0 -0
- quickthumb-0.3.1/tests/snapshots/text_with_background_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.claude/settings.json +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.claude/skills/tdd/SKILL.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.github/workflows/publish.yaml +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.gitignore +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.vscode/extensions.json +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/.vscode/settings.json +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/CLAUDE.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/LICENSE +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Black.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-BlackItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Bold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-BoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraLight.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ExtraLightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Italic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Light.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-LightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Medium.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-MediumItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Regular.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-SemiBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-SemiBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-Thin.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSans-ThinItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Black.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-BlackItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Bold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-BoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraLight.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ExtraLightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Italic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Light.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-LightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Medium.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-MediumItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Regular.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-SemiBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-SemiBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-Thin.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/NotoSerif-ThinItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Black.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-BlackItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Bold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-BoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraLight.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ExtraLightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Italic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Light.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-LightItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Medium.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-MediumItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Regular.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-SemiBold.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-SemiBoldItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-Thin.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/fonts/Roboto-ThinItalic.ttf +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/c-g-JgDUVGAXsso-unsplash.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/denise-jans-WIRvXd1PYlg-unsplash.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/assets/images/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/background.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/canvas.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/effects.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/enums.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/image.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/index.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/outline.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/shape.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/api/text.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/assets/examples/youtube_thumbnail_01.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/changelog.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/concepts.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/ai-workflow.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/index.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/instagram-card.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/podcast-promo.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/shorts-cover.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/webfonts-rembg.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/cookbook/youtube-thumbnail.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/faq.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/getting-started.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/index.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/installation.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/json-schema.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/docs/stylesheets/extra.css +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/README.md +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/instagram_news_card.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/podcast_interview_promo.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/shorts_cover_agent.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_reaction.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_talking_head.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_01.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_01.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_thumbnail_02.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/examples/youtube_tutorial_explainer.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/mkdocs.yml +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/__init__.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/errors.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/font_cache.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/quickthumb/models.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/__init__.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/conftest.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/ivana-cajina-_7LbC5J-jw4-unsplash.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/sample_image.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/fixtures/tobias-rademacher-wnF27F85ZKw-unsplash.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_bold.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_bold_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/arial_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/auto_scale_rich_text.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/auto_scale_simple_text.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_blur_heavy.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_blur_soft.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_combined_filters.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_contrast_high.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_contrast_low.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_filter_effect.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_saturation_grayscale.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/background_saturation_vivid.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_darken.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_lighten.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_multiply.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_normal.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_overlay.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/blend_mode_screen.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/bold_italic_with_named_font.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/bold_with_named_font.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/custom_canvas_layer.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/font_name_works.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_background_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_background_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_darken.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_lighten.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_multiply.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_normal.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_overlay.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_blend_mode_screen.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_brightness_decrease.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_brightness_increase.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_filter_effect.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_contain.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_cover.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_fit_fill.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_on_background_image.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_contain.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_cover.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_overlay_fit_fill.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_percentage_position.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_drop_shadow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_glow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_remove_background.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_stroke.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/image_with_text_overlay.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/italic_with_named_font.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/jpeg_format.jpg +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_brightness_decrease.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_brightness_increase.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_diagonal.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_horizontal.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_with_alpha.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/linear_gradient_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/multiline_text_with_glow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/multiline_text_with_shadow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/outline_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/outline_with_offset.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/percentage_with_alignment.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_brightness_decrease.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_brightness_increase.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_centered.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/radial_gradient_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_advanced_styles.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_alignment.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_different_colors.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_mixed_fonts.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_mixed_styles.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/rich_text_with_effects.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_bold.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_bold_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/roboto_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/shape_rectangle_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/shape_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_background.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_color_brightness_decrease.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/solid_color_brightness_increase.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_alignment.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_and_outline_with_opacity.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_bold_and_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_no_wrapping.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rendering.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_centered.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_rotation_with_effects.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_weight_black.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_weight_light.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_basic_shadow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_blurred_shadow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_glow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing_glow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_letter_spacing_shadow.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_line_height.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_multiple_glows.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_with_stroke.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_center_aligned.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_center_aligned_percentage.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_left_aligned.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/text_wrapping_right_aligned.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_bold.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_bold_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/times_new_roman_italic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/url_image_background_basic.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/webfont_basic_rendering.png +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/snapshots/webp_format.webp +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_background_layers.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_font_cache.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_image_layers.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_outline_layers.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_shape_layers.py +0 -0
- {quickthumb-0.3.1 → quickthumb-0.4.0}/tests/test_text_layers.py +0 -0
|
@@ -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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|
+
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
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -80,10 +80,11 @@
|
|
|
80
80
|
"font": "Roboto",
|
|
81
81
|
"size": 36,
|
|
82
82
|
"color": "#FFFFFF",
|
|
83
|
-
"position": ["23.4%", "10.
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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[
|
|
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(
|
|
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
|
-
|
|
283
|
-
raise ValidationError("Custom layers cannot be serialized to JSON")
|
|
298
|
+
import json as _json
|
|
284
299
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
draw
|
|
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
|
|
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
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|