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
15 changes: 15 additions & 0 deletions CHAGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
- New functionality to define steps for a field to have more control over the
order of the validation and filtering process.

### Filter

- New [`Base64ImageDownscaleFilter`](flask_inputfilter/Filter/Base64ImageDownscaleFilter.py) to reduce the size of an image.
- New [`Base64ImageResizeFilter`](flask_inputfilter/Filter/Base64ImageResizeFilter.py) to reduce the file size of an image.

### Validator

- New [`IsHorizontalImageValidator`](flask_inputfilter/Validator/IsHorizontalImageValidator.py) to check if an image is horizontical.
- New [`IsVerticalImageValidator`](flask_inputfilter/Validator/IsVerticalImageValidator.py) to check if an image is vertical.

## Changed

- Added UnicodeFormEnum to show possible config values for ToNormalizedUnicodeFilter.
Old config is still supportet, but will be removed at a later version.


# [0.0.7.1] - 2025-01-16

Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Quickstart
To use the `InputFilter` class, create a new class that inherits from it and define the
fields you want to validate and filter.

There are numerous filters and validators available, but you can also create your `own <CREATE_OWN.md>`.
There are numerous filters and validators available, but you can also create your `own <CREATE_OWN.md>`_.

Definition
----------
Expand Down Expand Up @@ -93,7 +93,7 @@ Usage
-----

To use the `InputFilter` class, call the `validate` method on the class instance.
After calling `validate`, the validated data will be available in `g.validatedData`.
After calling `validate`, the validated data will be available in `g.validated_data`.
If the data is invalid, a 400 response with an error message will be returned.

.. code-block:: python
Expand All @@ -106,7 +106,7 @@ If the data is invalid, a 400 response with an error message will be returned.
@app.route('/update-zipcode', methods=['POST'])
@UpdateZipcodeInputFilter.validate()
def updateZipcode():
data = g.validatedData
data = g.validated_data

# Do something with validated data
id = data.get('id')
Expand Down
18 changes: 18 additions & 0 deletions flask_inputfilter/Enum/ImageFormatEnum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from enum import Enum


class ImageFormatEnum(Enum):
JPEG = "JPEG"
PNG = "PNG"
GIF = "GIF"
BMP = "BMP"
TIFF = "TIFF"
WEBP = "WEBP"
ICO = "ICO"
PDF = "PDF"
EPS = "EPS"
SVG = "SVG"
PSD = "PSD"
XCF = "XCF"
HEIF = "HEIF"
AVIF = "AVIF"
8 changes: 8 additions & 0 deletions flask_inputfilter/Enum/UnicodeFormEnum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class UnicodeFormEnum(Enum):
NFC = "NFC"
NFD = "NFD"
NFKC = "NFKC"
NFKD = "NFKD"
2 changes: 2 additions & 0 deletions flask_inputfilter/Enum/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .ImageFormatEnum import ImageFormatEnum
from .RegexEnum import RegexEnum
from .UnicodeFormEnum import UnicodeFormEnum
83 changes: 83 additions & 0 deletions flask_inputfilter/Filter/Base64ImageDownscaleFilter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import base64
import io
from typing import Any, Optional

from PIL import Image

from .BaseFilter import BaseFilter


class Base64ImageDownscaleFilter(BaseFilter):
"""
Filter that downscales a base64 image to a given size
"""

def __init__(
self,
size: int = 1024 * 1024,
width: Optional[int] = None,
height: Optional[int] = None,
proportionally: bool = True,
) -> None:
self.width = int(width or size**0.5)
self.height = int(height or size**0.5)
self.proportionally = proportionally

def apply(self, value: Any) -> Any:
if not isinstance(value, (str, Image.Image)):
return value

try:
if isinstance(value, Image.Image):
return self.resize_picture(value)

image = Image.open(io.BytesIO(base64.b64decode(value)))
return self.resize_picture(image)

except Exception:
return value

def resize_picture(self, image: Image) -> str:
"""
Resizes the image if it exceeds the specified width/height
and returns the base64 representation.
"""
is_animated = getattr(image, "is_animated", False)

