Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add method to 'scale' SVGs by modifying display size and viewbox #68

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/68.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add method to 'scale' SVGs by modifying display size and viewbox. [jensens[
135 changes: 132 additions & 3 deletions plone/scale/scale.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -109,7 +116,7 @@ def scaleImage(
new_result = False

if result is None:
result = StringIO()
result = io.BytesIO()
new_result = True

image.save(
Expand Down Expand Up @@ -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))
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
test_suite="plone.scale",
install_requires=[
"Pillow",
"lxml",
"setuptools",
],
extras_require=dict(
Expand Down