Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ results = batch.save(

### Available Chainable Methods

BatchImage supports the same chainable methods as Image: `resize`, `thumbnail`, `cover`, `contain`, `crop`, `rotate`, `flip`, `mirror`, `grayscale`, `strip_metadata`, `watermark`, `text_overlay`, `brightness`, `contrast`, `saturation`, `sharpen`, `blur`, `sepia`, `rounded_corners`, `jpeg`, `png`, `webp`, `format`.
BatchImage supports the same chainable methods as Image: `resize`, `thumbnail`, `cover`, `contain`, `crop`, `rotate`, `flip`, `mirror`, `grayscale`, `strip_metadata`, `watermark`, `text_overlay`, `brightness`, `contrast`, `saturation`, `sharpen`, `blur`, `sepia`, `rounded_corners`, `jpeg`, `png`, `webp`, `gif`, `format`.

---

Expand All @@ -445,10 +445,12 @@ config.update(
allow_upscale=False, # bool, default: False
auto_orient=True, # bool, default: True - auto-apply EXIF rotation on load
strip_metadata=False, # bool, default: False - auto-strip metadata on load
max_input_size=50_000_000, # int bytes, default: 50MB
max_output_dimensions=10_000, # int pixels, default: 10000
max_input_size=50_000_000, # int bytes, default: 50MB - applies to uploads, files, bytes
max_pixels=178_956_970, # int pixels, default: ~179MP - decompression-bomb guard
max_output_dimensions=10_000, # int pixels, default: 10000 - cap on resize result width/height
url_timeout=30.0, # float seconds, default: 30.0
url_max_size=50_000_000, # int bytes, default: 50MB
url_max_size=50_000_000, # int bytes, default: 50MB - download aborts when exceeded
url_allowed_schemes=("http", "https"), # tuple[str, ...] - URL schemes accepted by from_url
)
```

Expand Down Expand Up @@ -614,8 +616,22 @@ img.save_responsive("static/hero/", [400, 800, 1200, 1600], name="hero")

5. **Format required for byte output.** `.to_bytes()`, `.to_base64()`, etc. need a format set (via `.jpeg()`, `.webp()`, etc.). `.save()` can infer from the file extension.

6. **auto_format is deferred.** `.auto_format()` doesn't pick the format immediately - it resolves when an output method is called. Images with alpha get PNG; photographic images get WebP or JPEG (whichever is smaller).
6. **auto_format is deferred.** `.auto_format()` doesn't pick the format immediately - it resolves when an output method is called. Images with alpha get PNG; photographic images get WebP or JPEG (whichever is smaller). `auto_format()` is honored by every output method, including `optimize()` and the Django/Flask/FastAPI response helpers.

7. **Presets are standalone.** `Image.preset.thumbnail("photo.jpg")` returns `bytes` directly - it doesn't return an `Image` instance. Presets load, process, and encode in one call.

8. **BatchImage records, doesn't chain Image instances.** Each operation on `BatchImage` records the method name and args. On `.save()`, it creates a fresh `Image` for each file and replays the recorded operations.

9. **Resize caps via `max_output_dimensions`.** Any resize/cover/contain/thumbnail call that would produce an output wider or taller than `config.max_output_dimensions` raises `ImageSizeError`. Bump the config if you need larger output.

10. **`responsive()` widths are measured against the post-pipeline image.** Operations queued before `.responsive(...)` execute first, so chained resizes/crops shrink the source the responsive set is derived from. Skip in-pipeline resizes if you want widths relative to the original.

11. **`from_url` is strict.** Only `http` and `https` schemes are accepted by default (set `config.url_allowed_schemes` to extend). The download streams in chunks and aborts as soon as `config.url_max_size` is exceeded; `Content-Length` headers above the cap short-circuit the request.

12. **Decompression bombs are blocked at load time.** `config.max_pixels` (default ~179MP, matching Pillow's own cap) rejects any source whose dimensions imply more than that many pixels before pixel data is decoded.

13. **`grayscale()` preserves alpha.** RGBA inputs stay RGBA - the alpha channel survives the conversion.

14. **`strip_metadata()` does not preserve animation frames.** Animated GIF/WebP inputs are collapsed to a single frame after stripping.

15. **Position arguments are validated.** `watermark()` and `text_overlay()` raise `ValueError` for unknown `position` values. `"tiled"` is only accepted by `watermark()`.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ build-backend = "hatchling.build"

[project]
name = "nitro-image"
version = "1.0.2"
description = "Fast, friendly image processing for web apps and SaaS"
version = "1.1.1"
description = "Resize, convert, and optimize images with a chainable API. Generate responsive sizes in one call."
readme = "README.md"
license = "BSD-3-Clause"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/nitro_img/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
)
from .types import Format, Position, ResizeStrategy

__version__ = "1.0.2"
__version__ = "1.1.1"

__all__ = [
"Image",
Expand Down
12 changes: 12 additions & 0 deletions src/nitro_img/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ def webp(self, quality: int | None = None) -> BatchImage:
self._output_quality = quality
return self

def gif(self) -> BatchImage:
"""Select GIF for every image in the batch.

Returns:
The same ``BatchImage`` for chaining.

Example:
>>> BatchImage("frames/*.png").gif().save("out/{name}.gif")
"""
self._output_format = Format.GIF
return self

def format(self, fmt: Format | str, quality: int | None = None) -> BatchImage:
"""Select the output format by enum or case-insensitive string.

Expand Down
15 changes: 13 additions & 2 deletions src/nitro_img/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ class Config:
Attributes:
max_input_size: Maximum accepted input size in bytes. Larger
inputs raise :class:`ImageSizeError`.
max_pixels: Maximum decoded pixel count for any loaded image.
Guards against decompression-bomb attacks where a tiny file
expands to a huge raster. Larger inputs raise
:class:`ImageSizeError`.
max_output_dimensions: Maximum width or height in pixels for any
resize result.
resize result. Operations that would exceed this raise
:class:`ImageSizeError`.
default_jpeg_quality: JPEG quality used when no explicit value is
passed to ``.jpeg()`` or an output method.
default_webp_quality: WebP quality used when no explicit value is
Expand All @@ -32,14 +37,19 @@ class Config:
the pipeline runs.
url_timeout: Request timeout in seconds for ``Image.from_url``.
url_max_size: Maximum accepted response size in bytes for
``Image.from_url``.
``Image.from_url``. The download is aborted as soon as this
cap is exceeded; the body is never fully buffered above it.
url_allowed_schemes: URL schemes accepted by ``Image.from_url``.
Defaults to HTTP and HTTPS to block ``file://`` and SSRF
vectors like the cloud metadata endpoint.

Example:
>>> from nitro_img import config
>>> config.update(default_webp_quality=75, allow_upscale=True)
"""

max_input_size: int = 50_000_000 # 50 MB
max_pixels: int = 178_956_970 # ~179 Mpx (Pillow's default cap)
max_output_dimensions: int = 10_000 # 10k px
default_jpeg_quality: int = 85
default_webp_quality: int = 80
Expand All @@ -49,6 +59,7 @@ class Config:
strip_metadata: bool = False
url_timeout: float = 30.0
url_max_size: int = 50_000_000 # 50 MB
url_allowed_schemes: tuple[str, ...] = ("http", "https")

def update(self, **kwargs: object) -> None:
"""Update one or more config fields in place.
Expand Down
Loading
Loading