From a406d6730cd782282ee6ed54b46cc4d8bba84ace Mon Sep 17 00:00:00 2001 From: Julien Date: Fri, 25 Nov 2022 12:54:57 +0100 Subject: [PATCH 01/11] Add module to check image size --- .../utils/check_image_size.py | 96 +++++++++++++++++++ requirements/base.txt | 1 + 2 files changed, 97 insertions(+) create mode 100644 qgis_deployment_toolbelt/utils/check_image_size.py 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..7173f6dd --- /dev/null +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -0,0 +1,96 @@ +#! python3 # noqa: E265 + +""" + Check image size using pure Python . + + Author: Julien Moura (https://github.com/guts) +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +from os import R_OK, access +from pathlib import Path +from typing import Union + +# 3rd party +import imagesize + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + +compatible_images_extensions: tuple = (".jpg", ".jpeg", ".png") + +# ############################################################################# +# ########## Functions ############# +# ################################## + + +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, +) -> bool: + """Check input image dimensions against passed limits. + + :param Union[str, Path] image_filepath: path to the image to check + :param int min_width: _description_, defaults to 500 + :param int max_width: _description_, defaults to 600 + :param int min_height: _description_, defaults to 250 + :param int max_height: _description_, defaults to 350 + + :return bool: True if image dimensions are inferior + + :example: + + .. code-block:: python + + sample_txt = "Oyé oyé brâves gens de 1973 ! Hé oh ! Sentons-nous l'ail %$*§ ?!" + print(sluggy(sample_txt)) + > oye-oye-braves-gens-de-1973-he-oh-sentons-nous-lail + """ + # check input path + if not isinstance(image_filepath, (str, Path)): + raise TypeError( + f"image_filepath must be a string or a Path, not {type(image_filepath)}." + ) + + if isinstance(image_filepath, str): + try: + image_filepath = Path(image_filepath) + except Exception as exc: + raise TypeError(f"Converting image_filepath into Path failed. Trace: {exc}") + + # check if file exists + if not image_filepath.exists(): + raise FileExistsError( + "YAML file to check doesn't exist: {}".format(image_filepath.resolve()) + ) + + # check if it's a file + if not image_filepath.is_file(): + raise IOError("YAML file is not a file: {}".format(image_filepath.resolve())) + + # check if file is readable + if not access(image_filepath, R_OK): + raise IOError("yaml file isn't readable: {}".format(image_filepath)) + + pass + + +# ############################################################################# +# ##### Stand alone program ######## +# ################################## + +if __name__ == "__main__": + """Standalone execution.""" + pass 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' From 86c3ed4ba225007f737588ee32f8ab3b43f31d7a Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 11:30:13 +0100 Subject: [PATCH 02/11] Handle SVG images --- .../utils/check_image_size.py | 82 ++++++++++++------- tests/test_utils_images_size.py | 63 ++++++++++++++ 2 files changed, 117 insertions(+), 28 deletions(-) create mode 100644 tests/test_utils_images_size.py diff --git a/qgis_deployment_toolbelt/utils/check_image_size.py b/qgis_deployment_toolbelt/utils/check_image_size.py index 7173f6dd..e432ad6f 100644 --- a/qgis_deployment_toolbelt/utils/check_image_size.py +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -12,9 +12,10 @@ # Standard library import logging -from os import R_OK, access +import xml.etree.ElementTree as ET +from decimal import Decimal from pathlib import Path -from typing import Union +from typing import Tuple, Union # 3rd party import imagesize @@ -33,6 +34,31 @@ # ################################## +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, @@ -58,33 +84,23 @@ def check_image_dimensions( print(sluggy(sample_txt)) > oye-oye-braves-gens-de-1973-he-oh-sentons-nous-lail """ - # check input path - if not isinstance(image_filepath, (str, Path)): - raise TypeError( - f"image_filepath must be a string or a Path, not {type(image_filepath)}." - ) - - if isinstance(image_filepath, str): - try: - image_filepath = Path(image_filepath) - except Exception as exc: - raise TypeError(f"Converting image_filepath into Path failed. Trace: {exc}") - # check if file exists - if not image_filepath.exists(): - raise FileExistsError( - "YAML file to check doesn't exist: {}".format(image_filepath.resolve()) + if image_filepath.suffix not in compatible_images_extensions: + logger.error("Image extension is not supported: ") + return None + # print(file.name, file.parents[0]) + + # get image dimensions + try: + width, height = imagesize.get(image_filepath) + except ValueError as exc: + logging.error(f"Invalid image: {image_filepath.resolve()}. Trace: {exc}") + width, height = -1, -1 + except Exception as exc: + logging.error( + f"Something went wrong reading the image: {image_filepath.resolve()}. Trace: {exc}" ) - - # check if it's a file - if not image_filepath.is_file(): - raise IOError("YAML file is not a file: {}".format(image_filepath.resolve())) - - # check if file is readable - if not access(image_filepath, R_OK): - raise IOError("yaml file isn't readable: {}".format(image_filepath)) - - pass + width, height = -1, -1 # ############################################################################# @@ -93,4 +109,14 @@ def check_image_dimensions( if __name__ == "__main__": """Standalone execution.""" - pass + svg_path = Path( + "tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg" + ) + assert svg_path.is_file() + print(get_svg_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/tests/test_utils_images_size.py b/tests/test_utils_images_size.py new file mode 100644 index 00000000..aa3e11da --- /dev/null +++ b/tests/test_utils_images_size.py @@ -0,0 +1,63 @@ +#! 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.TestUtilsImagesSizeChecker.get_svg_size +""" + + +import unittest + +# standard library +from pathlib import Path + +# project +from qgis_deployment_toolbelt.utils.check_image_size import get_svg_size + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestUtilsImagesSizeChecker(unittest.TestCase): + """Test package utilities.""" + + 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_svg_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 with dimensions set + svg_size = get_svg_size(image_filepath=svg_without_dimensions_attributes) + + self.assertIsNone(svg_size) + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main() From c5bbe8f9a3b28f5295c4357ba3cdee0aaa258b51 Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 12:39:28 +0100 Subject: [PATCH 03/11] Add function to get image dimensions --- .../utils/check_image_size.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/qgis_deployment_toolbelt/utils/check_image_size.py b/qgis_deployment_toolbelt/utils/check_image_size.py index e432ad6f..3f48d98a 100644 --- a/qgis_deployment_toolbelt/utils/check_image_size.py +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -34,6 +34,32 @@ # ################################## +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 = imagesize.get(image_filepath) + if not svg_size: + return None + + # 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. From 680c7ca219746d2e4016d89e205e8e057af32e24 Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:03:40 +0100 Subject: [PATCH 04/11] Add pillow as test dependencies --- requirements/testing.txt | 1 + 1 file changed, 1 insertion(+) 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 From 3b815b0637b9077159e8928af1aa87bcb262a0eb Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:03:52 +0100 Subject: [PATCH 05/11] Add sample SVG as tests fixtures --- .../sample_with_dimensions_attributes.svg | 22 +++++++++++++++++++ .../sample_without_dimensions_attributes.svg | 18 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 tests/fixtures/miscellaneous/sample_with_dimensions_attributes.svg create mode 100644 tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg 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 From be811404128d92d338f64511988fe2ef89345953 Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:04:09 +0100 Subject: [PATCH 06/11] Complete check image --- .../utils/check_image_size.py | 48 ++++++++----------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/qgis_deployment_toolbelt/utils/check_image_size.py b/qgis_deployment_toolbelt/utils/check_image_size.py index 3f48d98a..73cfe42b 100644 --- a/qgis_deployment_toolbelt/utils/check_image_size.py +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -27,7 +27,6 @@ # logs logger = logging.getLogger(__name__) -compatible_images_extensions: tuple = (".jpg", ".jpeg", ".png") # ############################################################################# # ########## Functions ############# @@ -43,7 +42,7 @@ def get_image_size(image_filepath: Path) -> Tuple[int, int]: """ # handle SVG if image_filepath.suffix.lower() == ".svg": - svg_size = imagesize.get(image_filepath) + svg_size = get_svg_size(image_filepath) if not svg_size: return None @@ -76,7 +75,7 @@ def get_svg_size(image_filepath: Path) -> Tuple[int, int]: return None try: - return (int(Decimal(root.attrib["width"])), int(Decimal(root.attrib["height"]))) + 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 " @@ -91,42 +90,35 @@ def check_image_dimensions( 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: _description_, defaults to 500 - :param int max_width: _description_, defaults to 600 - :param int min_height: _description_, defaults to 250 - :param int max_height: _description_, defaults to 350 + :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 - - :example: - - .. code-block:: python - - sample_txt = "Oyé oyé brâves gens de 1973 ! Hé oh ! Sentons-nous l'ail %$*§ ?!" - print(sluggy(sample_txt)) - > oye-oye-braves-gens-de-1973-he-oh-sentons-nous-lail """ - if image_filepath.suffix not in compatible_images_extensions: - logger.error("Image extension is not supported: ") + 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 - # print(file.name, file.parents[0]) - # get image dimensions - try: - width, height = imagesize.get(image_filepath) - except ValueError as exc: - logging.error(f"Invalid image: {image_filepath.resolve()}. Trace: {exc}") - width, height = -1, -1 - except Exception as exc: - logging.error( - f"Something went wrong reading the image: {image_filepath.resolve()}. Trace: {exc}" + 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." ) - width, height = -1, -1 + return None + + return all(d <= l for d, l in zip(image_dimensions, (max_width, max_height))) # ############################################################################# From eb7a46ab1f6d8cf2d3aaca344676f32a16aae20e Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:04:15 +0100 Subject: [PATCH 07/11] Improve tests --- tests/test_utils_images_size.py | 50 +++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/tests/test_utils_images_size.py b/tests/test_utils_images_size.py index aa3e11da..5371b8e0 100644 --- a/tests/test_utils_images_size.py +++ b/tests/test_utils_images_size.py @@ -7,17 +7,25 @@ # for whole tests python -m unittest tests.test_utils_images_size # for specific test - python -m unittest tests.test_utils.TestUtilsImagesSizeChecker.get_svg_size + python -m unittest tests.test_utils_images_size.TestUtilsImagesSizeChecker.get_svg_size """ -import unittest - # standard library +import unittest from pathlib import Path +from tempfile import TemporaryDirectory + +# 3rd party +from PIL import Image # project -from qgis_deployment_toolbelt.utils.check_image_size import get_svg_size +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 ############# @@ -27,6 +35,28 @@ 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( @@ -35,7 +65,7 @@ def test_svg_size_with_dimensions(self): self.assertTrue(svg_with_dimensions_attributes.is_file()) # svg with dimensions set - svg_size = get_svg_size(image_filepath=svg_with_dimensions_attributes) + svg_size = get_image_size(image_filepath=svg_with_dimensions_attributes) self.assertIsInstance(svg_size, tuple) self.assertIsInstance(svg_size[0], int) @@ -48,13 +78,17 @@ def test_svg_size_without_dimensions(self): svg_without_dimensions_attributes = Path( "tests/fixtures/miscellaneous/sample_without_dimensions_attributes.svg" ) - self.assertTrue(svg_without_dimensions_attributes.is_file()) - # svg with dimensions set - svg_size = get_svg_size(image_filepath=svg_without_dimensions_attributes) + 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) + # ############################################################################ # ####### Stand-alone run ######## From 5b1035cb81b396dc3f63f33ee22b7018fc09dcf6 Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:06:32 +0100 Subject: [PATCH 08/11] Fix svg size --- qgis_deployment_toolbelt/utils/check_image_size.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qgis_deployment_toolbelt/utils/check_image_size.py b/qgis_deployment_toolbelt/utils/check_image_size.py index 73cfe42b..4c7f31bf 100644 --- a/qgis_deployment_toolbelt/utils/check_image_size.py +++ b/qgis_deployment_toolbelt/utils/check_image_size.py @@ -45,6 +45,8 @@ def get_image_size(image_filepath: Path) -> Tuple[int, int]: svg_size = get_svg_size(image_filepath) if not svg_size: return None + else: + return svg_size # get image dimensions try: @@ -132,6 +134,7 @@ def check_image_dimensions( ) 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" From 97c7fc05ee9009113ea66ba3100da1c509943eff Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:06:41 +0100 Subject: [PATCH 09/11] Add temp folder and auto cleanup --- tests/test_utils_images_size.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils_images_size.py b/tests/test_utils_images_size.py index 5371b8e0..822268bc 100644 --- a/tests/test_utils_images_size.py +++ b/tests/test_utils_images_size.py @@ -54,7 +54,7 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls): """Executed after each test.""" - # cls.img_tmp_folder.cleanup() + cls.img_tmp_folder.cleanup() # -- TESTS --------------------------------------------------------- def test_svg_size_with_dimensions(self): From 76286c5845f170f70f5a13d1577b723b09db0b37 Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:08:53 +0100 Subject: [PATCH 10/11] Tests image dimensions checker --- tests/test_utils_images_size.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_utils_images_size.py b/tests/test_utils_images_size.py index 822268bc..6c81b4df 100644 --- a/tests/test_utils_images_size.py +++ b/tests/test_utils_images_size.py @@ -89,6 +89,32 @@ def test_get_image_dimensions(self): 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 ######## From 0df879712c97431705e11389f838367ab1c1410b Mon Sep 17 00:00:00 2001 From: Julien M Date: Fri, 13 Jan 2023 13:11:02 +0100 Subject: [PATCH 11/11] typo --- docs/usage/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)