diff --git a/docs/usage/installation.md b/docs/usage/installation.md index 1eb523dc..deb79f53 100644 --- a/docs/usage/installation.md +++ b/docs/usage/installation.md @@ -17,4 +17,4 @@ The package is installable with pip: pip install qgis-deployment-toolbelt ``` -It's then availabl as a CLI: see [the relevant section](/usage/cli) +It's then available as a CLI: see [the relevant section](/usage/cli) diff --git a/qgis_deployment_toolbelt/utils/check_image_size.py b/qgis_deployment_toolbelt/utils/check_image_size.py new file mode 100644 index 00000000..4c7f31bf --- /dev/null +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -0,0 +1,143 @@ +#! python3 # noqa: E265 + +""" + Check image size using pure Python . + + Author: Julien Moura (https://github.com/guts) +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +import xml.etree.ElementTree as ET +from decimal import Decimal +from pathlib import Path +from typing import Tuple, Union + +# 3rd party +import imagesize + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + + +# ############################################################################# +# ########## Functions ############# +# ################################## + + +def get_image_size(image_filepath: Path) -> Tuple[int, int]: + """Get image dimensions as a tuple (width,height). Return None in case of error. + + :param Path image_filepath: path to the image + + :return Tuple[int, int]: dimensions tuple (width,height) + """ + # handle SVG + if image_filepath.suffix.lower() == ".svg": + svg_size = get_svg_size(image_filepath) + if not svg_size: + return None + else: + return svg_size + + # get image dimensions + try: + return imagesize.get(image_filepath) + except ValueError as exc: + logging.error(f"Invalid image: {image_filepath.resolve()}. Trace: {exc}") + except Exception as exc: + logging.error( + f"Something went wrong reading the image: {image_filepath.resolve()}. Trace: {exc}" + ) + + return None + + +def get_svg_size(image_filepath: Path) -> Tuple[int, int]: + """Extract SVG width and height from a SVG file and convert them into integers. \ + Relevant and working only if the file root has width and height attributes. + + :param Path image_filepath: path to the svg file + + :return Tuple[int, int]: tuple of dimensions as integers (width,height) + """ + try: + tree = ET.parse(image_filepath) + root = tree.getroot() + except Exception as err: + logger.error(f"Unable to open SVG file as XML: {image_filepath}. Trace: {err}") + return None + + try: + return int(Decimal(root.attrib["width"])), int(Decimal(root.attrib["height"])) + except Exception as err: + logger.warning( + "Unable to determine image dimensions from width/height " + f"attributes: {image_filepath}. It migh be infinitely scalable. Trace: {err}" + ) + return None + + +def check_image_dimensions( + image_filepath: Union[str, Path], + min_width: int = 500, + max_width: int = 600, + min_height: int = 250, + max_height: int = 350, + allowed_images_extensions: tuple = (".jpg", ".jpeg", ".png", ".svg"), +) -> bool: + """Check input image dimensions against passed limits. + + :param Union[str, Path] image_filepath: path to the image to check + :param int min_width: minimum width, defaults to 500 + :param int max_width: maximum width, defaults to 600 + :param int min_height: minimum height, defaults to 250 + :param int max_height: maximum height, defaults to 350 + + :return bool: True if image dimensions are inferior + """ + + if image_filepath.suffix.lower() not in allowed_images_extensions: + logger.error( + f"Image extension {image_filepath.suffix.lower()} is not one of " + f"supported: {allowed_images_extensions}" + ) + return None + + image_dimensions = get_image_size(image_filepath=image_filepath) + if not image_dimensions: + logger.info( + f"Unable to determine image dimensions ({image_filepath.resolve()}), " + "so unable to check it it complies with limits." + ) + return None + + return all(d <= l for d, l in zip(image_dimensions, (max_width, max_height))) + + +# ############################################################################# +# ##### Stand alone program ######## +# ################################## + +if __name__ == "__main__": + """Standalone execution.""" + svg_path = Path( + "tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg" + ) + assert svg_path.is_file() + print(get_svg_size(image_filepath=svg_path)) + print(get_image_size(image_filepath=svg_path)) + + svg_path = Path( + "tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg" + ) + assert svg_path.is_file() + print(get_svg_size(image_filepath=svg_path)) diff --git a/requirements/base.txt b/requirements/base.txt index e3d52502..3390e674 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,6 +1,7 @@ click>=8,<9 dulwich>=0.20,<0.20.51 giturlparse>=0.10,<0.11 +imagesize>=1.4,<1.5 pyyaml>=5.4,<7 py-setenv>=1.1,<1.2 pywin32==305 ; sys_platform == 'win32' diff --git a/requirements/testing.txt b/requirements/testing.txt index 4437c106..99b35a6d 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,6 +1,7 @@ # Testing dependencies # -------------------- +pillow>=9.4,<10 pytest-cov>=3.0,<4.1 semver>=2.13,<2.14 validators>=0.19,<0.21 diff --git a/tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg b/tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg new file mode 100644 index 00000000..f91a2a28 --- /dev/null +++ b/tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg b/tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg new file mode 100644 index 00000000..ffc317fc --- /dev/null +++ b/tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + d + + + + ? + + \ No newline at end of file diff --git a/tests/test_utils_images_size.py b/tests/test_utils_images_size.py new file mode 100644 index 00000000..6c81b4df --- /dev/null +++ b/tests/test_utils_images_size.py @@ -0,0 +1,123 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + # for whole tests + python -m unittest tests.test_utils_images_size + # for specific test + python -m unittest tests.test_utils_images_size.TestUtilsImagesSizeChecker.get_svg_size +""" + + +# standard library +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +# 3rd party +from PIL import Image + +# project +from qgis_deployment_toolbelt.__about__ import __title_clean__, __version__ +from qgis_deployment_toolbelt.utils.check_image_size import ( + check_image_dimensions, + get_image_size, + get_svg_size, +) + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestUtilsImagesSizeChecker(unittest.TestCase): + """Test package utilities.""" + + # -- Standard methods -------------------------------------------------------- + @classmethod + def setUpClass(cls) -> None: + """Set up test.""" + + cls.img_tmp_folder = TemporaryDirectory( + prefix=f"{__title_clean__}_{__version__}_" + ) + + # create a temporary image + cls.img_black_800x600 = Path(cls.img_tmp_folder.name, "img_black_800x600.jpg") + width = 800 + height = 600 + image = Image.new("RGB", (width, height), "black") + image.save(cls.img_black_800x600) + + @classmethod + def tearDownClass(cls): + """Executed after each test.""" + cls.img_tmp_folder.cleanup() + + # -- TESTS --------------------------------------------------------- + def test_svg_size_with_dimensions(self): + """Test svg size retriever.""" + svg_with_dimensions_attributes = Path( + "tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg" + ) + + self.assertTrue(svg_with_dimensions_attributes.is_file()) + # svg with dimensions set + svg_size = get_image_size(image_filepath=svg_with_dimensions_attributes) + + self.assertIsInstance(svg_size, tuple) + self.assertIsInstance(svg_size[0], int) + self.assertIsInstance(svg_size[1], int) + self.assertEqual(svg_size[0], 853) + self.assertEqual(svg_size[1], 568) + + def test_svg_size_without_dimensions(self): + """Test svg size retriever.""" + svg_without_dimensions_attributes = Path( + "tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg" + ) + self.assertTrue(svg_without_dimensions_attributes.is_file()) + svg_size = get_image_size(image_filepath=svg_without_dimensions_attributes) + + self.assertIsNone(svg_size) + + def test_get_image_dimensions(self): + img_800x600 = get_image_size(self.img_black_800x600) + self.assertIsInstance(img_800x600, tuple) + self.assertEqual(img_800x600[0], 800) + self.assertEqual(img_800x600[1], 600) + + def test_check_image_dimensions(self): + """Test image dimensions checker.""" + self.assertTrue( + check_image_dimensions( + image_filepath=self.img_black_800x600, max_width=801, max_height=601 + ) + ) + + self.assertFalse( + check_image_dimensions( + image_filepath=self.img_black_800x600, max_width=300, max_height=601 + ) + ) + + self.assertFalse( + check_image_dimensions( + image_filepath=self.img_black_800x600, max_width=2000, max_height=200 + ) + ) + + self.assertFalse( + check_image_dimensions( + image_filepath=self.img_black_800x600, max_width=300, max_height=500 + ) + ) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main()