From b47d6c59d67c52e123ebd301d1ed1fb7d4d85867 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 8 Sep 2022 18:14:52 +0200 Subject: [PATCH 01/10] add method to 'scale' svgs by modifying display size and viewbox --- plone/scale/scale.py | 119 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index afdd0f5..8e3eeac 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -1,6 +1,7 @@ -from io import BytesIO as StringIO +from lxml import etree import math +import io import PIL.Image import PIL.ImageFile import PIL.ImageSequence @@ -75,7 +76,7 @@ def scaleImage( not lost, which JPEG does not support. """ if isinstance(image, (bytes, str)): - image = StringIO(image) + image = io.BytesIO(image) animated_kwargs = {} with PIL.Image.open(image) as img: @@ -130,7 +131,7 @@ def scaleImage( new_result = False if result is None: - result = StringIO() + result = io.BytesIO() new_result = True image.save( @@ -493,3 +494,115 @@ def scalePILImage(image, width=None, height=None, mode="scale", direction=None): image = image.crop(dimensions.post_scale_crop) return image + + +def _contain_svg_image(root, target_width: int, target_height: int): + """Scale SVG viewbox, modifies tree in place. + + Starts by scaling the relatively smallest dimension to the required size and crops the other dimension if needed. + """ + viewbox = root.attrib.get("viewBox", "").split(" ") + if len(viewbox) != 4: + return root + + try: + viewbox = [int(float(x)) for x in viewbox] + except ValueError: + return target_width, target_height + viewbox_width = viewbox[2] + viewbox_height = viewbox[3] + if not viewbox_width or not viewbox_height: + return target_width, target_height + + # if we have a max height set, make it square + if target_width == 65536: + target_width = target_height + elif target_height == 65536: + target_height = target_width + + target_ratio = target_width / target_height + view_box_ratio = viewbox_width / viewbox_height + if target_ratio < view_box_ratio: + # narrow down the viewbox width to the same ratio as the target + width = (target_ratio / view_box_ratio) * viewbox_width + margin = (viewbox_width - width) / 2 + viewbox[0] = round(viewbox[0] + margin) + viewbox[2] = round(width) + else: + # narrow down the viewbox height to the same ratio as the target + height = (view_box_ratio / target_ratio) * viewbox_height + margin = (viewbox_height - height) / 2 + viewbox[1] = round(viewbox[1] + margin) + viewbox[3] = round(height) + root.attrib["viewBox"] = " ".join([str(x) for x in viewbox]) + return target_width, target_height + + +def scale_svg_image( + image: str, + target_width: None | int, + target_height: None | int, + mode: str = "contain", +) -> tuple[bytes, tuple[int, int]]: + """Scale and crop a SVG image to another display size. + + This is all about scaling for the display in a web browser. + + Either width or height - or both - must be given. + + Three different scaling options are supported via `mode` and correspond to + the CSS background-size values + (see https://developer.mozilla.org/en-US/docs/Web/CSS/background-size): + + `contain` + Alternative spellings: `scale-crop-to-fit`, `down`. + Starts by scaling the relatively smallest dimension to the required + size and crops the other dimension if needed. + + `cover` + Alternative spellings: `scale-crop-to-fill`, `up`. + Scales the relatively largest dimension up to the required size. + Despite the alternative spelling, I see no cropping happening. + + `scale` + Alternative spellings: `keep`, `thumbnail`. + Scales to the requested dimensions without cropping. The resulting + image may have a different size than requested. This option + requires both width and height to be specified. + Does scale up. + + The `image` parameter must be bytes of the SVG, utf-8 encoded. + + The return value the scaled bytes in the form of another instance of + `PIL.Image`. + """ + target_size = target_width, target_height + mode = get_scale_mode(mode) + tree = etree.parse(image) + root = tree.getroot() + try: + source_width, source_height = int(float(root.attrib["width"])), int(float(root.attrib["height"])) + except (KeyError, ValueError): + data = image.read() + if isinstance(data, str): + return data.encode("utf-8") + return data + + source_aspectratio = source_width / source_height + target_aspectratio = target_width / target_height + if mode in ["scale", "cover"]: + # check if new width is larger than the one we get with aspect ratio + # if we scale on height + if source_width * target_aspectratio < target_width: + # keep height, new width + target_width = target_height * source_aspectratio + else: + target_height = target_width / source_aspectratio + target_size = target_width, target_height + elif mode == "contain": + target_width, target_height = _contain_svg_image(root, target_width, target_height) + + root.attrib["width"] = str(target_width) + root.attrib["height"] = str(target_height) + + return etree.tostring(tree, encoding="utf-8", xml_declaration=True), (target_width, target_height) From e1b19516c51c4d16b57864d3abd4f4973de52900 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 8 Sep 2022 18:18:07 +0200 Subject: [PATCH 02/10] needs now lxml --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 0816549..cdcb841 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ python_requires=">=3.8", install_requires=[ "Pillow", + "lxml", "setuptools", "zope.annotation", "zope.interface", From f67f26444af4e5319599bc2c3f6e500f3a07e30e Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Sat, 10 Sep 2022 20:30:12 +0200 Subject: [PATCH 03/10] old typing usage --- plone/scale/scale.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index 8e3eeac..bfeace7 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -6,6 +6,7 @@ import PIL.ImageFile import PIL.ImageSequence import sys +import typing import warnings @@ -540,8 +541,8 @@ def _contain_svg_image(root, target_width: int, target_height: int): def scale_svg_image( image: str, - target_width: None | int, - target_height: None | int, + target_width: typing.Union[None, int], + target_height: typing.Union[None, int], mode: str = "contain", ) -> tuple[bytes, tuple[int, int]]: """Scale and crop a SVG image to another display size. From 264a18ae36fe3cc8977e0d5d31e036bd58b5dc16 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Tue, 13 Sep 2022 01:34:05 +0200 Subject: [PATCH 04/10] do not use collections in typing directly --- plone/scale/scale.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index bfeace7..d3a75f3 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -544,7 +544,7 @@ def scale_svg_image( target_width: typing.Union[None, int], target_height: typing.Union[None, int], mode: str = "contain", -) -> tuple[bytes, tuple[int, int]]: +) -> typing.Tuple[bytes, typing.Tuple[int, int]]: """Scale and crop a SVG image to another display size. This is all about scaling for the display in a web browser. From fef67a5198360b014c4a32291f247894fc2c33a9 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Tue, 13 Sep 2022 02:28:10 +0200 Subject: [PATCH 05/10] width and height in SVG may have a unti --- plone/scale/scale.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index d3a75f3..cbfe749 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -5,6 +5,7 @@ import PIL.Image import PIL.ImageFile import PIL.ImageSequence +import re import sys import typing import warnings @@ -25,6 +26,7 @@ # a height of 0 or less is ignored. MAX_HEIGHT = 65000 +FLOAT_RE = re.compile(r"(?:\d*\.\d+|\d+)") def none_as_int(the_int): """For python 3 compatibility, to make int vs. none comparison possible @@ -540,7 +542,7 @@ def _contain_svg_image(root, target_width: int, target_height: int): def scale_svg_image( - image: str, + image: io.StringIO, target_width: typing.Union[None, int], target_height: typing.Union[None, int], mode: str = "contain", @@ -577,13 +579,23 @@ def scale_svg_image( The return value the scaled bytes in the form of another instance of `PIL.Image`. """ - target_size = target_width, target_height mode = get_scale_mode(mode) tree = etree.parse(image) root = tree.getroot() + source_width, source_height = root.attrib.get("width", ""), root.attrib.get("height", "") + + # strip units from width and height + match = FLOAT_RE.match(source_width) + if match: + source_width = match.group(0) + match = FLOAT_RE.match(source_height) + if match: + source_height = match.group(0) + + # to float try: - source_width, source_height = int(float(root.attrib["width"])), int(float(root.attrib["height"])) - except (KeyError, ValueError): + source_width, source_height = float(source_width), float(source_height) + except ValueError: data = image.read() if isinstance(data, str): return data.encode("utf-8") @@ -599,11 +611,10 @@ def scale_svg_image( target_width = target_height * source_aspectratio else: target_height = target_width / source_aspectratio - target_size = target_width, target_height elif mode == "contain": target_width, target_height = _contain_svg_image(root, target_width, target_height) - root.attrib["width"] = str(target_width) - root.attrib["height"] = str(target_height) + root.attrib["width"] = str(int(target_width)) + root.attrib["height"] = str(int(target_height)) return etree.tostring(tree, encoding="utf-8", xml_declaration=True), (target_width, target_height) From acdd9eded3f59a3a0d3fb9ddeab12809449cacbd Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Tue, 13 Sep 2022 02:31:28 +0200 Subject: [PATCH 06/10] ad changelog --- news/68.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/68.bugfix diff --git a/news/68.bugfix b/news/68.bugfix new file mode 100644 index 0000000..8598223 --- /dev/null +++ b/news/68.bugfix @@ -0,0 +1 @@ +Add method to 'scale' SVGs by modifying display size and viewbox. [jensens[ From 78bf1738d3d4a5178c6d9546a359e85e2460b753 Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 15 Sep 2022 11:18:10 +0200 Subject: [PATCH 07/10] early returns need to provide scale as well, log reason --- plone/scale/scale.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index cbfe749..5478ae9 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -2,6 +2,7 @@ import math import io +import logging import PIL.Image import PIL.ImageFile import PIL.ImageSequence @@ -17,6 +18,8 @@ except AttributeError: LANCZOS = PIL.Image.ANTIALIAS +logger = logging.getLogger(__name__) + # When height is higher than this we do not limit the height, but only the width. # Otherwise cropping does not make sense, and in a Pillow you may get an error. # In a Pillow traceback I saw 65500 as maximum. @@ -596,10 +599,11 @@ def scale_svg_image( try: source_width, source_height = float(source_width), float(source_height) except ValueError: + logger.exception("Can not convert source dimensions") data = image.read() if isinstance(data, str): - return data.encode("utf-8") - return data + return data.encode("utf-8"), (target_width, target_height) + return data, (target_width, target_height) source_aspectratio = source_width / source_height target_aspectratio = target_width / target_height From 443fac84e674cbddc4db53a0a5fabd3f8845305e Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 22 Sep 2022 22:21:32 +0200 Subject: [PATCH 08/10] fix problem with floats returned, but anyway be careful and handle floats --- plone/scale/scale.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index 5478ae9..c2a98cf 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -599,11 +599,11 @@ def scale_svg_image( try: source_width, source_height = float(source_width), float(source_height) except ValueError: - logger.exception("Can not convert source dimensions") + logger.exception(f"Can not convert source dimensions: '{source_width}':'{source_height}'") data = image.read() if isinstance(data, str): - return data.encode("utf-8"), (target_width, target_height) - return data, (target_width, target_height) + return data.encode("utf-8"), (int(target_width), int(target_height)) + return data, (int(target_width), int(target_height)) source_aspectratio = source_width / source_height target_aspectratio = target_width / target_height @@ -621,4 +621,4 @@ def scale_svg_image( root.attrib["width"] = str(int(target_width)) root.attrib["height"] = str(int(target_height)) - return etree.tostring(tree, encoding="utf-8", xml_declaration=True), (target_width, target_height) + return etree.tostring(tree, encoding="utf-8", xml_declaration=True), (int(target_width), int(target_height)) From 8558a3feec539fc40cbe5092e03dc3e43c470e8b Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Wed, 2 Apr 2025 14:51:48 +0200 Subject: [PATCH 09/10] tox -e lint --- plone/scale/scale.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index c2a98cf..bae5b61 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -1,8 +1,8 @@ from lxml import etree -import math import io import logging +import math import PIL.Image import PIL.ImageFile import PIL.ImageSequence @@ -31,6 +31,7 @@ FLOAT_RE = re.compile(r"(?:\d*\.\d+|\d+)") + def none_as_int(the_int): """For python 3 compatibility, to make int vs. none comparison possible without changing the algorithms below. @@ -585,7 +586,9 @@ def scale_svg_image( mode = get_scale_mode(mode) tree = etree.parse(image) root = tree.getroot() - source_width, source_height = root.attrib.get("width", ""), root.attrib.get("height", "") + source_width, source_height = root.attrib.get("width", ""), root.attrib.get( + "height", "" + ) # strip units from width and height match = FLOAT_RE.match(source_width) @@ -599,7 +602,9 @@ def scale_svg_image( try: source_width, source_height = float(source_width), float(source_height) except ValueError: - logger.exception(f"Can not convert source dimensions: '{source_width}':'{source_height}'") + logger.exception( + f"Can not convert source dimensions: '{source_width}':'{source_height}'" + ) data = image.read() if isinstance(data, str): return data.encode("utf-8"), (int(target_width), int(target_height)) @@ -616,9 +621,14 @@ def scale_svg_image( else: target_height = target_width / source_aspectratio elif mode == "contain": - target_width, target_height = _contain_svg_image(root, target_width, target_height) + target_width, target_height = _contain_svg_image( + root, target_width, target_height + ) root.attrib["width"] = str(int(target_width)) root.attrib["height"] = str(int(target_height)) - return etree.tostring(tree, encoding="utf-8", xml_declaration=True), (int(target_width), int(target_height)) + return etree.tostring(tree, encoding="utf-8", xml_declaration=True), ( + int(target_width), + int(target_height), + ) From ac11536387cb4d2cfd1c06d08de3bf2f38f74e8a Mon Sep 17 00:00:00 2001 From: "Jens W. Klein" Date: Thu, 3 Apr 2025 17:33:49 +0200 Subject: [PATCH 10/10] needs BytesIO in Py 3.13 --- plone/scale/scale.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plone/scale/scale.py b/plone/scale/scale.py index bae5b61..6e43b56 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -1,5 +1,6 @@ from lxml import etree +import codecs import io import logging import math @@ -546,7 +547,7 @@ def _contain_svg_image(root, target_width: int, target_height: int): def scale_svg_image( - image: io.StringIO, + image: io.BytesIO, target_width: typing.Union[None, int], target_height: typing.Union[None, int], mode: str = "contain", @@ -584,6 +585,13 @@ def scale_svg_image( `PIL.Image`. """ mode = get_scale_mode(mode) + + if isinstance(image, io.StringIO): + image = codecs.EncodedFile(image, "utf-8") + warnings.warn( + "The 'image' is a StringIO, but a BytesIO is needed, autoconvert.", + DeprecationWarning, + ) tree = etree.parse(image) root = tree.getroot() source_width, source_height = root.attrib.get("width", ""), root.attrib.get(