4
\$\begingroup\$

svgmanip is a library that extends svgutils by providing rotation, import, and export primitives. It is available on PyPI, and was created a few years ago.

Install

pip install svgmanip

Bugs

The code of the main file, _generator.py is here:

from __future__ import division
import re
import os
from tempfile import NamedTemporaryFile

import svgutils
from lxml import etree
import mpmath as math
from ensure import ensure
from svgutils.compose import SVG, Figure, Unit
from svgutils import transform as _transform

math.dps = 17  # SVG's precision is double, which may be up to 17 digits

__all__ = ['Element']


def rotate_point(x, y, degrees):
    radians = math.radians(degrees)

    cos_theta = math.cos(radians)
    sin_theta = math.sin(radians)
    return x * cos_theta - y * sin_theta, \
           x * sin_theta + y * cos_theta


def get_shift_and_dims(points, degrees):
    rotated = [
        rotate_point(*point, degrees=degrees)
        for point in points
    ]

    unrotated = points

    # find highest x in rotated
    max_x_unrotated = max(unrotated, key=lambda point: point[0])[0]
    max_y_unrotated = max(unrotated, key=lambda point: point[1])[1]

    max_x_rotated = max(rotated, key=lambda point: point[0])[0]
    max_y_rotated = max(rotated, key=lambda point: point[1])[1]
    min_x_rotated = min(rotated, key=lambda point: point[0])[0]
    min_y_rotated = min(rotated, key=lambda point: point[1])[1]

    # Python's float is double precision so we can convert losslessly
    # However, mpmath has higher precision for  sin, cos, and tan.
    shift = float(max_x_rotated - max_x_unrotated), float(max_y_rotated - max_y_unrotated)
    dims = max_x_rotated - min_x_rotated, max_y_rotated - min_y_rotated
    return shift, dims


def get_quad_shift_and_dims(width, height, degrees):
    return get_shift_and_dims(
        [(+width / 2, -height / 2),
         (-width / 2, -height / 2),
         (+width / 2, +height / 2),
         (-width / 2, +height / 2), ],
        degrees,
    )


class Element(Figure):
    @staticmethod
    def _parse_string_dimension(dimension):
        if dimension is None:
            raise ValueError('Expected `dimension` to be str, Unit, float, or int, got None.')

        if isinstance(dimension, str):
            dimension = dimension.strip()
            groups = re.match(r'(\d+)\w*', dimension).groups()
            assert len(groups) == 1  # if this errors, your SVG probably has an invalid size!
            return float(groups[0])
        elif isinstance(dimension, Unit):
            return dimension.to('px').value
        elif isinstance(dimension, float) or isinstance(dimension, int):
            return dimension  # it *should* be a float

    def __init__(self, width_or_filename, height=None, *svgelements):
        if height is None:
            # some pretty hacky code to autodetect height
            assert len(svgelements) == 0
            svgfigure = svgutils.transform.fromfile(width_or_filename)
            svg = SVG(width_or_filename).scale(1)  # scale of 1 converts to an Element
            super(Element, self).__init__(self._parse_string_dimension(svgfigure.width),
                                          self._parse_string_dimension(svgfigure.height), svg)
        else:
            super(Element, self).__init__(width_or_filename, height, *svgelements)

    def placeat(self, element, x, y):
        ensure(element).is_an(Element)
        ensure(x).is_numeric()
        ensure(y).is_numeric()
        super(Element, self).__init__(self.width, self.height, self, element.move(x, y))

    @property
    def width(self):
        return self._parse_string_dimension(self._width)

    @width.setter
    def width(self, new):
        self._width = new

    @property
    def height(self):
        return self._parse_string_dimension(self._height)

    @height.setter
    def height(self, new):
        self._height = new

    def rotate(self, angle, x=None, y=None):
        if x is None and y is None:
            self.rotate(angle, self._parse_string_dimension(self.width) / 2,
                        self._parse_string_dimension(self.height) / 2)
            # height then width
            quad_shift, quad_dims = get_quad_shift_and_dims(self.width, self.height, angle)
            figure = type(self)(quad_dims[0], quad_dims[1], self)
            figure.move(*quad_shift)

            return figure
        else:
            ensure(x).is_numeric()
            ensure(y).is_numeric()
            return super(Element, self).rotate(angle, x, y)

    @staticmethod
    def loads(string):
        return svgutils.transform.fromstring(string)

    @staticmethod
    def load(filename):
        return svgutils.transform.fromfile(filename)

    def dumps(self):
        # the default .tostr() function fails to return the same text .dump() would return
        element = _transform.SVGFigure(self.width, self.height)
        element.append(self)
        out = etree.tostring(element.root, xml_declaration=True,
                             standalone=True,
                             pretty_print=True)
        # but even this doesn't make it. The encoding must be changed to UTF-8 (otherwise svgexport fails)
        out = out.replace("version='1.0'", 'version="1.0"')
        out = out.replace("encoding='ASCII'", 'encoding="UTF-8"')
        return out

    def dump(self, filename):
        return self.save(filename)

    def to_png(self, width, height=None):
        # rather unfortunately, the two available python libraries either
        #  a) don't support Python 2.7 or b) generate awful images
        if height is None:
            wh_string = width
        else:
            wh_string = "{}:{}".format(width, height)
        # svgexport fails on files missing the svg extension
        in_tempfile = NamedTemporaryFile(suffix=".svg", delete=False)
        in_tempfile.write(self.dumps())

        # svgexport also fails if the file isn't closed
        in_tempfile.close()

        out_tempfile = NamedTemporaryFile()
        command = 'svgexport {} {} {} > /dev/null'.format(in_tempfile.name, out_tempfile.name, wh_string)
        exit_code = os.system(command)
        if exit_code != 0:
            raise RuntimeError("External command svgexport failed")
        out_tempfile.seek(0)
        return out_tempfile.read()

    def save_as_png(self, filename, width, height=None):
        with open(filename, mode="wb") as f:
            f.write(self.to_png(width, height))

    def find_id(self, element_id):
        """Find a single element with the given ID.

        Parameters
        ----------
        element_id : str
            ID of the element to find

        Returns
        -------
        found element
        """
        element = _transform.FigureElement.find_id(self, element_id)
        return type(self)(element.root)
