348 lines
12 KiB
Python
348 lines
12 KiB
Python
import re
|
|
from collections import namedtuple
|
|
from copy import copy
|
|
from xml.etree.ElementTree import ElementTree
|
|
|
|
from .image import BadImageOperationError, Image, SvgImageFile
|
|
|
|
|
|
class WillowSvgException(Exception):
|
|
pass
|
|
|
|
|
|
class InvalidSvgAttribute(WillowSvgException):
|
|
pass
|
|
|
|
|
|
class InvalidSvgSizeAttribute(WillowSvgException):
|
|
pass
|
|
|
|
|
|
class SvgViewBoxParseError(WillowSvgException):
|
|
pass
|
|
|
|
|
|
ViewBox = namedtuple("ViewBox", "min_x min_y width height")
|
|
|
|
|
|
def view_box_to_attr_str(view_box):
|
|
return f"{view_box.min_x} {view_box.min_y} {view_box.width} {view_box.height}"
|
|
|
|
|
|
class ViewportToUserSpaceTransform:
|
|
def __init__(self, scale_x, scale_y, translate_x, translate_y):
|
|
self.scale_x = scale_x
|
|
self.scale_y = scale_y
|
|
self.translate_x = translate_x
|
|
self.translate_y = translate_y
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"{self.__class__.__name__}(scale_x={self.scale_x}, scale_y="
|
|
f"{self.scale_y}, translate_x={self.translate_x}, "
|
|
f"translate_y={self.translate_y})"
|
|
)
|
|
|
|
def __eq__(self, other):
|
|
if not isinstance(other, self.__class__):
|
|
return False
|
|
return (
|
|
self.scale_x == other.scale_x
|
|
and self.scale_y == other.scale_y
|
|
and self.translate_x == other.translate_x
|
|
and self.translate_y == other.translate_y
|
|
)
|
|
|
|
def __call__(self, rect):
|
|
left, top, right, bottom = rect
|
|
return (
|
|
(left + self.translate_x) / self.scale_x,
|
|
(top + self.translate_y) / self.scale_y,
|
|
(right + self.translate_x) / self.scale_x,
|
|
(bottom + self.translate_y) / self.scale_y,
|
|
)
|
|
|
|
|
|
def get_viewport_to_user_space_transform(
|
|
svg: "SvgImage",
|
|
) -> ViewportToUserSpaceTransform:
|
|
# cairosvg used as a reference
|
|
view_box = svg.image.view_box
|
|
|
|
preserve_aspect_ratio = svg.image.preserve_aspect_ratio.split()
|
|
try:
|
|
align, meet_or_slice = preserve_aspect_ratio
|
|
except ValueError:
|
|
align = preserve_aspect_ratio[0]
|
|
meet_or_slice = None
|
|
|
|
scale_x = svg.image.width / view_box.width
|
|
scale_y = svg.image.height / view_box.height
|
|
|
|
if align == "none":
|
|
# if align is "none", the viewBox will be scaled non-uniformly,
|
|
# so we keep and use both scale_x and scale_y
|
|
x_position = "min"
|
|
y_position = "min"
|
|
else:
|
|
x_position = align[1:4].lower()
|
|
y_position = align[5:].lower()
|
|
choose_coefficient = max if meet_or_slice == "slice" else min
|
|
# all values of preserveAspectRatio's `align', other than
|
|
# "none", force uniform scaling, so choose the appropriate
|
|
# coefficient and use it for scaling both axes
|
|
scale_x = scale_y = choose_coefficient(scale_x, scale_y)
|
|
|
|
# initial offsets to account for non-zero viewBox min-x and min-y
|
|
translate_x = view_box.min_x * scale_x
|
|
translate_y = view_box.min_y * scale_y
|
|
|
|
# adjust the offsets by the amount the viewBox has been translated
|
|
# to fit into the viewport (if any)
|
|
if x_position == "mid":
|
|
translate_x -= (svg.image.width - view_box.width * scale_x) / 2
|
|
elif x_position == "max":
|
|
translate_x -= svg.image.width - view_box.width * scale_x
|
|
|
|
if y_position == "mid":
|
|
translate_y -= (svg.image.height - view_box.height * scale_y) / 2
|
|
elif y_position == "max":
|
|
translate_y -= svg.image.height - view_box.height * scale_y
|
|
|
|
return ViewportToUserSpaceTransform(scale_x, scale_y, translate_x, translate_y)
|
|
|
|
|
|
class SvgWrapper:
|
|
# https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#length
|
|
UNIT_RE = re.compile(r"(?:em|ex|px|in|cm|mm|pt|pc|%)$")
|
|
|
|
# https://www.w3.org/TR/SVG11/types.html#DataTypeNumber
|
|
# https://www.w3.org/TR/2013/WD-SVG2-20130409/types.html#DataTypeNumber
|
|
# This will exclude some inputs that Python will accept (e.g. "1.e9", "1."),
|
|
# but for integration with other tools, we should adhere to the spec
|
|
NUMBER_PATTERN = r"([+-]?(?:\d*\.)?\d+(?:[Ee][+-]?\d+)?)"
|
|
|
|
# https://www.w3.org/Graphics/SVG/1.1/coords.html#ViewBoxAttribute
|
|
VIEW_BOX_RE = re.compile(
|
|
rf"^{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}(?:,\s*|\s+)"
|
|
rf"{NUMBER_PATTERN}(?:,\s*|\s+){NUMBER_PATTERN}$"
|
|
)
|
|
|
|
PRESERVE_ASPECT_RATIO_RE = re.compile(
|
|
r"^none$|^x(Min|Mid|Max)Y(Min|Mid|Max)(\s+(meet|slice))?$",
|
|
)
|
|
|
|
# Borrowed from cairosvg
|
|
COEFFICIENTS = {
|
|
"mm": 1 / 25.4,
|
|
"cm": 1 / 2.54,
|
|
"in": 1,
|
|
"pt": 1 / 72.0,
|
|
"pc": 1 / 6.0,
|
|
}
|
|
|
|
def __init__(self, dom: ElementTree, dpi=96, font_size_px=16):
|
|
self.dom = dom
|
|
self.dpi = dpi
|
|
self.font_size_px = font_size_px
|
|
self.view_box = self._get_view_box()
|
|
self.preserve_aspect_ratio = self._get_preserve_aspect_ratio()
|
|
|
|
width, width_unit = self._get_width()
|
|
height, height_unit = self._get_height()
|
|
# If one attr is missing or relative, we fall back to the other. After
|
|
# this either both will be valid, or neither will, which will be handled
|
|
# below. Relative width/height are treated as being undefined - so fall
|
|
# back first to the other attribute, then the viewBox, then the browser
|
|
# fallback. This gives us some flexibility for real world use cases, where
|
|
# SVGs may have a relative height, a relative width, or both
|
|
if width is None:
|
|
width = height
|
|
width_unit = height_unit
|
|
elif height is None:
|
|
height = width
|
|
height_unit = width_unit
|
|
elif width_unit == "%":
|
|
width = height
|
|
width_unit = height_unit
|
|
elif height_unit == "%":
|
|
height = width
|
|
height_unit = width_unit
|
|
|
|
# If the root svg element has no width, height, or viewBox attributes,
|
|
# emulate browser behaviour and set width and height to 300 and 150
|
|
# respectively, and set the viewBox to match
|
|
# (https://svgwg.org/specs/integration/#svg-css-sizing). This means we
|
|
# can always crop and resize without needing to rasterise
|
|
if width is None and height is None or width_unit == "%" and height_unit == "%":
|
|
if self.view_box is not None:
|
|
self.width = self.view_box.width
|
|
self.height = self.view_box.height
|
|
else:
|
|
self.width = 300
|
|
self.height = 150
|
|
else:
|
|
self.width = self._convert_to_px(width, width_unit)
|
|
self.height = self._convert_to_px(height, height_unit)
|
|
if self.view_box is None:
|
|
self.view_box = ViewBox(0, 0, self.width, self.height)
|
|
|
|
def __copy__(self):
|
|
# copy() called on ElementTree.Element makes a shallow copy (child
|
|
# elements are shared with the original) so is efficient enough - we
|
|
# only need to copy the root SVG element, as that is the only element
|
|
# we will mutate
|
|
dom = ElementTree(copy(self.dom.getroot()))
|
|
return self.__class__(dom, dpi=self.dpi, font_size_px=self.font_size_px)
|
|
|
|
@classmethod
|
|
def from_file(cls, f):
|
|
return cls(SvgImageFile(f).dom)
|
|
|
|
@property
|
|
def root(self):
|
|
return self.dom.getroot()
|
|
|
|
def _get_preserve_aspect_ratio(self):
|
|
value = self.root.get("preserveAspectRatio", "").strip()
|
|
if value == "":
|
|
return "xMidYMid meet"
|
|
if not self.PRESERVE_ASPECT_RATIO_RE.match(value):
|
|
raise InvalidSvgAttribute(
|
|
f"Unable to parse preserveAspectRatio value '{value}'"
|
|
)
|
|
return value
|
|
|
|
def _get_width(self):
|
|
attr_value = self.root.get("width")
|
|
if attr_value:
|
|
return self._parse_size(attr_value)
|
|
return None, None
|
|
|
|
def _get_height(self):
|
|
attr_value = self.root.get("height")
|
|
if attr_value:
|
|
return self._parse_size(attr_value)
|
|
return None, None
|
|
|
|
def _parse_size(self, raw_value):
|
|
clean_value = raw_value.strip()
|
|
match = self.UNIT_RE.search(clean_value)
|
|
unit = clean_value[match.start() :] if match else None
|
|
amount_raw = clean_value[: -len(unit)] if unit else clean_value
|
|
try:
|
|
amount = float(amount_raw)
|
|
except ValueError as err:
|
|
raise InvalidSvgSizeAttribute(
|
|
f"Unable to parse value from '{raw_value}'"
|
|
) from err
|
|
if amount <= 0:
|
|
raise InvalidSvgSizeAttribute(f"Negative or 0 sizes are invalid ({amount})")
|
|
return amount, unit
|
|
|
|
def _convert_to_px(self, size, unit):
|
|
if unit in (None, "px"):
|
|
return size
|
|
elif unit == "em":
|
|
return size * self.font_size_px
|
|
elif unit == "ex":
|
|
# This is not exactly correct, but it's the best we can do
|
|
return size * self.font_size_px / 2
|
|
else:
|
|
return size * self.dpi * self.COEFFICIENTS[unit]
|
|
|
|
def _get_view_box(self):
|
|
attr_value = self.root.get("viewBox")
|
|
if attr_value:
|
|
return self._parse_view_box(attr_value)
|
|
|
|
@classmethod
|
|
def _parse_view_box(cls, raw_value):
|
|
match = cls.VIEW_BOX_RE.match(raw_value.strip())
|
|
if match is None:
|
|
raise SvgViewBoxParseError(f"Unable to parse viewBox value '{raw_value}'")
|
|
return ViewBox(*map(float, match.groups()))
|
|
|
|
def set_root_attr(self, attr, value):
|
|
self.root.set(attr, str(value))
|
|
|
|
def set_width(self, width):
|
|
self.set_root_attr("width", width)
|
|
self.width = width
|
|
|
|
def set_height(self, height):
|
|
self.set_root_attr("height", height)
|
|
self.height = height
|
|
|
|
def set_view_box(self, view_box):
|
|
self.set_root_attr("viewBox", view_box_to_attr_str(view_box))
|
|
self.view_box = view_box
|
|
|
|
def write(self, f):
|
|
self.dom.write(f, encoding="utf-8")
|
|
|
|
|
|
class SvgImage(Image):
|
|
def __init__(self, image):
|
|
self.image: SvgWrapper = image
|
|
|
|
@Image.operation
|
|
def crop(self, rect, get_transformer=get_viewport_to_user_space_transform):
|
|
left, top, right, bottom = rect
|
|
if left >= right or top >= bottom:
|
|
raise BadImageOperationError(f"Invalid crop dimensions: {rect}")
|
|
|
|
viewport_width = right - left
|
|
viewport_height = bottom - top
|
|
|
|
transformed_rect = get_transformer(self)(rect)
|
|
left, top, right, bottom = transformed_rect
|
|
|
|
svg_wrapper = copy(self.image)
|
|
view_box_width = right - left
|
|
view_box_height = bottom - top
|
|
svg_wrapper.set_view_box(ViewBox(left, top, view_box_width, view_box_height))
|
|
svg_wrapper.set_width(viewport_width)
|
|
svg_wrapper.set_height(viewport_height)
|
|
return self.__class__(image=svg_wrapper)
|
|
|
|
@Image.operation
|
|
def resize(self, size):
|
|
new_width, new_height = size
|
|
if new_width < 1 or new_height < 1:
|
|
raise BadImageOperationError(f"Invalid resize dimensions: {size}")
|
|
|
|
svg_wrapper = copy(self.image)
|
|
svg_wrapper.set_width(new_width)
|
|
svg_wrapper.set_height(new_height)
|
|
return self.__class__(image=svg_wrapper)
|
|
|
|
@Image.operation
|
|
def get_size(self):
|
|
return (self.image.width, self.image.height)
|
|
|
|
@Image.operation
|
|
def auto_orient(self):
|
|
return self
|
|
|
|
@Image.operation
|
|
def has_animation(self):
|
|
return False
|
|
|
|
@Image.operation
|
|
def get_frame_count(self):
|
|
return 1
|
|
|
|
def write(self, f):
|
|
self.image.write(f)
|
|
f.seek(0)
|
|
|
|
@Image.operation
|
|
def save_as_svg(self, f):
|
|
self.write(f)
|
|
return SvgImageFile(f, dom=self.image.dom)
|
|
|
|
@classmethod
|
|
@Image.converter_from(SvgImageFile)
|
|
def open(cls, svg_image_file):
|
|
return cls(image=SvgWrapper(svg_image_file.dom))
|