2# +==== BEGIN CatFeeder =================+
5# ...............)..(.')
7# ...............\(__)|
8# Inspired by Joan Stark
9# source https://www.asciiart.eu/
13# FILE: image_reducer.py
14# CREATION DATE: 05-01-2026
15# LAST Modified: 23:3:11 10-01-2026
17# This is the backend server in charge of making the actual website work.
19# COPYRIGHT: (c) Cat Feeder
20# PURPOSE: This is the class in charge of doing the reducing work.
22# +==== END CatFeeder =================+
26from typing
import TYPE_CHECKING, Optional, List, Union
28from fastapi
import Response
29from scour
import scour
as _scour
30from PIL
import Image, UnidentifiedImageError
31from display_tty
import Disp, initialise_logger
33from .
import image_reducer_constants
as IR_CONST
34from .
import image_reducer_error_class
as IR_ERR
36from ..core
import FinalClass
37from ..core.runtime_manager
import RI, RuntimeManager
38from ..http_codes
import HCI, HttpDataTypes
41 from ..server_header
import ServerHeaders
42 from ..boilerplates.responses
import BoilerplateResponses
46 """Utility for validating, converting and compressing images.
48 Provides methods for opening, validating, converting, and saving images
49 in different formats. Supports raster formats (PNG, JPEG, WebP) and SVG
50 with optional compression.
58 disp: Disp = initialise_logger(__qualname__,
False)
64 def __init__(self, error: int = 84, success: int = 0, debug: bool =
False) ->
None:
65 """Initialize the ImageReducer instance with dependencies.
68 debug: Enable debug logging when True. Defaults to False.
71 self.
disp.update_disp_debug(debug)
72 self.
disp.log_debug(
"Initialising...")
77 "BoilerplateResponses")
80 self.
disp.log_debug(
"Initialised")
85 allowed_formats: Optional[List[str]] =
None,
86 output_format: Optional[str] =
"WEBP",
87 max_dimension: Optional[int] =
None,
89 compress_svg: bool =
False
91 """Process an image with validation and conversion.
93 Convenience wrapper that calls reprocess_image_strict with the given
94 arguments. Validates image format, enforces maximum dimensions, and
95 converts to the specified output format using predefined compression settings.
98 file_bytes: Image data as raw bytes.
99 allowed_formats: Allowed input formats. Defaults to None.
100 output_format: Desired output format. Defaults to "WEBP".
101 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
102 compress_svg: Whether to minify SVG files. Defaults to False.
105 Processed image as bytes.
109 f
"output_format={output_format}, max_dimension={max_dimension}")
111 file_bytes=file_bytes,
112 allowed_formats=allowed_formats,
113 output_format=output_format,
114 max_dimension=max_dimension,
115 compress_svg=compress_svg
117 self.
disp.log_debug(
"completed")
123 allowed_formats: Optional[List[str]] =
None,
124 output_format: Optional[str] =
"WEBP",
125 max_dimension: Optional[int] =
None,
127 compress_svg: bool =
False,
128 title: str =
"reprocess_image",
129 token: Optional[str] =
None
130 ) -> Union[bytes, Response]:
131 """Process an image and return bytes or an error response.
133 Safely processes an image with comprehensive error handling. Returns
134 processed image bytes on success or an HTTP error response (400/413/415/500)
135 if validation fails. Uses predefined compression settings from constants.
138 file_bytes: The image data to process as raw bytes.
139 allowed_formats: Allowed input formats. Defaults to None.
140 output_format: Desired output format. Defaults to "WEBP".
141 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
142 compress_svg: Whether to minify SVG files. Defaults to False.
143 title: Title for the HTTP response in case of error. Defaults to "reprocess_image".
144 token: Token to include in the HTTP response. Defaults to None.
147 Processed image bytes on success, or an HTTP error Response.
150 No exceptions raised. All errors result in HTTP responses.
153 self.
disp.log_debug(
"Reducing image (safe)")
155 file_bytes=file_bytes,
156 allowed_formats=allowed_formats,
157 output_format=output_format,
158 max_dimension=max_dimension,
159 compress_svg=compress_svg
161 self.
disp.log_info(
"Image_reduced")
163 f
"Image reduced (safe) result_bytes={len(data) if hasattr(data, '__len__') else 'unknown'}")
165 except IR_ERR.ImageReducerInvalidImageFile
as e:
166 self.
disp.log_error(f
"ImageReducerInvalidImageFile : '{str(e)}'")
170 resp=
"invalid_image_file",
174 return HCI.bad_request(content=body, content_type=HttpDataTypes.JSON, headers=self.
server_header.for_json())
175 except IR_ERR.ImageReducerUnsupportedFormat
as e:
176 self.
disp.log_error(f
"ImageReducerUnsupportedFormat : '{str(e)}'")
180 resp=
"unsupported_format",
184 return HCI.unsupported_media_type(content=body, content_type=HttpDataTypes.JSON, headers=self.
server_header.for_json())
185 except IR_ERR.ImageReducerTooLarge
as e:
186 self.
disp.log_error(f
"ImageReducerTooLarge : '{str(e)}'")
194 return HCI.payload_too_large(content=body, content_type=HttpDataTypes.JSON, headers=self.
server_header.for_json())
195 except IR_ERR.ImageReducer
as e:
196 self.
disp.log_error(f
"ImageReducer : '{str(e)}'")
200 resp=
"image_reducer_error",
204 return HCI.internal_server_error(content=body, content_type=HttpDataTypes.JSON, headers=self.
server_header.for_json())
205 except ValueError
as e:
206 self.
disp.log_error(f
"ValueError : '{str(e)}'")
214 return HCI.internal_server_error(content=body, content_type=HttpDataTypes.JSON, headers=self.
server_header.for_json())
219 allowed_formats: Optional[List[str]] =
None,
220 output_format: Optional[str] =
"WEBP",
221 max_dimension: Optional[int] =
None,
223 compress_svg: bool =
False
225 """Process an image with validation and format conversion.
227 Detects file format, validates against allowed formats, enforces
228 dimension limits, converts to the desired output format using predefined
229 compression settings. For SVG files, optionally minifies them.
230 Does not rescale raster images.
233 file_bytes: Uploaded image as raw bytes.
234 allowed_formats: Allowed input formats. Defaults to None (uses ALLOWED_FORMATS).
235 output_format: Output format to use. Defaults to "WEBP".
236 max_dimension: Maximum allowed width/height in pixels. Defaults to None.
237 compress_svg: Whether to minify SVG files. Defaults to False.
240 Processed image bytes.
243 ImageReducerInvalidImageFile: If image bytes cannot be decoded.
244 ImageReducerUnsupportedFormat: If image format is not in allowed_formats.
245 ImageReducerTooLarge: If image dimensions exceed max_dimension.
249 f
"reprocess_image_strict start: output_format={output_format}, max_dimension={max_dimension}")
254 if _file_format == IR_CONST.FileFormat.SVG:
259 if allowed_formats
is None:
260 allowed_formats = IR_CONST.ALLOWED_FORMATS
262 f
"Using default allowed_formats={allowed_formats}")
274 save_params[
"format"] = fmt
277 f
"completed: result_bytes={len(result)}")
281 """Detect file format from bytes content.
283 Uses heuristics to identify the image format:
284 1. Checks for SVG XML tags (lossless, checks first).
285 2. Uses Pillow to identify raster formats (PNG, JPEG, WebP).
286 3. Returns FileFormat.UNKNOWN if detection fails.
289 file_bytes: Raw file bytes to analyze.
292 FileFormat enum indicating the detected format.
295 if isinstance(file_bytes, (bytes, bytearray)):
296 head = file_bytes[:512].lstrip()
297 if b
"<svg" in head.lower()
or (head.startswith(b
"<?xml")
and b
"<svg" in head.lower()):
299 "detected SVG by content")
300 return IR_CONST.FileFormat.SVG
302 self.
disp.log_warning(
303 "file_bytes is not bytes-like")
307 img = Image.open(io.BytesIO(file_bytes))
308 fmt = (img.format
or "").upper()
310 f
"Pillow detected format={fmt}")
312 return IR_CONST.FileFormat.PNG
313 if fmt
in (
"JPEG",
"JPG"):
314 return IR_CONST.FileFormat.JPEG
316 return IR_CONST.FileFormat.WEBP
317 except (UnidentifiedImageError, OSError)
as e:
319 f
"Pillow failed to identify format: {e}")
321 return IR_CONST.FileFormat.UNKNOWN
324 """Minify SVG content by removing comments and metadata.
326 Uses the scour library for safe, lossless SVG optimization. Removes
327 XML comments, metadata, and enables viewboxing. Gracefully degrades
328 if decoding fails and returns original bytes.
331 file_bytes: SVG file content as bytes (must be valid UTF-8).
334 Minified SVG content as bytes.
336 self.
disp.log_debug(
"_compress_svg: attempting svg minify")
337 if not isinstance(file_bytes, (bytes, bytearray)):
338 self.
disp.log_warning(
339 "_compress_svg: input is not bytes-like, returning original")
343 text = file_bytes.decode(
"utf-8")
344 except UnicodeDecodeError:
345 text = file_bytes.decode(
"utf-8", errors=
"ignore")
348 opts = _scour.sanitizeOptions()
349 opts.remove_metadata =
True
350 opts.strip_comments =
True
351 opts.enable_viewboxing =
True
352 out_text = _scour.scourString(text, opts)
353 out_bytes = out_text.encode(
"utf-8")
355 f
"_compress_svg (scour): reduced from {len(file_bytes)} to {len(out_bytes)} bytes")
359 """Open image bytes and return a Pillow Image instance.
362 file_bytes: Raw image bytes to open.
365 Pillow Image instance.
368 ImageReducerInvalidImageFile: If bytes cannot be identified as a valid image.
370 self.
disp.log_debug(
"attempting to open image bytes")
372 img = Image.open(io.BytesIO(file_bytes))
374 f
"opened image format={img.format}, mode={img.mode}, size={getattr(img, 'size', None)}")
376 except UnidentifiedImageError
as e:
377 file =
"incorrect byte format"
378 if not isinstance(file_bytes, bytes):
379 file = f
"{type(file_bytes)}"
380 self.
disp.log_error(f
"{file}")
381 raise IR_ERR.ImageReducerInvalidImageFile(file,
"bytes")
from e
384 """Validate that image format is in the allowed formats list.
387 img: Pillow image to validate.
388 allowed_formats: List of allowed image format strings.
391 ImageReducerUnsupportedFormat: If image format is not in allowed_formats.
394 f
"img.format={img.format}, allowed={allowed_formats}")
395 if img.format
not in allowed_formats:
396 self.
disp.log_warning(
397 f
"unsupported format {img.format}")
398 raise IR_ERR.ImageReducerUnsupportedFormat(
399 img.format, allowed_formats
403 """Validate that image dimensions do not exceed the maximum allowed size.
406 img: Pillow image to check.
407 max_dimension: Maximum allowed width/height in pixels. If None, no check is performed.
410 ImageReducerTooLarge: If image width or height exceeds max_dimension.
413 f
"img.size={img.width}x{img.height}, max_dimension={max_dimension}")
414 if max_dimension
is not None:
415 if img.width > max_dimension
or img.height > max_dimension:
416 self.
disp.log_warning(
417 f
"image too large {img.width}x{img.height} > {max_dimension}")
418 raise IR_ERR.ImageReducerTooLarge(
419 img.width, img.height, max_dimension
423 """Ensure image is in RGB or RGBA mode, converting if necessary.
425 Converts indexed (P), grayscale (L, LA), or other color modes to either
426 RGB (for opaque images) or RGBA (for images with alpha channel).
429 img: Pillow image to convert if necessary.
432 Image in RGB or RGBA mode.
434 self.
disp.log_debug(f
"current mode={img.mode}")
435 if img.mode
not in (
"RGB",
"RGBA"):
441 f
"converting mode from {img.mode} to {target}")
442 return img.convert(target)
443 self.
disp.log_debug(
"no conversion needed")
447 """Build parameters for saving an image in the desired format.
449 Resolves the output format (from parameter, original format, or default),
450 and constructs a dictionary of save parameters using predefined compression
451 settings from COMPRESSION_QUALITY for the target format.
454 output_format: Desired output format (e.g., "WEBP", "PNG"). If None, uses original format or default.
455 img_format: Original image format from Pillow.
458 Tuple of (format_string: str, save_params: dict).
461 f
"requested output_format={output_format}, img_format={img_format}")
463 if not output_format:
465 output_format = IR_CONST.DEFAULT_OUTPUT_FORMAT
467 output_format = img_format
469 output_format = output_format.upper()
470 save_params: dict = {
"optimize":
True}
473 of = output_format.lower()
475 if output_format
in IR_CONST.ALLOWED_OUTPUT_FORMATS:
477 if of
in IR_CONST.COMPRESSION_QUALITY:
478 compression_value = IR_CONST.COMPRESSION_QUALITY[of]
480 if of
in (
"jpeg",
"jpg",
"webp",
"avif"):
481 save_params[
"quality"] = compression_value
484 save_params[
"compress_level"] = compression_value
487 save_params[
"optimize"] = bool(compression_value)
490 f
"resolved format={output_format}, save_params={save_params}")
491 return output_format, save_params
493 def _save_image(self, img: Image.Image, save_params: dict) -> bytes:
494 """Save a Pillow image to bytes using the specified parameters.
497 img: Pillow image to save.
498 save_params: Dictionary of parameters to pass to Image.save() (e.g., format, quality, optimize).
501 Saved image content as raw bytes.
503 self.
disp.log_debug(f
"saving with params={save_params}")
505 img.save(data, **save_params)
506 out = data.getvalue()
507 self.
disp.log_debug(f
"saved {len(out)} bytes")
511 """Test image reduction by processing a sample file.
513 Loads "admin_upload.png", processes it to WebP format with quality 90
514 and a maximum dimension of 1024px, then saves the result to "processed.webp".
515 Useful for validating the reduction pipeline end-to-end.
517 with open(
"admin_upload.png",
"rb")
as f:
518 original_bytes: bytes = f.read()
521 file_bytes=original_bytes,
522 output_format=
"WEBP",
526 with open(
"processed.webp",
"wb")
as f:
527 f.write(processed_bytes)
Union[bytes, Response] reprocess_image_strict_safe(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False, str title="reprocess_image", Optional[str] token=None)
None _enforce_max_dimension(self, Image.Image img, Optional[int] max_dimension)
None __init__(self, int error=84, int success=0, bool debug=False)
Image.Image _ensure_mode(self, Image.Image img)
RuntimeManager server_header
bytes __call__(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False)
bytes _save_image(self, Image.Image img, dict save_params)
RuntimeManager runtime_manager
tuple _build_save_params(self, Optional[str] output_format, Optional[str] img_format)
bytes reprocess_image_strict(self, bytes file_bytes, Optional[List[str]] allowed_formats=None, Optional[str] output_format="WEBP", Optional[int] max_dimension=None, *, bool compress_svg=False)
Image.Image _open_image(self, bytes file_bytes)
bytes _compress_svg(self, bytes file_bytes)
RuntimeManager boilerplate_response
IR_CONST.FileFormat detect_file_format(self, bytes file_bytes)
None _validate_format(self, Image.Image img, List[str] allowed_formats)