angrybeanie_wagtail/env/lib/python3.12/site-packages/willow/svg.py

349 lines
12 KiB
Python
Raw Permalink Normal View History

2025-07-25 21:32:16 +10:00
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))