\$\endgroup\$
3
\$\begingroup\$

Firstly: congratulations on listing this in pypi. That's pretty cool.

Documentation

Particularly since this is a publicly-consumable library, it's very important that you add docstrings to all of your exposed functions. You know the routine: triple-quoted comments at the top of each function describing the return value and the parameters.

Type hints

For the same reason as above, it's important to have type hints on your methods. At a guess,

def rotate_point(x, y, degrees):

would become

def rotate_point(x: float, y: float, degrees: float) -> float:

Lambdas

... are great, to a point. However, these:

max_x_unrotated = max(unrotated, key=lambda point: point[0])[0]
max_y_unrotated = max(unrotated, key=lambda point: point[1])[1]

max_x_rotated = max(rotated, key=lambda point: point[0])[0]
max_y_rotated = max(rotated, key=lambda point: point[1])[1]
min_x_rotated = min(rotated, key=lambda point: point[0])[0]
min_y_rotated = min(rotated, key=lambda point: point[1])[1]

are better-represented as reusable functions, perhaps

def _first_coord(point: Sequence[float]) -> float:
    return point[0]
def _second_coord(point: Sequence[float]) -> float:
    return point[1]

Python version

Which one are you supporting? Because super(Element, self) indicates Python 2.

Naming convention

placeat would be place_at.

\$\endgroup\$
  • 4
    \$\begingroup\$ _first_coord and _second_coord could also be implemented using operator.itemgetter. \$\endgroup\$ – AlexV Sep 25 at 6:51
  • \$\begingroup\$ I think most of my publicly accessible functions are self-explanatory by their names. What do you suggest I add for docstrings? \$\endgroup\$ – noɥʇʎԀʎzɐɹƆ Sep 25 at 20:02
  • \$\begingroup\$ "Which one are you supporting?" It seems like both methods of calling super are supported in Python 3, so I see no reason to break compatibility with Py3-- not until Py2 is formally deprecated. \$\endgroup\$ – noɥʇʎԀʎzɐɹƆ Sep 25 at 20:08
  • \$\begingroup\$ Self-explanatory is relative. get_shift_and_dims probably returns two things. What are those things? This is really not self-explanatory. \$\endgroup\$ – Reinderien Sep 25 at 20:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.