if not is_animated and image.mode in ("RGBA", "P"):
image = image.convert("RGB")

if (
image.size[0] * image.size[1] < self.width * self.height
or is_animated
):
return self.image_to_base64(image)

if self.proportionally:
image = self.scale_image(image)
else:
image = image.resize((self.width, self.height), Image.LANCZOS)

return self.image_to_base64(image)

def scale_image(self, image: Image) -> Image:
"""
Scale the image proportionally to fit within the target width/height.
"""
original_width, original_height = image.size
aspect_ratio = original_width / original_height

if original_width > original_height:
new_width = self.width
new_height = int(new_width / aspect_ratio)
else:
new_height = self.height
new_width = int(new_height * aspect_ratio)

return image.resize((new_width, new_height), Image.LANCZOS)

@staticmethod
def image_to_base64(image: Image) -> str:
buffered = io.BytesIO()
image.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode("ascii")
94 changes: 94 additions & 0 deletions flask_inputfilter/Filter/Base64ImageResizeFilter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import base64
import io
from typing import Any

from PIL import Image

from ..Enum import ImageFormatEnum
from .BaseFilter import BaseFilter


class Base64ImageResizeFilter(BaseFilter):
"""
A filter to reduce the file size of a base64-encoded
image by resizing and compressing it.
"""

def __init__(
self,
max_size: int = 4 * 1024 * 1024,
format: ImageFormatEnum = ImageFormatEnum.JPEG,
preserve_icc_profile: bool = False,
preserve_metadata: bool = False,
) -> None:
self.max_size = max_size
self.format = format
self.preserve_metadata = preserve_metadata
self.preserve_icc_profile = preserve_icc_profile

def apply(self, value: Any) -> Any:
if not isinstance(value, (str, Image.Image)):
return value

try:
if isinstance(value, Image.Image):
return self.reduce_image(value)

value = Image.open(io.BytesIO(base64.b64decode(value)))
return self.reduce_image(value)
except Exception:
return value

def reduce_image(self, image: Image) -> Image:
"""Reduce the size of an image by resizing and compressing it."""
is_animated = getattr(image, "is_animated", False)

if not is_animated and image.mode in ("RGBA", "P"):
image = image.convert("RGB")

buffer = self.save_image_to_buffer(image, quality=80)
if buffer.getbuffer().nbytes <= self.max_size:
return self.image_to_base64(image)

new_width, new_height = image.size

while (
buffer.getbuffer().nbytes > self.max_size
and new_width > 10
and new_height > 10
):
new_width = int(new_width * 0.9)
new_height = int(new_height * 0.9)
image = image.resize((new_width, new_height), Image.LANCZOS)

buffer = self.save_image_to_buffer(image, quality=80)

quality = 80
while buffer.getbuffer().nbytes > self.max_size and quality > 0:
buffer = self.save_image_to_buffer(image, quality)
quality -= 5

return self.image_to_base64(image)

def save_image_to_buffer(
self, image: Image.Image, quality: int
) -> io.BytesIO:
"""Save the image to an in-memory buffer with the specified quality."""
buffer = io.BytesIO()
image.save(buffer, format=self.format.value, quality=quality)
buffer.seek(0)
return buffer

def image_to_base64(self, image: Image) -> str:
"""Convert an image to a base64-encoded string."""
buffered = io.BytesIO()
options = {
"format": self.format.value,
"optimize": True,
}
if self.preserve_icc_profile:
options["icc_profile"] = image.info.get("icc_profile", None)
if self.preserve_metadata:
options["exif"] = image.info.get("exif", None)
image.save(buffered, **options)
return base64.b64encode(buffered.getvalue()).decode("ascii")
2 changes: 2 additions & 0 deletions flask_inputfilter/Filter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ The `Filter` module contains the filters that can be used to filter the input da
The following filters are available in the `Filter` module:

1. [`ArrayExplodeFilter`](ArrayExplodeFilter.py) - Explodes the input string into an array.
2. [`Base64ImageDownscaleFilter`](Base64ImageDownscaleFilter.py) - Downscale the base64 image.
3. [`Base64ImageResizeFilter`](Base64ImageResizeFilter.py) - Resize the base64 image.
2. [`BlacklistFilter`](BlacklistFilter.py) - Filters the string based on the blacklist.
3. [`RemoveEmojisFilter`](RemoveEmojisFilter.py) - Removes the emojis from the string.
4. [`SlugifyFilter`](SlugifyFilter.py) - Converts the string to a slug.
Expand Down
15 changes: 11 additions & 4 deletions flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from typing_extensions import Literal

from ..Enum import UnicodeFormEnum
from .BaseFilter import BaseFilter


Expand All @@ -12,20 +13,26 @@ class ToNormalizedUnicodeFilter(BaseFilter):
"""

def __init__(
self, form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFC"
self,
form: Union[
UnicodeFormEnum, Literal["NFC", "NFD", "NFKC", "NFKD"]
] = UnicodeFormEnum.NFC,
) -> None:
if not isinstance(form, UnicodeFormEnum):
form = UnicodeFormEnum(form)

self.form = form

def apply(self, value: Any) -> Union[str, Any]:
if not isinstance(value, str):
return value

value = unicodedata.normalize(self.form, value)
value = unicodedata.normalize(self.form.value, value)

value_without_accents = "".join(
char
for char in unicodedata.normalize("NFD", value)
for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value)
if unicodedata.category(char) != "Mn"
)

return unicodedata.normalize(self.form, value_without_accents)
return unicodedata.normalize(self.form.value, value_without_accents)
2 changes: 2 additions & 0 deletions flask_inputfilter/Filter/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from .ArrayExplodeFilter import ArrayExplodeFilter
from .Base64ImageDownscaleFilter import Base64ImageDownscaleFilter
from .Base64ImageResizeFilter import Base64ImageResizeFilter
from .BaseFilter import BaseFilter
from .BlacklistFilter import BlacklistFilter
from .RemoveEmojisFilter import RemoveEmojisFilter
Expand Down
5 changes: 4 additions & 1 deletion flask_inputfilter/InputFilter.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ def __applySteps(
Apply multiple filters and validators in a specific order.
"""

if value is None:
return

field = self.fields.get(field_name)

try:
Expand Down Expand Up @@ -280,7 +283,7 @@ def validateData(
self.__validateField(field_name, field_info, value) or value
)

value = self.__applySteps(field_name, field_info, value)
value = self.__applySteps(field_name, field_info, value) or value

if field_info.get("external_api"):
value = self.__callExternalApi(field_info, validated_data)
Expand Down
31 changes: 31 additions & 0 deletions flask_inputfilter/Validator/IsHorizontalImageValidator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import base64
import io

from PIL import Image
from PIL.Image import Image as ImageType

from flask_inputfilter.Exception import ValidationError
from flask_inputfilter.Validator import BaseValidator


class IsHorizontalImageValidator(BaseValidator):
def __init__(self, error_message=None):
self.error_message = (
error_message or "The image is not horizontically oriented."
)

def validate(self, value):
if not isinstance(value, (str, ImageType)):
raise ValidationError(
"The value is not an image or its base 64 representation."
)

try:
if isinstance(value, str):
value = Image.open(io.BytesIO(base64.b64decode(value)))

if value.width < value.height:
raise

except Exception:
raise ValidationError(self.error_message)
32 changes: 32 additions & 0 deletions flask_inputfilter/Validator/IsVerticalImageValidator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import base64
import io
from typing import Any

from PIL import Image
from PIL.Image import Image as ImageType

from flask_inputfilter.Exception import ValidationError
from flask_inputfilter.Validator import BaseValidator


class IsVerticalImageValidator(BaseValidator):
def __init__(self, error_message=None):
self.error_message = (
error_message or "The image is not vertically oriented."
)

def validate(self, value: Any) -> None:
if not isinstance(value, (str, ImageType)):
raise ValidationError(
"The value is not an image or its base 64 representation."
)

try:
if isinstance(value, str):
value = Image.open(io.BytesIO(base64.b64decode(value)))

if value.width > value.height:
raise

except Exception:
raise ValidationError(self.error_message)
Loading
Loading