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[ diff --git a/plone/scale/scale.py b/plone/scale/scale.py index 3fe7be6..6694952 100644 --- a/plone/scale/scale.py +++ b/plone/scale/scale.py @@ -1,9 +1,13 @@ -from io import BytesIO as StringIO +from lxml import etree import math +import io +import logging import PIL.Image import PIL.ImageFile +import re import sys +import typing import warnings @@ -13,6 +17,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. @@ -22,6 +28,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 @@ -71,7 +78,7 @@ def scaleImage( not lost, which JPEG does not support. """ if isinstance(image, (bytes, str)): - image = StringIO(image) + image = io.BytesIO(image) image = PIL.Image.open(image) # When we create a new image during scaling we loose the format # information, so remember it here. @@ -109,7 +116,7 @@ def scaleImage( new_result = False if result is None: - result = StringIO() + result = io.BytesIO() new_result = True image.save( @@ -437,3 +444,125 @@ def scalePILImage(image, width=None, height=None, mode="contain", 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: io.StringIO, + target_width: typing.Union[None, int], + target_height: typing.Union[None, int], + mode: str = "contain", +) -> 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. + + 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`. + """ + 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 = float(source_width), float(source_height) + except ValueError: + 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)) + return data, (int(target_width), int(target_height)) + + 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 + elif mode == "contain": + 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)) diff --git a/setup.py b/setup.py index 33a745f..7bdbb15 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ test_suite="plone.scale", install_requires=[ "Pillow", + "lxml", "setuptools", ], extras_require=dict(