Skip to content

Commit 89959ef

Browse files
authored
Merge pull request #21 from LeanderCS/17
17 | Add new filters and validators
2 parents c77e9f4 + a08af11 commit 89959ef

18 files changed

+547
-55
lines changed

CHAGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
1010
- New functionality to define steps for a field to have more control over the
1111
order of the validation and filtering process.
1212

13+
### Filter
14+
15+
- New [`Base64ImageDownscaleFilter`](flask_inputfilter/Filter/Base64ImageDownscaleFilter.py) to reduce the size of an image.
16+
- New [`Base64ImageResizeFilter`](flask_inputfilter/Filter/Base64ImageResizeFilter.py) to reduce the file size of an image.
17+
18+
### Validator
19+
20+
- New [`IsHorizontalImageValidator`](flask_inputfilter/Validator/IsHorizontalImageValidator.py) to check if an image is horizontical.
21+
- New [`IsVerticalImageValidator`](flask_inputfilter/Validator/IsVerticalImageValidator.py) to check if an image is vertical.
22+
23+
## Changed
24+
25+
- Added UnicodeFormEnum to show possible config values for ToNormalizedUnicodeFilter.
26+
Old config is still supportet, but will be removed at a later version.
27+
1328

1429
# [0.0.7.1] - 2025-01-16
1530

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Quickstart
4040
To use the `InputFilter` class, create a new class that inherits from it and define the
4141
fields you want to validate and filter.
4242

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

4545
Definition
4646
----------
@@ -93,7 +93,7 @@ Usage
9393
-----
9494

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

9999
.. code-block:: python
@@ -106,7 +106,7 @@ If the data is invalid, a 400 response with an error message will be returned.
106106
@app.route('/update-zipcode', methods=['POST'])
107107
@UpdateZipcodeInputFilter.validate()
108108
def updateZipcode():
109-
data = g.validatedData
109+
data = g.validated_data
110110
111111
# Do something with validated data
112112
id = data.get('id')
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from enum import Enum
2+
3+
4+
class ImageFormatEnum(Enum):
5+
JPEG = "JPEG"
6+
PNG = "PNG"
7+
GIF = "GIF"
8+
BMP = "BMP"
9+
TIFF = "TIFF"
10+
WEBP = "WEBP"
11+
ICO = "ICO"
12+
PDF = "PDF"
13+
EPS = "EPS"
14+
SVG = "SVG"
15+
PSD = "PSD"
16+
XCF = "XCF"
17+
HEIF = "HEIF"
18+
AVIF = "AVIF"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from enum import Enum
2+
3+
4+
class UnicodeFormEnum(Enum):
5+
NFC = "NFC"
6+
NFD = "NFD"
7+
NFKC = "NFKC"
8+
NFKD = "NFKD"

