From e356521f787e563ab0c8b43457cbd954a5637a5f Mon Sep 17 00:00:00 2001 From: Sean N Date: Fri, 17 Apr 2026 16:58:02 +0200 Subject: [PATCH] Added full docstrings for all public methods --- pyproject.toml | 2 +- src/nitro_img/__init__.py | 2 +- src/nitro_img/batch.py | 326 ++++++++++++++++- src/nitro_img/config.py | 44 +++ src/nitro_img/errors.py | 36 +- src/nitro_img/image.py | 739 +++++++++++++++++++++++++++++++++++++- src/nitro_img/presets.py | 141 +++++++- src/nitro_img/types.py | 35 ++ 8 files changed, 1289 insertions(+), 36 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f11be0a..3d47cc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nitro-image" -version = "1.0.1" +version = "1.0.2" description = "Fast, friendly image processing for web apps and SaaS" readme = "README.md" license = "BSD-3-Clause" diff --git a/src/nitro_img/__init__.py b/src/nitro_img/__init__.py index 2b37b4a..e329034 100644 --- a/src/nitro_img/__init__.py +++ b/src/nitro_img/__init__.py @@ -14,7 +14,7 @@ ) from .types import Format, Position, ResizeStrategy -__version__ = "1.0.1" +__version__ = "1.0.2" __all__ = [ "Image", diff --git a/src/nitro_img/batch.py b/src/nitro_img/batch.py index b6be7dd..baf45f1 100644 --- a/src/nitro_img/batch.py +++ b/src/nitro_img/batch.py @@ -12,12 +12,35 @@ class BatchImage: - """Process multiple images with the same pipeline. - - Operations are recorded and applied to each image on output. + """Apply the same pipeline to every file matching a glob pattern. + + Unlike :class:`Image`, ``BatchImage`` does not hold a shared + ``Pipeline``. Each chainable method records the call name and + arguments; on ``save`` a fresh ``Image`` is constructed for every + matched file and the recorded calls are replayed against it. + + The constructor raises immediately if the glob matches zero files + so batch mistakes surface before pipeline construction. + + Example: + >>> from nitro_img import BatchImage + >>> ( + ... BatchImage("photos/*.jpg") + ... .resize(800) + ... .webp(quality=80) + ... .save("output/{name}.webp", parallel=True) + ... ) """ def __init__(self, pattern: str) -> None: + """Build a batch from a glob pattern. + + Args: + pattern: A ``glob`` pattern (e.g. ``"photos/*.jpg"``). + + Raises: + ImageLoadError: If no files match the pattern. + """ self._paths = sorted(glob.glob(pattern)) if not self._paths: raise ImageLoadError(f"No files matched pattern: {pattern}") @@ -32,82 +55,357 @@ def _record(self, method: str, *args: object, **kwargs: object) -> BatchImage: # -- Chainable operations (recorded, not executed) -- def resize(self, width: int | None = None, height: int | None = None, **kw: object) -> BatchImage: + """Record a proportional resize applied to every file. + + See :meth:`Image.resize` for semantics. + + Args: + width: Target width in pixels. + height: Target height in pixels. + **kw: Keyword arguments forwarded to :meth:`Image.resize`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").resize(800).webp().save("out/{name}.webp") + """ return self._record("resize", width, height, **kw) def thumbnail(self, width: int, height: int, **kw: object) -> BatchImage: + """Record a thumbnail fit inside ``width`` x ``height`` for each file. + + See :meth:`Image.thumbnail` for semantics. + + Args: + width: Maximum width in pixels. + height: Maximum height in pixels. + **kw: Keyword arguments forwarded to :meth:`Image.thumbnail`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").thumbnail(200, 200).save("thumbs/{name}.jpg") + """ return self._record("thumbnail", width, height, **kw) def cover(self, width: int, height: int, **kw: object) -> BatchImage: + """Record a cover-fit resize applied to every file. + + See :meth:`Image.cover` for semantics. + + Args: + width: Target width in pixels. + height: Target height in pixels. + **kw: Keyword arguments forwarded to :meth:`Image.cover`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").cover(400, 400).save("squares/{name}.jpg") + """ return self._record("cover", width, height, **kw) def contain(self, width: int, height: int, bg: str = "white", **kw: object) -> BatchImage: + """Record a contain-fit resize applied to every file. + + See :meth:`Image.contain` for semantics. + + Args: + width: Target width in pixels. + height: Target height in pixels. + bg: Background color used for padding. + **kw: Keyword arguments forwarded to :meth:`Image.contain`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").contain(400, 400, bg="black").save("out/{name}.jpg") + """ return self._record("contain", width, height, bg, **kw) def crop(self, width: int, height: int, anchor: Anchor = "center") -> BatchImage: + """Record a crop applied to every file. + + See :meth:`Image.crop` for semantics. + + Args: + width: Crop width in pixels. + height: Crop height in pixels. + anchor: Crop anchor point. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").crop(500, 400, anchor="top").save("out/{name}.jpg") + """ return self._record("crop", width, height, anchor) def rotate(self, degrees: float, **kw: object) -> BatchImage: + """Record a rotation applied to every file. + + See :meth:`Image.rotate` for semantics. + + Args: + degrees: Rotation angle in degrees counter-clockwise. + **kw: Keyword arguments forwarded to :meth:`Image.rotate`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("scans/*.jpg").rotate(90).save("out/{name}.jpg") + """ return self._record("rotate", degrees, **kw) def flip(self) -> BatchImage: + """Record a vertical flip applied to every file. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").flip().save("out/{name}.jpg") + """ return self._record("flip") def mirror(self) -> BatchImage: + """Record a horizontal flip applied to every file. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").mirror().save("out/{name}.jpg") + """ return self._record("mirror") def grayscale(self) -> BatchImage: + """Record a grayscale conversion applied to every file. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").grayscale().save("out/{name}.jpg") + """ return self._record("grayscale") def strip_metadata(self) -> BatchImage: + """Record metadata stripping (EXIF/IPTC/XMP) for every file. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("uploads/*.jpg").strip_metadata().save("clean/{name}.jpg") + """ return self._record("strip_metadata") def brightness(self, factor: float) -> BatchImage: + """Record a brightness adjustment applied to every file. + + Args: + factor: Multiplier where ``1.0`` is unchanged. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").brightness(1.1).save("out/{name}.jpg") + """ return self._record("brightness", factor) def contrast(self, factor: float) -> BatchImage: + """Record a contrast adjustment applied to every file. + + Args: + factor: Multiplier where ``1.0`` is unchanged. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").contrast(1.15).save("out/{name}.jpg") + """ return self._record("contrast", factor) def saturation(self, factor: float) -> BatchImage: + """Record a saturation adjustment applied to every file. + + Args: + factor: Multiplier where ``1.0`` is unchanged. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").saturation(1.2).save("out/{name}.jpg") + """ return self._record("saturation", factor) def sharpen(self, factor: float = 2.0) -> BatchImage: + """Record a sharpness adjustment applied to every file. + + Args: + factor: Multiplier where ``1.0`` is unchanged. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").sharpen(1.3).save("out/{name}.jpg") + """ return self._record("sharpen", factor) def blur(self, radius: float = 2.0) -> BatchImage: + """Record a Gaussian blur applied to every file. + + Args: + radius: Blur radius in pixels. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").blur(3.0).save("out/{name}.jpg") + """ return self._record("blur", radius) def sepia(self) -> BatchImage: + """Record a sepia tone effect applied to every file. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").sepia().save("out/{name}.jpg") + """ return self._record("sepia") def rounded_corners(self, radius: int) -> BatchImage: + """Record rounded-corner masking applied to every file. + + Use PNG or WebP output to preserve the alpha channel. + + Args: + radius: Corner radius in pixels. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").rounded_corners(16).png().save("out/{name}.png") + """ return self._record("rounded_corners", radius) def watermark( self, source: object, position: str = "bottom-right", opacity: float = 0.3, scale: float | None = None, margin: int = 10, ) -> BatchImage: + """Record an image watermark applied to every file. + + See :meth:`Image.watermark` for parameter semantics. + + Args: + source: Path, ``Path``, or PIL image for the watermark. + position: Anchor position or ``"tiled"``. + opacity: Blend opacity between 0.0 and 1.0. + scale: Optional watermark width as a fraction of the base + image width. + margin: Distance from the anchored edge in pixels. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").watermark("logo.png").save("out/{name}.jpg") + """ return self._record("watermark", source, position, opacity, scale, margin) def text_overlay(self, text: str, **kw: object) -> BatchImage: + """Record a text overlay applied to every file. + + See :meth:`Image.text_overlay` for supported keyword arguments. + + Args: + text: Text to render on each image. + **kw: Keyword arguments forwarded to :meth:`Image.text_overlay`. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").text_overlay("Draft").save("out/{name}.jpg") + """ return self._record("text_overlay", text, **kw) # -- Format selection -- def jpeg(self, quality: int | None = None) -> BatchImage: + """Select JPEG for every image in the batch. + + Args: + quality: Encoder quality (1-100). Falls back to + ``config.default_jpeg_quality`` when None. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.png").resize(800).jpeg(quality=85).save("out/{name}.jpg") + """ self._output_format = Format.JPEG self._output_quality = quality return self def png(self) -> BatchImage: + """Select PNG for every image in the batch. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").png().save("out/{name}.png") + """ self._output_format = Format.PNG return self def webp(self, quality: int | None = None) -> BatchImage: + """Select WebP for every image in the batch. + + Args: + quality: Encoder quality (1-100). Falls back to + ``config.default_webp_quality`` when None. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").webp(quality=80).save("out/{name}.webp") + """ self._output_format = Format.WEBP self._output_quality = quality return self def format(self, fmt: Format | str, quality: int | None = None) -> BatchImage: + """Select the output format by enum or case-insensitive string. + + Args: + fmt: Desired format, either a :class:`Format` member or a + string such as ``"webp"``. + quality: Encoder quality for formats that support it. + + Returns: + The same ``BatchImage`` for chaining. + + Example: + >>> BatchImage("photos/*.jpg").format("webp", quality=75).save("out/{name}.webp") + """ if isinstance(fmt, str): fmt = Format(fmt.upper()) self._output_format = fmt @@ -134,11 +432,25 @@ def _process_one(self, src_path: str, pattern: str) -> Path: return img.save(out_path) def save(self, pattern: str, *, parallel: bool = False, max_workers: int | None = None) -> list[Path]: - """Save all images. Use {name} in pattern for the original filename stem. + """Save every matched image through the recorded pipeline. Args: - parallel: If True, process images in parallel using threads. - max_workers: Max thread count (default: min(count, 8)). + pattern: Destination path pattern; the substring ``{name}`` + is replaced with each source file's stem. + parallel: When True, process files concurrently using a + thread pool. Useful for IO-bound batches; CPU-bound + batches may not benefit because of the GIL. + max_workers: Thread pool size used when ``parallel`` is True. + Defaults to ``min(count, 8)``. + + Returns: + A list of destination paths in the order of the matched + source files. + + Example: + >>> BatchImage("photos/*.jpg").resize(800).webp().save( + ... "out/{name}.webp", parallel=True + ... ) """ if not parallel: return [self._process_one(p, pattern) for p in self._paths] @@ -158,8 +470,10 @@ def save(self, pattern: str, *, parallel: bool = False, max_workers: int | None @property def count(self) -> int: + """Number of files matched by the glob pattern.""" return len(self._paths) @property def paths(self) -> list[str]: + """List of matched source file paths, sorted alphabetically.""" return list(self._paths) diff --git a/src/nitro_img/config.py b/src/nitro_img/config.py index 45c8a6e..4784f0e 100644 --- a/src/nitro_img/config.py +++ b/src/nitro_img/config.py @@ -7,6 +7,38 @@ @dataclass class Config: + """Process-wide defaults for load, processing, and output behaviour. + + A single instance is exposed as ``nitro_img.config``. Fields are + read at operation time, so calling ``config.update(...)`` takes + effect for every subsequent operation without recreating the + ``Image`` instance. + + Attributes: + max_input_size: Maximum accepted input size in bytes. Larger + inputs raise :class:`ImageSizeError`. + max_output_dimensions: Maximum width or height in pixels for any + resize result. + 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 + passed to ``.webp()``. + default_png_compression: PNG ``zlib`` compression level (0-9). + allow_upscale: Permit enlarging images beyond their native + dimensions during resize operations. + auto_orient: Apply EXIF orientation to loaded images so downstream + operations see upright pixels. + strip_metadata: Strip EXIF/IPTC/XMP from every loaded image before + 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``. + + 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_output_dimensions: int = 10_000 # 10k px default_jpeg_quality: int = 85 @@ -19,6 +51,18 @@ class Config: url_max_size: int = 50_000_000 # 50 MB def update(self, **kwargs: object) -> None: + """Update one or more config fields in place. + + Args: + **kwargs: Field names and their new values. Every key must + match an existing attribute on :class:`Config`. + + Raises: + ValueError: If a keyword does not match a known config field. + + Example: + >>> config.update(default_jpeg_quality=90, allow_upscale=True) + """ for key, value in kwargs.items(): if not hasattr(self, key): raise ValueError(f"Unknown config option: {key}") diff --git a/src/nitro_img/errors.py b/src/nitro_img/errors.py index 8326b4d..c26e821 100644 --- a/src/nitro_img/errors.py +++ b/src/nitro_img/errors.py @@ -2,24 +2,48 @@ class NitroImgError(Exception): - """Base exception for all nitro-img errors.""" + """Base class for every exception raised by nitro-img. + + Catch this to handle any library-originated failure in one except + clause while letting unrelated exceptions propagate. + """ class ImageLoadError(NitroImgError): - """Cannot read or decode the input image.""" + """Raised when the source image cannot be read or decoded. + + Covers missing files, unreadable bytes, unsupported containers, and + failed network fetches in ``Image.from_url``. + """ class ImageFormatError(NitroImgError): - """Unsupported or invalid image format.""" + """Raised when the output format is missing, invalid, or unsupported. + + Typically raised by output methods when no explicit format was set + and none could be inferred from the save path or source image. + """ class ImageSizeError(NitroImgError): - """Image exceeds configured size limits.""" + """Raised when an image exceeds a configured size limit. + + Limits are controlled by ``config.max_input_size``, + ``config.max_output_dimensions``, and ``config.url_max_size``. + """ class ImageProcessingError(NitroImgError): - """An operation failed during pipeline execution.""" + """Raised when a queued pipeline operation fails during execution. + + Wraps the underlying exception so callers can distinguish operation + failures from load/encode failures while retaining the cause chain. + """ class ImageOutputError(NitroImgError): - """Cannot write or encode the output image.""" + """Raised when the processed image cannot be written or encoded. + + Covers disk write errors, unsupported encoder combinations, and + framework response construction failures. + """ diff --git a/src/nitro_img/image.py b/src/nitro_img/image.py index 18b6a05..5de6f63 100644 --- a/src/nitro_img/image.py +++ b/src/nitro_img/image.py @@ -28,8 +28,26 @@ class Image: """Chainable image processing interface with lazy execution. - Operations are queued and only executed when an output method - (save, to_bytes, to_base64, to_data_uri, to_response) is called. + Each ``Image`` wraps an original Pillow image plus a queue of pending + operations. Transform methods (``resize``, ``brightness``, ...) append + to the queue and return ``self`` so calls can be chained. No pixels + are touched until an output method (``save``, ``to_bytes``, + ``to_base64``, ``to_data_uri``, ``to_response``, ``responsive``, ...) + is called, which runs the full pipeline once against a fresh copy of + the source. + + Construct an instance from a filesystem path, or use the ``from_*`` + class methods for bytes, base64 strings, URLs, or file-like objects. + + Example: + >>> from nitro_img import Image + >>> ( + ... Image("photo.jpg") + ... .resize(800) + ... .brightness(1.1) + ... .webp(quality=80) + ... .save("photo.webp") + ... ) """ # Class-level preset accessor @@ -37,6 +55,15 @@ class Image: preset = _Presets() def __init__(self, source: str | Path) -> None: + """Load an image from a filesystem path. + + Args: + source: Path to an image file on disk. + + Raises: + ImageLoadError: If the file cannot be read or decoded. + ImageSizeError: If the file exceeds ``config.max_input_size``. + """ img, fmt, path = loaders.load_from_path(source) self._original: PILImage.Image = img self._source_format: Format | None = fmt @@ -67,21 +94,93 @@ def _new_instance( @classmethod def from_bytes(cls, data: bytes) -> Image: + """Create an ``Image`` from raw encoded image bytes. + + Args: + data: Encoded image bytes (JPEG, PNG, WebP, ...). + + Returns: + A new ``Image`` instance with an empty pipeline. + + Raises: + ImageLoadError: If the bytes cannot be decoded. + + Example: + >>> Image.from_bytes(response.content).resize(400).to_bytes() + """ img, fmt, _ = loaders.load_from_bytes(data) return cls._new_instance(img, fmt) @classmethod def from_file(cls, file_obj: IO[bytes]) -> Image: + """Create an ``Image`` from a binary file-like object. + + Reads the stream fully, so the caller keeps ownership of the file + handle but should not reuse the stream position. + + Args: + file_obj: Any object with a ``read`` method returning bytes + (e.g. Django's ``UploadedFile`` or Flask's + ``FileStorage``). + + Returns: + A new ``Image`` instance. + + Raises: + ImageLoadError: If the stream cannot be decoded. + + Example: + >>> with open("photo.jpg", "rb") as f: + ... img = Image.from_file(f) + """ img, fmt, _ = loaders.load_from_file(file_obj) return cls._new_instance(img, fmt) @classmethod def from_base64(cls, b64_string: str) -> Image: + """Create an ``Image`` from a base64-encoded string. + + Accepts both plain base64 and full data URIs + (``data:image/...;base64,...``). + + Args: + b64_string: Base64 text, optionally prefixed with a data URI + scheme. + + Returns: + A new ``Image`` instance. + + Raises: + ImageLoadError: If the string cannot be decoded to a valid + image. + + Example: + >>> img = Image.from_base64(data_uri) + """ img, fmt, _ = loaders.load_from_base64(b64_string) return cls._new_instance(img, fmt) @classmethod def from_url(cls, url: str) -> Image: + """Fetch and load an image from a URL. + + Requires the ``url`` extra: ``pip install nitro-image[url]``. + Download honours ``config.url_timeout`` and ``config.url_max_size``. + + Args: + url: HTTP(S) URL pointing to an image. + + Returns: + A new ``Image`` instance. + + Raises: + ImageLoadError: If the download fails or the response is not + a valid image. + ImageSizeError: If the response exceeds ``config.url_max_size``. + + Example: + >>> img = Image.from_url("https://example.com/photo.jpg") + """ img, fmt, _ = loaders.load_from_url(url) return cls._new_instance(img, fmt) @@ -94,6 +193,24 @@ def resize( *, allow_upscale: bool | None = None, ) -> Image: + """Queue a proportional resize, preserving aspect ratio. + + Supply one dimension to scale proportionally, or both to fit the + image inside the given box. Downscales use Lanczos resampling; + upscales are blocked by default because they degrade quality. + + Args: + width: Target width in pixels, or None to derive from height. + height: Target height in pixels, or None to derive from width. + allow_upscale: Permit enlarging past the source size. Falls + back to ``config.allow_upscale`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").resize(800).save("out.jpg") + """ up = allow_upscale if allow_upscale is not None else config.allow_upscale self._pipeline.add( "resize", @@ -108,6 +225,24 @@ def thumbnail( *, allow_upscale: bool | None = None, ) -> Image: + """Queue an in-place thumbnail fit inside a box. + + Mirrors Pillow's ``thumbnail`` semantics: shrinks the image so + both dimensions fit inside ``width`` x ``height`` while keeping + aspect ratio. + + Args: + width: Maximum width in pixels. + height: Maximum height in pixels. + allow_upscale: Permit enlarging images smaller than the box. + Falls back to ``config.allow_upscale`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").thumbnail(200, 200).save("thumb.jpg") + """ up = allow_upscale if allow_upscale is not None else config.allow_upscale self._pipeline.add( "thumbnail", @@ -122,6 +257,23 @@ def cover( *, allow_upscale: bool | None = None, ) -> Image: + """Queue a cover-fit resize, centre-cropping overflow. + + The output exactly matches ``width`` x ``height``; content that + does not fit is cropped equally from both sides. + + Args: + width: Target width in pixels. + height: Target height in pixels. + allow_upscale: Permit enlarging small images to fill the + target. Falls back to ``config.allow_upscale`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").cover(400, 400).save("square.jpg") + """ up = allow_upscale if allow_upscale is not None else config.allow_upscale self._pipeline.add( "cover", @@ -137,6 +289,25 @@ def contain( *, allow_upscale: bool | None = None, ) -> Image: + """Queue a contain-fit resize, padding empty space. + + The image fits entirely inside the box; any letterboxing is + filled with ``bg``. Aspect ratio is preserved. + + Args: + width: Target width in pixels. + height: Target height in pixels. + bg: Background color for padding (any value accepted by + ``PIL.ImageColor``). + allow_upscale: Permit enlarging small images. Falls back to + ``config.allow_upscale`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").contain(400, 400, bg="black").save("out.jpg") + """ up = allow_upscale if allow_upscale is not None else config.allow_upscale self._pipeline.add( "contain", @@ -152,35 +323,110 @@ def crop( height: int, anchor: Anchor = "center", ) -> Image: + """Queue a crop to the given dimensions at an anchor point. + + Args: + width: Crop width in pixels. + height: Crop height in pixels. + anchor: Where to anchor the crop box — one of ``"center"``, + ``"top"``, ``"bottom"``, ``"left"``, ``"right"``, or any + corner such as ``"top-left"``. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").crop(500, 400, anchor="top-left").save("out.jpg") + """ self._pipeline.add("crop", crop_ops.crop(width, height, anchor)) return self # -- Transform operations -- def rotate(self, degrees: float, *, expand: bool = True, fill: str = "white") -> Image: + """Queue a rotation by the given number of degrees counter-clockwise. + + Args: + degrees: Rotation angle; positive values rotate + counter-clockwise. + expand: Enlarge the canvas so no pixels are clipped. Set to + False to keep the original dimensions. + fill: Color used to fill exposed corners when the canvas is + expanded or rotation is not a multiple of 90°. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").rotate(45, fill="black").save("tilted.jpg") + """ self._pipeline.add("rotate", transform_ops.rotate(degrees, expand=expand, fill=fill)) return self def flip(self) -> Image: + """Queue a vertical flip (top-to-bottom). + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").flip().save("flipped.jpg") + """ self._pipeline.add("flip", transform_ops.flip()) return self def mirror(self) -> Image: + """Queue a horizontal flip (left-to-right). + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").mirror().save("mirrored.jpg") + """ self._pipeline.add("mirror", transform_ops.mirror()) return self def grayscale(self) -> Image: + """Queue a grayscale conversion. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").grayscale().save("gray.jpg") + """ self._pipeline.add("grayscale", transform_ops.grayscale()) return self # -- Metadata operations -- def strip_metadata(self) -> Image: + """Queue removal of EXIF, IPTC, and XMP metadata from the output. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").strip_metadata().jpeg().save("clean.jpg") + """ self._pipeline.add("strip_metadata", metadata_ops.strip_metadata()) return self def get_metadata(self) -> dict: - """Read metadata from the original image (executes immediately).""" + """Return metadata from the original source image. + + Reads from the untransformed original — the pipeline is not run + and any queued ``strip_metadata`` is ignored. + + Returns: + Dict with ``width``, ``height``, ``mode``, ``format`` keys, + plus ``exif`` when the source has EXIF data. + + Example: + >>> Image("photo.jpg").get_metadata()["width"] + 1920 + """ return metadata_ops.get_metadata(self._original) # -- Overlay operations -- @@ -193,6 +439,26 @@ def watermark( scale: float | None = None, margin: int = 10, ) -> Image: + """Queue an image watermark overlay. + + Args: + source: Path, ``Path``, or already-loaded Pillow image to + place on top of the base image. + position: Anchor such as ``"bottom-right"`` or the literal + ``"tiled"`` for a repeating pattern. + opacity: Blending opacity between 0.0 (invisible) and 1.0 + (opaque). + scale: If set, resize the watermark so its width is this + fraction of the base image width. + margin: Distance in pixels from the anchored edge; ignored + when ``position="tiled"``. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").watermark("logo.png", position="bottom-right").save("out.jpg") + """ self._pipeline.add( "watermark", overlay_ops.watermark(source, position, opacity, scale, margin), @@ -209,6 +475,25 @@ def text_overlay( opacity: float = 1.0, margin: int = 10, ) -> Image: + """Queue a text overlay drawn on top of the image. + + Args: + text: The text to render. + position: Anchor such as ``"center"`` or ``"bottom-right"``. + font_path: Path to a TrueType/OpenType font file. When None, + Pillow's default bitmap font is used. + font_size: Font size in pixels. + color: Text color; any value accepted by ``PIL.ImageColor`` + or an ``(R, G, B[, A])`` tuple. + opacity: Blending opacity between 0.0 and 1.0. + margin: Distance in pixels from the anchored edge. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").text_overlay("Draft", font_size=48, color="red").save("out.jpg") + """ self._pipeline.add( "text_overlay", overlay_ops.text_overlay( @@ -220,32 +505,113 @@ def text_overlay( # -- Adjustment operations -- def brightness(self, factor: float) -> Image: + """Queue a brightness adjustment. + + Args: + factor: Multiplier where ``1.0`` is unchanged, values above + 1.0 brighten, and values below 1.0 darken. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").brightness(1.2).save("brighter.jpg") + """ self._pipeline.add("brightness", adjust_ops.brightness(factor)) return self def contrast(self, factor: float) -> Image: + """Queue a contrast adjustment. + + Args: + factor: Multiplier where ``1.0`` is unchanged; higher values + increase contrast, lower values flatten it. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").contrast(1.15).save("punchier.jpg") + """ self._pipeline.add("contrast", adjust_ops.contrast(factor)) return self def saturation(self, factor: float) -> Image: + """Queue a saturation adjustment. + + Args: + factor: Multiplier where ``1.0`` is unchanged, ``0.0`` + produces grayscale, and larger values produce more + vivid colors. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").saturation(1.3).save("vivid.jpg") + """ self._pipeline.add("saturation", adjust_ops.saturation(factor)) return self def sharpen(self, factor: float = 2.0) -> Image: + """Queue a sharpness adjustment. + + Args: + factor: Multiplier where ``1.0`` is unchanged; higher values + sharpen, lower values soften. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").sharpen(1.5).save("sharp.jpg") + """ self._pipeline.add("sharpen", adjust_ops.sharpen(factor)) return self def blur(self, radius: float = 2.0) -> Image: + """Queue a Gaussian blur. + + Args: + radius: Blur radius in pixels; larger values blur more. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").blur(3.0).save("soft.jpg") + """ self._pipeline.add("blur", adjust_ops.blur(radius)) return self # -- Effect operations -- def sepia(self) -> Image: + """Queue a sepia tone effect. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").sepia().save("vintage.jpg") + """ self._pipeline.add("sepia", effects_ops.sepia()) return self def rounded_corners(self, radius: int) -> Image: + """Queue rounded-corner masking, producing an alpha channel. + + Use a transparency-capable output format such as PNG or WebP. + + Args: + radius: Corner radius in pixels. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").rounded_corners(20).png().save("rounded.png") + """ self._pipeline.add("rounded_corners", effects_ops.rounded_corners(radius)) return self @@ -259,7 +625,28 @@ def responsive( quality: int | None = None, allow_upscale: bool = False, ) -> dict[int, bytes]: - """Generate multiple sizes and return {width: bytes}. Executes pipeline immediately.""" + """Render the pipeline at multiple widths and return the encoded bytes. + + Executes the pipeline immediately. Widths above the original size + are clamped when ``allow_upscale`` is False, which may collapse + duplicate entries in the returned dict. + + Args: + widths: Target widths in pixels. Defaults to + ``[320, 640, 1024, 1920]``. + fmt: Output format. Falls back to the format set via + ``.jpeg()``/``.webp()``/... or the source format. + quality: Encoder quality applied to every width. + allow_upscale: Permit widths larger than the source width. + + Returns: + Mapping of effective width to encoded bytes. + + Example: + >>> data = Image("photo.jpg").responsive([400, 800, 1200]) + >>> sorted(data) + [400, 800, 1200] + """ from .responsive import generate_responsive if widths is None: widths = [320, 640, 1024, 1920] @@ -280,7 +667,28 @@ def save_responsive( quality: int | None = None, allow_upscale: bool = False, ) -> dict[int, Path]: - """Generate multiple sizes and save to disk. Returns {width: Path}.""" + """Render the pipeline at multiple widths and save each to disk. + + Files are written as ``{name}_{width}.{ext}`` inside + ``output_dir``, which is created if it does not exist. + + Args: + output_dir: Destination directory for the generated files. + widths: Target widths in pixels. Defaults to + ``[320, 640, 1024, 1920]``. + name: Filename prefix; defaults to the source filename stem, + or ``"image"`` when the source is not a path. + fmt: Output format. Falls back to the format set via + ``.jpeg()``/``.webp()``/... or the source format. + quality: Encoder quality applied uniformly. + allow_upscale: Permit widths larger than the source width. + + Returns: + Mapping of effective width to the saved ``Path``. + + Example: + >>> Image("hero.jpg").save_responsive("static/", [400, 800, 1200]) + """ from .responsive import save_responsive if widths is None: widths = [320, 640, 1024, 1920] @@ -297,34 +705,119 @@ def save_responsive( # -- Placeholders (execute immediately) -- def lqip(self, width: int = 20) -> str: - """Generate a Low Quality Image Placeholder as a base64 data URI.""" + """Return a Low Quality Image Placeholder as a base64 data URI. + + Produces a tiny, inline-friendly data URI suitable for + progressive image loading. Runs on the untransformed source. + + Args: + width: Width in pixels of the placeholder (default 20). + + Returns: + A ``data:image/webp;base64,...`` URI. + + Example: + >>> uri = Image("photo.jpg").lqip() + >>> uri.startswith("data:image/") + True + """ from .placeholder import lqip return lqip(self._original, width) def dominant_color(self) -> str: - """Extract the dominant color as a hex string.""" + """Return the dominant color of the image as a hex string. + + Returns: + A lowercased ``#rrggbb`` color string. + + Example: + >>> Image("photo.jpg").dominant_color() + '#3a6b8c' + """ from .placeholder import dominant_color return dominant_color(self._original) def color_palette(self, count: int = 5) -> list[str]: - """Extract a color palette as a list of hex strings.""" + """Return the top ``count`` colors as a list of hex strings. + + Args: + count: Number of palette entries to return. + + Returns: + List of ``#rrggbb`` strings ordered by prominence. + + Example: + >>> Image("photo.jpg").color_palette(3) + ['#3a6b8c', '#d4a574', '#1a1a1a'] + """ from .placeholder import color_palette return color_palette(self._original, count) def svg_placeholder(self, width: int | None = None, height: int | None = None) -> str: - """Generate a lightweight SVG placeholder with the dominant color.""" + """Return a tiny SVG placeholder filled with the dominant color. + + The SVG matches the source aspect ratio unless both ``width`` and + ``height`` are supplied. + + Args: + width: Optional SVG width attribute (defaults to the source + width). + height: Optional SVG height attribute (defaults to the source + height). + + Returns: + An inline SVG string. + + Example: + >>> Image("photo.jpg").svg_placeholder(width=800, height=600) # doctest: +ELLIPSIS + ' str: - """Generate a BlurHash string. Requires blurhash-python.""" + """Return a BlurHash string representing the source image. + + Requires the ``blur`` extra: ``pip install nitro-image[blur]``. + + Args: + components_x: Number of horizontal BlurHash components (1-9). + components_y: Number of vertical BlurHash components (1-9). + + Returns: + A BlurHash string suitable for client-side decoding. + + Raises: + ImportError: If ``blurhash-python`` is not installed. + + Example: + >>> Image("photo.jpg").blurhash() # doctest: +SKIP + 'LKO2:N%2Tw=w]~RBVZRi...' + """ from .placeholder import blurhash return blurhash(self._original, components_x, components_y) # -- Optimization -- def optimize(self, target_kb: int, *, min_quality: int = 10, max_quality: int = 95) -> bytes: - """Auto-reduce quality to hit a target file size. Executes pipeline immediately.""" + """Encode while binary-searching quality to hit a target file size. + + Runs the pipeline, then repeatedly encodes at different quality + levels until the output is at or below ``target_kb`` kilobytes. + + Args: + target_kb: Desired maximum output size in kilobytes. + min_quality: Lowest quality to try before giving up. + max_quality: Highest quality to try first. + + Returns: + Encoded bytes, as close to ``target_kb`` as quality allows. + + Example: + >>> data = Image("photo.jpg").resize(1200).optimize(target_kb=200) + >>> len(data) <= 200 * 1024 + True + """ fmt = self._resolve_format() img = self._execute() data, _ = optimize_mod.optimize( @@ -333,7 +826,18 @@ def optimize(self, target_kb: int, *, min_quality: int = 10, max_quality: int = return data def auto_format(self) -> Image: - """Pick the best format for the image content (WebP > JPEG for photos, PNG for alpha).""" + """Defer format choice until output time, picking the best fit. + + At output time the image is inspected: images with alpha prefer + PNG, photographic images prefer the smaller of WebP and JPEG. + The choice is remembered only for the current output call. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").auto_format().save("out.webp") + """ # Deferred — we resolve at output time self._auto_format = True return self @@ -341,24 +845,77 @@ def auto_format(self) -> Image: # -- Format selection -- def jpeg(self, quality: int | None = None) -> Image: + """Select JPEG as the output format. + + Args: + quality: Encoder quality (1-100). Falls back to + ``config.default_jpeg_quality`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.png").jpeg(quality=90).save("photo.jpg") + """ self._output_format = Format.JPEG self._output_quality = quality return self def png(self) -> Image: + """Select PNG as the output format. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").png().save("photo.png") + """ self._output_format = Format.PNG return self def webp(self, quality: int | None = None) -> Image: + """Select WebP as the output format. + + Args: + quality: Encoder quality (1-100). Falls back to + ``config.default_webp_quality`` when None. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").webp(quality=80).save("photo.webp") + """ self._output_format = Format.WEBP self._output_quality = quality return self def gif(self) -> Image: + """Select GIF as the output format. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.png").gif().save("photo.gif") + """ self._output_format = Format.GIF return self def format(self, fmt: Format | str, quality: int | None = None) -> Image: + """Select an output format by enum or case-insensitive string. + + Args: + fmt: Desired format, either a :class:`Format` member or a + string such as ``"webp"``. + quality: Encoder quality for formats that support it. + + Returns: + The same ``Image`` for chaining. + + Example: + >>> Image("photo.jpg").format("webp", quality=80).save("photo.webp") + """ if isinstance(fmt, str): fmt = Format(fmt.upper()) self._output_format = fmt @@ -386,6 +943,25 @@ def _execute(self) -> PILImage.Image: return self._pipeline.execute(self._original.copy()) def save(self, path: str | Path) -> Path: + """Run the pipeline and write the result to disk. + + The output format is resolved from the explicit format method, + then ``auto_format``, then the file extension, then the source + format. Parent directories are created as needed. + + Args: + path: Destination file path. + + Returns: + The resolved destination ``Path``. + + Raises: + ImageFormatError: If no format can be resolved. + ImageOutputError: If the file cannot be written. + + Example: + >>> Image("photo.jpg").resize(800).save("out/photo.webp") + """ img = self._execute() if self._auto_format and self._output_format is None: data, fmt = optimize_mod.auto_format(img, quality=self._output_quality) @@ -397,6 +973,20 @@ def save(self, path: str | Path) -> Path: return export.save(img, path, fmt, quality=self._output_quality) def to_bytes(self) -> bytes: + """Run the pipeline and return the encoded image bytes. + + Requires an output format set via ``.jpeg()``/``.png()``/..., + ``.auto_format()``, or inherited from the source. + + Returns: + Encoded image bytes. + + Raises: + ImageFormatError: If no format can be resolved. + + Example: + >>> data = Image("photo.jpg").resize(400).webp().to_bytes() + """ img = self._execute() if self._auto_format and self._output_format is None: data, _ = optimize_mod.auto_format(img, quality=self._output_quality) @@ -405,6 +995,19 @@ def to_bytes(self) -> bytes: return export.to_bytes(img, fmt, quality=self._output_quality) def to_base64(self) -> str: + """Run the pipeline and return a base64-encoded string. + + Returns: + The encoded image as a base64 ASCII string (no data URI + prefix). + + Raises: + ImageFormatError: If no format can be resolved. + + Example: + >>> Image("photo.jpg").resize(100).webp().to_base64() # doctest: +ELLIPSIS + 'UklGR...' + """ img = self._execute() if self._auto_format and self._output_format is None: import base64 @@ -414,6 +1017,20 @@ def to_base64(self) -> str: return export.to_base64(img, fmt, quality=self._output_quality) def to_data_uri(self) -> str: + """Run the pipeline and return an inline data URI. + + Returns: + A ``data:image/...;base64,...`` string suitable for embedding + directly in HTML or CSS. + + Raises: + ImageFormatError: If no format can be resolved. + + Example: + >>> uri = Image("photo.jpg").thumbnail(200, 200).webp().to_data_uri() + >>> uri.startswith("data:image/webp;base64,") + True + """ img = self._execute() if self._auto_format and self._output_format is None: import base64 as b64mod @@ -425,6 +1042,20 @@ def to_data_uri(self) -> str: return export.to_data_uri(img, fmt, quality=self._output_quality) def to_response(self) -> dict: + """Run the pipeline and return a framework-agnostic response dict. + + Returns: + Dict with ``body`` (bytes), ``content_type`` (str), and + ``content_length`` (int) keys. + + Raises: + ImageFormatError: If no format can be resolved. + + Example: + >>> resp = Image("photo.jpg").resize(400).webp().to_response() + >>> resp["content_type"] + 'image/webp' + """ img = self._execute() if self._auto_format and self._output_format is None: from .utils import mime_type @@ -440,21 +1071,77 @@ def to_response(self) -> dict: # -- Framework integration responses -- def to_django_response(self, *, filename: str | None = None) -> object: - """Return a Django HttpResponse. Requires Django.""" + """Run the pipeline and return a Django ``HttpResponse``. + + Requires Django to be installed. + + Args: + filename: Optional download filename; sets a + ``Content-Disposition: inline`` header when provided. + + Returns: + A ``django.http.HttpResponse`` instance. + + Raises: + ImportError: If Django is not installed. + ImageFormatError: If no format can be resolved. + + Example: + >>> def view(request): # doctest: +SKIP + ... return Image("photo.jpg").resize(400).webp().to_django_response() + """ from .integrations import to_django_response fmt = self._resolve_format() img = self._execute() return to_django_response(img, fmt, quality=self._output_quality, filename=filename) def to_flask_response(self, *, filename: str | None = None) -> object: - """Return a Flask Response. Requires Flask.""" + """Run the pipeline and return a Flask ``Response``. + + Requires Flask to be installed. + + Args: + filename: Optional download filename; sets a + ``Content-Disposition: inline`` header when provided. + + Returns: + A ``flask.Response`` instance. + + Raises: + ImportError: If Flask is not installed. + ImageFormatError: If no format can be resolved. + + Example: + >>> @app.route("/thumb") # doctest: +SKIP + ... def thumb(): + ... return Image("photo.jpg").resize(400).webp().to_flask_response() + """ from .integrations import to_flask_response fmt = self._resolve_format() img = self._execute() return to_flask_response(img, fmt, quality=self._output_quality, filename=filename) def to_fastapi_response(self, *, filename: str | None = None) -> object: - """Return a FastAPI/Starlette Response. Requires FastAPI.""" + """Run the pipeline and return a FastAPI/Starlette ``Response``. + + Requires Starlette (installed transitively with FastAPI). + + Args: + filename: Optional download filename; sets a + ``Content-Disposition: inline`` header when provided. + + Returns: + A ``starlette.responses.Response`` instance. + + Raises: + ImportError: If Starlette is not installed. + ImageFormatError: If no format can be resolved. + + Example: + >>> @app.get("/thumb") # doctest: +SKIP + ... async def thumb(): + ... return Image("photo.jpg").resize(400).webp().to_fastapi_response() + """ from .integrations import to_fastapi_response fmt = self._resolve_format() img = self._execute() @@ -464,18 +1151,40 @@ def to_fastapi_response(self, *, filename: str | None = None) -> object: @property def width(self) -> int: + """Width of the original source image in pixels. + + Reflects the untransformed source; queued pipeline operations do + not affect this value. + """ return self._original.size[0] @property def height(self) -> int: + """Height of the original source image in pixels. + + Reflects the untransformed source; queued pipeline operations do + not affect this value. + """ return self._original.size[1] @property def size(self) -> tuple[int, int]: + """Size of the original source image as ``(width, height)``. + + Reflects the untransformed source; queued pipeline operations do + not affect this value. + """ return self._original.size @property def source_format(self) -> Format | None: + """Detected format of the original source image, if known. + + Returns: + The :class:`Format` decoded from the source, or None when the + format could not be inferred (for example, raw bytes without + a recognisable signature). + """ return self._source_format def __repr__(self) -> str: diff --git a/src/nitro_img/presets.py b/src/nitro_img/presets.py index ff5eb59..2797376 100644 --- a/src/nitro_img/presets.py +++ b/src/nitro_img/presets.py @@ -26,7 +26,20 @@ def _load(source: str | Path | bytes | IO[bytes]) -> tuple[PILImage.Image, Forma class Presets: - """Opinionated, one-call solutions for common image tasks.""" + """One-call helpers for the most common image tasks. + + Each preset loads the source, runs a fixed pipeline, and returns the + encoded bytes (or, for ``responsive``, a dict of saved paths). Use + these when you want a sensible default without composing a pipeline. + + Access the shared instance as ``nitro_img.presets`` or + ``Image.preset``. + + Example: + >>> from nitro_img import presets + >>> thumb = presets.thumbnail("photo.jpg") + >>> og = presets.og_image("photo.jpg") + """ @staticmethod def thumbnail( @@ -37,7 +50,26 @@ def thumbnail( fmt: Format = Format.WEBP, quality: int | None = None, ) -> bytes: - """Generate a thumbnail that fits within width x height.""" + """Generate a thumbnail that fits within ``width`` by ``height``. + + Preserves aspect ratio and strips metadata. Returns the encoded + bytes — does not write to disk. + + Args: + source: File path, bytes, or file-like object. + width: Maximum width of the thumbnail in pixels. + height: Maximum height of the thumbnail in pixels. + fmt: Output image format. + quality: Encoder quality (format-specific); falls back to + the matching config default when None. + + Returns: + Encoded image bytes. + + Example: + >>> from nitro_img import presets + >>> data = presets.thumbnail("photo.jpg", width=150, height=150) + """ img, _ = _load(source) img = resize_fit(width, height)(img) img = _strip_metadata_op()(img) @@ -51,7 +83,25 @@ def avatar( fmt: Format = Format.PNG, quality: int | None = None, ) -> bytes: - """Circle-crop, centered, square avatar.""" + """Produce a square, circle-cropped avatar. + + Cover-crops to a square, then applies a circular alpha mask, so + PNG or WebP output is recommended to preserve transparency. + + Args: + source: File path, bytes, or file-like object. + size: Side length of the square avatar in pixels. + fmt: Output format; keep transparency-capable formats for the + circle mask. + quality: Encoder quality (format-specific). + + Returns: + Encoded avatar bytes. + + Example: + >>> from nitro_img import presets + >>> avatar = presets.avatar("user.jpg", size=256) + """ img, _ = _load(source) # Cover to fill the square, then circle-mask img = cover(size, size, allow_upscale=True)(img) @@ -73,7 +123,27 @@ def avatar_placeholder( *, fmt: Format = Format.PNG, ) -> bytes: - """Generate an avatar placeholder with initials.""" + """Render a colored circle avatar with initials. + + Useful as a fallback when a user has not uploaded a profile image. + No source image is required. + + Args: + initials: Characters to draw in the centre of the circle; + uppercased automatically. + size: Side length of the avatar in pixels. + bg: Background color for the circle (any value accepted by + ``PIL.ImageColor``). + text_color: Text color for the initials. + fmt: Output format. + + Returns: + Encoded avatar bytes. + + Example: + >>> from nitro_img import presets + >>> data = presets.avatar_placeholder("SN", bg="#E74C3C") + """ img = PILImage.new("RGBA", (size, size), bg) draw = ImageDraw.Draw(img) @@ -108,7 +178,25 @@ def og_image( fmt: Format = Format.JPEG, quality: int = 85, ) -> bytes: - """Generate an Open Graph image (1200x630 by default).""" + """Produce a social/Open Graph card image. + + Cover-crops to the target dimensions (1200x630 by default) so the + result fills a typical social preview card without letterboxing. + + Args: + source: File path, bytes, or file-like object. + width: Target width in pixels. + height: Target height in pixels. + fmt: Output format. + quality: Encoder quality. + + Returns: + Encoded OG image bytes. + + Example: + >>> from nitro_img import presets + >>> card = presets.og_image("hero.jpg") + """ img, _ = _load(source) img = cover(width, height, allow_upscale=True)(img) img = _strip_metadata_op()(img) @@ -123,7 +211,25 @@ def banner( fmt: Format = Format.JPEG, quality: int = 85, ) -> bytes: - """Generate a hero/banner crop.""" + """Produce a wide hero/banner crop. + + Cover-crops to a wide aspect ratio (1920x400 by default), which + suits page headers and section dividers. + + Args: + source: File path, bytes, or file-like object. + width: Target width in pixels. + height: Target height in pixels. + fmt: Output format. + quality: Encoder quality. + + Returns: + Encoded banner bytes. + + Example: + >>> from nitro_img import presets + >>> hero = presets.banner("photo.jpg", width=2400, height=500) + """ img, _ = _load(source) img = cover(width, height, allow_upscale=True)(img) img = _strip_metadata_op()(img) @@ -139,7 +245,28 @@ def responsive( fmt: Format = Format.WEBP, quality: int | None = None, ) -> dict[int, Path]: - """Generate responsive image set and save to disk.""" + """Generate a responsive image set and save each width to disk. + + Writes files named ``{name}_{width}.{ext}`` into ``output_dir`` + (creating it if necessary). Suitable for building an ```` set in one call. + + Args: + source: File path, bytes, or file-like object. + widths: Target widths in pixels. Defaults to + ``[320, 640, 1024, 1920]``. + output_dir: Destination directory. + name: Filename prefix for each generated image. + fmt: Output format used for every width. + quality: Encoder quality applied uniformly. + + Returns: + Mapping of width to the saved ``Path``. + + Example: + >>> from nitro_img import presets + >>> presets.responsive("hero.jpg", output_dir="static/", name="hero") + """ if widths is None: widths = [320, 640, 1024, 1920] img, _ = _load(source) diff --git a/src/nitro_img/types.py b/src/nitro_img/types.py index 0a77ced..9d82256 100644 --- a/src/nitro_img/types.py +++ b/src/nitro_img/types.py @@ -7,6 +7,17 @@ class Format(str, Enum): + """Image format identifier used throughout the public API. + + Members correspond to Pillow's format strings, so ``Format.JPEG.value`` + is ``"JPEG"``. Accept the enum or a plain string wherever a format + argument is taken. + + Example: + >>> from nitro_img import Image, Format + >>> Image("photo.jpg").format(Format.WEBP).save("photo.webp") + """ + JPEG = "JPEG" PNG = "PNG" WEBP = "WEBP" @@ -16,6 +27,17 @@ class Format(str, Enum): class Position(str, Enum): + """Anchor position for crops, watermarks, and text overlays. + + Values are the same kebab-case strings accepted by the string-typed + ``anchor`` / ``position`` parameters across the API, so either the + enum or its string value can be passed. + + Example: + >>> from nitro_img import Image, Position + >>> Image("photo.jpg").crop(400, 400, anchor=Position.TOP_LEFT) + """ + CENTER = "center" TOP_LEFT = "top-left" TOP_RIGHT = "top-right" @@ -28,6 +50,19 @@ class Position(str, Enum): class ResizeStrategy(str, Enum): + """How a resize operation fits the source into target dimensions. + + Most users invoke strategies by calling the dedicated ``Image`` + methods (``resize``, ``cover``, ``contain``); this enum exists for + integrations that select a strategy dynamically. + + Members: + FIT: Scale to fit within the box, preserving aspect ratio. + COVER: Fill the box, centre-cropping any overflow. + CONTAIN: Fit within the box, padding remaining space. + EXACT: Resize to the exact dimensions, ignoring aspect ratio. + """ + FIT = "fit" COVER = "cover" CONTAIN = "contain"