flask_inputfilter/Enum/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
from .ImageFormatEnum import ImageFormatEnum
12
from .RegexEnum import RegexEnum
3+
from .UnicodeFormEnum import UnicodeFormEnum
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import base64
2+
import io
3+
from typing import Any, Optional
4+
5+
from PIL import Image
6+
7+
from .BaseFilter import BaseFilter
8+
9+
10+
class Base64ImageDownscaleFilter(BaseFilter):
11+
"""
12+
Filter that downscales a base64 image to a given size
13+
"""
14+
15+
def __init__(
16+
self,
17+
size: int = 1024 * 1024,
18+
width: Optional[int] = None,
19+
height: Optional[int] = None,
20+
proportionally: bool = True,
21+
) -> None:
22+
self.width = int(width or size**0.5)
23+
self.height = int(height or size**0.5)
24+
self.proportionally = proportionally
25+
26+
def apply(self, value: Any) -> Any:
27+
if not isinstance(value, (str, Image.Image)):
28+
return value
29+
30+
try:
31+
if isinstance(value, Image.Image):
32+
return self.resize_picture(value)
33+
34+
image = Image.open(io.BytesIO(base64.b64decode(value)))
35+
return self.resize_picture(image)
36+
37+
except Exception:
38+
return value
39+
40+
def resize_picture(self, image: Image) -> str:
41+
"""
42+
Resizes the image if it exceeds the specified width/height
43+
and returns the base64 representation.
44+
"""
45+
is_animated = getattr(image, "is_animated", False)
46+
47+
if not is_animated and image.mode in ("RGBA", "P"):
48+
image = image.convert("RGB")
49+
50+
if (
51+
image.size[0] * image.size[1] < self.width * self.height
52+
or is_animated
53+
):
54+
return self.image_to_base64(image)
55+
56+
if self.proportionally:
57+
image = self.scale_image(image)
58+
else:
59+
image = image.resize((self.width, self.height), Image.LANCZOS)
60+
61+
return self.image_to_base64(image)
62+
63+
def scale_image(self, image: Image) -> Image:
64+
"""
65+
Scale the image proportionally to fit within the target width/height.
66+
"""
67+
original_width, original_height = image.size
68+
aspect_ratio = original_width / original_height
69+
70+
if original_width > original_height:
71+
new_width = self.width
72+
new_height = int(new_width / aspect_ratio)
73+
else:
74+
new_height = self.height
75+
new_width = int(new_height * aspect_ratio)
76+
77+
return image.resize((new_width, new_height), Image.LANCZOS)
78+
79+
@staticmethod
80+
def image_to_base64(image: Image) -> str:
81+
buffered = io.BytesIO()
82+
image.save(buffered, format="PNG")
83+
return base64.b64encode(buffered.getvalue()).decode("ascii")
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import base64
2+
import io
3+
from typing import Any
4+
5+
from PIL import Image
6+
7+
from ..Enum import ImageFormatEnum
8+
from .BaseFilter import BaseFilter
9+
10+
11+
class Base64ImageResizeFilter(BaseFilter):
12+
"""
13+
A filter to reduce the file size of a base64-encoded
14+
image by resizing and compressing it.
15+
"""
16+
17+
def __init__(
18+
self,
19+
max_size: int = 4 * 1024 * 1024,
20+
format: ImageFormatEnum = ImageFormatEnum.JPEG,
21+
preserve_icc_profile: bool = False,
22+
preserve_metadata: bool = False,
23+
) -> None:
24+
self.max_size = max_size
25+
self.format = format
26+
self.preserve_metadata = preserve_metadata
27+
self.preserve_icc_profile = preserve_icc_profile
28+
29+
def apply(self, value: Any) -> Any:
30+
if not isinstance(value, (str, Image.Image)):
31+
return value
32+
33+
try:
34+
if isinstance(value, Image.Image):
35+
return self.reduce_image(value)
36+
37+
value = Image.open(io.BytesIO(base64.b64decode(value)))
38+
return self.reduce_image(value)
39+
except Exception:
40+
return value
41+
42+
def reduce_image(self, image: Image) -> Image:
43+
"""Reduce the size of an image by resizing and compressing it."""
44+
is_animated = getattr(image, "is_animated", False)
45+
46+
if not is_animated and image.mode in ("RGBA", "P"):
47+
image = image.convert("RGB")
48+
49+
buffer = self.save_image_to_buffer(image, quality=80)
50+
if buffer.getbuffer().nbytes <= self.max_size:
51+
return self.image_to_base64(image)
52+
53+
new_width, new_height = image.size
54+
55+
while (
56+
buffer.getbuffer().nbytes > self.max_size
57+
and new_width > 10
58+
and new_height > 10
59+
):
60+
new_width = int(new_width * 0.9)
61+
new_height = int(new_height * 0.9)
62+
image = image.resize((new_width, new_height), Image.LANCZOS)
63+
64+
buffer = self.save_image_to_buffer(image, quality=80)
65+
66+
quality = 80
67+
while buffer.getbuffer().nbytes > self.max_size and quality > 0:
68+
buffer = self.save_image_to_buffer(image, quality)
69+
quality -= 5
70+
71+
return self.image_to_base64(image)
72+
73+
def save_image_to_buffer(
74+
self, image: Image.Image, quality: int
75+
) -> io.BytesIO:
76+
"""Save the image to an in-memory buffer with the specified quality."""
77+
buffer = io.BytesIO()
78+
image.save(buffer, format=self.format.value, quality=quality)
79+
buffer.seek(0)
80+
return buffer
81+
82+
def image_to_base64(self, image: Image) -> str:
83+
"""Convert an image to a base64-encoded string."""
84+
buffered = io.BytesIO()
85+
options = {
86+
"format": self.format.value,
87+
"optimize": True,
88+
}
89+
if self.preserve_icc_profile:
90+
options["icc_profile"] = image.info.get("icc_profile", None)
91+
if self.preserve_metadata:
92+
options["exif"] = image.info.get("exif", None)
93+
image.save(buffered, **options)
94+
return base64.b64encode(buffered.getvalue()).decode("ascii")

flask_inputfilter/Filter/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ The `Filter` module contains the filters that can be used to filter the input da
77
The following filters are available in the `Filter` module:
88

99
1. [`ArrayExplodeFilter`](ArrayExplodeFilter.py) - Explodes the input string into an array.
10+
2. [`Base64ImageDownscaleFilter`](Base64ImageDownscaleFilter.py) - Downscale the base64 image.
11+
3. [`Base64ImageResizeFilter`](Base64ImageResizeFilter.py) - Resize the base64 image.
1012
2. [`BlacklistFilter`](BlacklistFilter.py) - Filters the string based on the blacklist.
1113
3. [`RemoveEmojisFilter`](RemoveEmojisFilter.py) - Removes the emojis from the string.
1214
4. [`SlugifyFilter`](SlugifyFilter.py) - Converts the string to a slug.

flask_inputfilter/Filter/ToNormalizedUnicodeFilter.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from typing_extensions import Literal
55

6+
from ..Enum import UnicodeFormEnum
67
from .BaseFilter import BaseFilter
78

89

@@ -12,20 +13,26 @@ class ToNormalizedUnicodeFilter(BaseFilter):
1213
"""
1314

1415
def __init__(
15-
self, form: Literal["NFC", "NFD", "NFKC", "NFKD"] = "NFC"
16+
self,
17+
form: Union[
18+
UnicodeFormEnum, Literal["NFC", "NFD", "NFKC", "NFKD"]
19+
] = UnicodeFormEnum.NFC,
1620
) -> None:
21+
if not isinstance(form, UnicodeFormEnum):
22+
form = UnicodeFormEnum(form)
23+
1724
self.form = form
1825

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

23-
value = unicodedata.normalize(self.form, value)
30+
value = unicodedata.normalize(self.form.value, value)
2431

2532
value_without_accents = "".join(
2633
char
27-
for char in unicodedata.normalize("NFD", value)
34+
for char in unicodedata.normalize(UnicodeFormEnum.NFD.value, value)
2835
if unicodedata.category(char) != "Mn"
2936
)
3037

31-
return unicodedata.normalize(self.form, value_without_accents)
38+
return unicodedata.normalize(self.form.value, value_without_accents)

flask_inputfilter/Filter/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from .ArrayExplodeFilter import ArrayExplodeFilter
2+
from .Base64ImageDownscaleFilter import Base64ImageDownscaleFilter
3+
from .Base64ImageResizeFilter import Base64ImageResizeFilter
24
from .BaseFilter import BaseFilter
35
from .BlacklistFilter import BlacklistFilter
46
from .RemoveEmojisFilter import RemoveEmojisFilter

0 commit comments

Comments
 (0)