Source code for pyinkscape.inkscape

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
'''
Helper functions to manipulate Inkscape SVG content

Latest version can be found at https://github.com/letuananh/pyinkscape

@author: Le Tuan Anh <tuananh.ke@gmail.com>
@license: MIT
'''

# Copyright (c) 2017, Le Tuan Anh <tuananh.ke@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

########################################################################

import os
import logging
import math
import warnings
from pathlib import Path
try:
    from lxml import etree
    from lxml.etree import XMLParser
    _LXML_AVAILABLE = True
except Exception as e:
    # logging.getLogger(__name__).debug("lxml is not available, fall back to xml.etree.ElementTree")
    from xml.etree import ElementTree as etree
    from xml.etree.ElementTree import XMLParser
    _LXML_AVAILABLE = False
try:
    from chirptext.anhxa import IDGenerator
    from chirptext.cli import setup_logging
    _CHIRPTEXT_AVAILABLE = True
except Exception as e:
    _CHIRPTEXT_AVAILABLE = False
    # When chirptext is not available, fall back to built-in IDGenerator
    # IDGenerator class is adopted from:
    # https://github.com/letuananh/chirptext/blob/master/chirptext/anhxa.py
    import threading
    class IDGenerator(object):

        def __init__(self, id_seed=1, id_hook=None):
            ''' id_seed = starting number '''
            self.__id_seed = id_seed
            self.__id_check_hook = id_hook  # external ID checker
            self.__lock = threading.Lock()

        def __next__(self):
            with self.__lock:
                while True:
                    valid_id = self.__id_seed
                    self.__id_seed += 1
                    if self.__id_check_hook is None or not self.__id_check_hook(valid_id):
                        break
                return valid_id


_MY_DIR = Path(os.path.dirname(os.path.realpath(__file__)))
_BLANK_CANVAS = _MY_DIR / 'data' / 'blank.svg'

# ------------------------------------------------------------------------------
# use chirptext setup_logging if possible
try:
    setup_logging('logging.json', 'logs')
except Exception:
    pass
# -------------------------------------------------------------------------------
# Configuration
# ------------------------------------------------------------------------------


def getLogger():
    return logging.getLogger(__name__)


class Style:
    def __init__(self, **kwargs):
        self.attributes = {}
        self.attributes.update(kwargs)

    # alias of attributes
    @property
    def attrs(self):
        return self.attributes

    def __str__(self):
        return ";".join("{}:{}".format(k.replace('_', '-'), v) for k, v in self.attributes.items())

    def clone(self, **kwargs):
        s = Style(**self.attributes)
        s.attributes.update(kwargs)
        return s


INKSCAPE_NS = 'http://www.inkscape.org/namespaces/inkscape'
SVG_NS = 'http://www.w3.org/2000/svg'
SVG_NAMESPACES = {'ns': SVG_NS,
          'svg': SVG_NS,
          'dc': "http://purl.org/dc/elements/1.1/",
          'cc': "http://creativecommons.org/ns#",
          'rdf': "http://www.w3.org/1999/02/22-rdf-syntax-ns#",

          "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
          'inkscape': INKSCAPE_NS}
DEFAULT_LINESTYLE = Style(display='inline', fill='none', stroke_width='0.86458332px', stroke_linecap='butt', stroke_linejoin='miter', stroke_opacity='1', stroke='#FF0000')
STYLE_FPNAME = Style(font_size='20px', font_family='sans-serif', font_style='normal', font_weight='normal', line_height='1.25', letter_spacing='0px', word_spacing='0px', fill='#000000', fill_opacity='1', stroke='none')
BLIND_COLORS = ("#999999", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7")
__idgen = IDGenerator()


def new_id(prefix=None):
    if not prefix:
        prefix = '_pyinkscape_'
    return '{prefix}_{id}'.format(prefix=prefix, id=next(__idgen))


class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return str(self)

    def __str__(self):
        return f"Point(x={self.x}, y={self.y})"

    def __eq__(self, other):
        if not isinstance(other, Point):
            return NotImplemented
        return self.x == other.x and self.y == other.y

    def __add__(self, other):
        if isinstance(other, Point):
            return Point(self.x + other.x, self.y + other.y)
        if isinstance(other, Dimension):
            return Point(self.x + other.width, self.y + other.height)
        else:
            return Point(self.x + other, self.y + other)

    def __sub__(self, other):
        if isinstance(other, Point):
            return Point(self.x - other.x, self.y - other.y)
        if isinstance(other, Dimension):
            return Point(self.x - other.width, self.y - other.height)
        else:
            return Point(self.x - other, self.y - other)

    def __mul__(self, other):
        if isinstance(other, Point):
            return Point(self.x * other.x, self.y * other.y)
        if isinstance(other, Dimension):
            return Point(self.x * other.width, self.y * other.height)
        else:
            return Point(self.x * other, self.y * other)

    def __div__(self, other):
        if isinstance(other, Point):
            return Point(self.x / other.x, self.y / other.y)
        if isinstance(other, Dimension):
            return Point(self.x / other.width, self.y / other.height)
        else:
            return Point(self.x / other, self.y / other)

    @staticmethod
    def rotate_percent(point, center, percent):
        degrees = percent * 3.6
        getLogger().debug(f"Percent: {percent} - Degrees: {degrees}")
        return Point.rotate(point, center, degrees)

    @staticmethod
    def ensure(p):
        if isinstance(p, Point):
            return p
        else:
            return Point(*p)

    @staticmethod
    def rotate(point, center, theta: float):
        ''' Rotate a `point` around a `center` point by `theta` degrees

        :param point: The point to rotate
        :param center: The center point of the rotation
        :param theta: Rotating angle, in degrees
        '''
        point = Point.ensure(point)
        center = Point.ensure(center)
        t_rad = math.radians(theta)
        n_x = point.x - center.x  # shift center point to (0, 0)
        n_y = point.y - center.y
        getLogger().debug(f"shifted = ({n_x}, {n_y}) - theta (in radians) = {t_rad}")
        r_x = n_x * math.cos(t_rad) - n_y * math.sin(t_rad)  # rotation matrix
        r_y = n_x * math.sin(t_rad) + n_y * math.cos(t_rad)
        getLogger().debug(f"new point = ({r_x + center.x}, {r_y + center.y})")
        return Point(r_x + center.x, r_y + center.y)  # shift it back


class Dimension:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def ensure(p):
        if isinstance(p, Dimension):
            return p
        else:
            return Dimension(*p)


class BBox():
    ''' A bounding box represents by a top-left anchor (x1, y1) and a dimension (width, height) '''
    
    def __init__(self, x, y, width, height):
        self.__anchor = Point(x, y)
        self.__dimension = Dimension(width, height)

    @property
    def x1(self):
        ''' x value of the top-left anchor '''
        return self.__anchor.x

    @property
    def y1(self):
        ''' y value of the top-left anchor '''
        return self.__anchor.y

    @property
    def width(self):
        ''' Width of the bounding box '''
        return self.__dimension.width

    @property
    def height(self):
        ''' Height of the bounding box '''
        return self.__dimension.height

    @property
    def x2(self):
        return self.__anchor.x + self.__dimension.width

    @property
    def y2(self):
        return self.__anchor.y + self.__dimension.height

    def to_tuple(self) -> tuple:
        return (self.x1, self.y1, self.width, self.height)

    def __str__(self):
        return f"{self.x1} {self.y1} {self.width} {self.height}"


[docs]class Group: ''' Represents either a group (composite object) or a layer (special group) ''' def __init__(self, elem, parent_elem): self.elem = elem self.parent_elem = parent_elem self.ID = elem.get('id') self.tag = elem.tag self.label = elem.get('{http://www.inkscape.org/namespaces/inkscape}label')
[docs] def delete(self): ''' Remove this group/layer from a canvas ''' self.parent_elem.remove(self.elem)
# self.elem.getparent().remove(self.elem) def paths(self): paths = self.elem.xpath('//ns:path', namespaces=SVG_NAMESPACES) return [Path(p) for p in paths] def new(self, tag_name, id=None, style=None, id_prefix=None, **kwargs): e = etree.SubElement(self.elem, tag_name) if not id: id = new_id(prefix=id_prefix) e.set('id', id) if style: e.set('style', str(style)) for k, v in kwargs.items(): e.set(str(k), str(v)) return e
[docs] def line(self, from_point, to_point, style: Style=DEFAULT_LINESTYLE, id_prefix='__pyinkscape_line', **kwargs): ''' Draw a new line between two points using a style :param style: A `Style` object :type style: pyinkscape.inkscape.Style ''' from_point = Point.ensure(from_point) to_point = Point.ensure(to_point) return self.new("line", x1=from_point.x, y1=from_point.y, x2=to_point.x, y2=to_point.y, style=style, id_prefix=id_prefix, **kwargs)
def rect(self, topleft, size, style=DEFAULT_LINESTYLE, id_prefix='__pyinkscape_rect', **kwargs): topleft = Point.ensure(topleft) size = Dimension.ensure(size) return self.new("rect", x=topleft.x, y=topleft.y, width=size.width, height=size.height, style=style, id_prefix=id_prefix, **kwargs) def path(self, path_code, id=None, style=DEFAULT_LINESTYLE, id_prefix='__pyinkscape_path', **kwargs): p = self.new("path", style=style, id_prefix=id_prefix, **kwargs) p.set('d', path_code) p.set('{http://www.inkscape.org/namespaces/inkscape}connector-curvature', "0") return Path(p) def circle(self, center, r, style=DEFAULT_LINESTYLE, id_prefix='__pyinkscape_circle', **kwargs): center = Point.ensure(center) c = self.new("circle", style=style, id_prefix=id_prefix, **kwargs) c.set('cx', str(center.x)) c.set('cy', str(center.y)) c.set('r', str(r)) return Circle(c) def text(self, text, center, width='', height='', font_size='18px', font_family="sans-serif", fill="black", text_anchor='middle', style=STYLE_FPNAME, id_prefix='__pyinkscape_text', **kwargs): txt = self.new('text', id_prefix=id_prefix) center = Point.ensure(center) txt.set('x', str(center.x)) txt.set('y', str(center.y)) txt.set('font-size', font_size) txt.set('font-family', font_family) txt.set('fill', fill) txt.set('text-anchor', text_anchor) if style: txt.set('style', str(style)) for k, v in kwargs.items(): txt.set(k, str(v)) txt.text = text return Text(txt)
class Shape: def __init__(self, elem): self.elem = elem self.ID = elem.get('id') self.label = elem.get('label') class Text(Shape): pass class Circle(Shape): pass class Path(Shape): pass
[docs]class Canvas: ''' This class represents an Inkscape drawing page (i.e. a SVG file) ''' FILEPATH_MEMORY = ':memory:' def __init__(self, filepath=FILEPATH_MEMORY, *args, **kwargs): ''' Create a new blank canvas or read from an existing file. To create a blank canvas, just ignore the filepath property. >>> c = Canvas() To open an existing file, use >>> c = Canvas("/path/to/file.svg") :param filepath: Path to an existing SVG file. :type filepath: str ''' self.__filepath = filepath self.__tree = None self.__root = None self.__units = 'mm' self.__width = 0 self.__height = 0 self.__viewbox = None self.__scale = 1.0 self.__elem_group_map = dict() if filepath is not None: self.__load_file(*args, **kwargs) def __load_file(self, remove_blank_text=True, encoding="utf-8", **kwargs): with open(_BLANK_CANVAS if self.__filepath == Canvas.FILEPATH_MEMORY else self.__filepath, encoding=encoding) as infile: if _LXML_AVAILABLE: kwargs['remove_blank_text'] = remove_blank_text # this flag is lxml specific parser = XMLParser(**kwargs) if not _LXML_AVAILABLE: for k, v in SVG_NAMESPACES.items(): etree.register_namespace(k, v) # register SVG as the default namespace etree.register_namespace('', SVG_NS) self.__tree = etree.parse(infile, parser) self.__root = self.__tree.getroot() self.__update_vsg_info() def __update_vsg_info(self): # load SVG information if self.__svg_node.get('width'): self.__units = self.__svg_node.get('width')[-2:] self.__width = float(self.__svg_node.get('width')[:-2]) if self.__svg_node.get('height'): self.__height = float(self.__svg_node.get('height')[:-2]) if self.__svg_node.get('viewBox'): self.__viewbox = BBox(*(float(x) for x in self.__svg_node.get('viewBox').split())) if not self.__width: self.__width = self.__viewbox.width if not self.__height: self.__width = self.__viewbox.height if self.viewBox and self.__width: self.__scale = self.viewBox.width / self.__width def __parent_map(self): return {c: p for p in self.__tree.iter() for c in p} def __build_group(self, elem): if elem not in self.__elem_group_map: if _LXML_AVAILABLE: _group_obj = Group(elem, elem.getparent()) else: _group_obj = Group(elem, self.__parent_map()[elem]) self.__elem_group_map[elem] = _group_obj return self.__elem_group_map[elem] @property def __svg_node(self): return self.__root @property def units(self): return self.__units @property def width(self): return self.__width @property def height(self): return self.__height @property def scale(self): return self.__scale @property def viewBox(self): return self.__viewbox @property def version(self): return self.__svg_node.get('version') @property def inkscape_version(self): return self.__svg_node.get('{http://www.inkscape.org/namespaces/inkscape}version') @property def docname(self): return self.__svg_node.get('{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname') @docname.setter def docname(self, value): return self.__svg_node.set('{http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}docname', value) @staticmethod def load(filepath, encoding="utf-8", remove_blank_text=True, **kwargs): warnings.warn("load() is deprecated and will be removed in near future, use Canvas constructor instead.", DeprecationWarning, stacklevel=2) return Canvas(filepath=filepath, encoding=encoding, remove_blank_text=remove_blank_text, **kwargs) def to_xml_string(self, encoding="utf-8", pretty_print=True, **kwargs): if _LXML_AVAILABLE: return etree.tostring(self.__root, encoding=encoding, pretty_print=pretty_print, **kwargs).decode('utf-8') else: return etree.tostring(self.__root, encoding=encoding, **kwargs).decode('utf-8') def __str__(self): return self.to_xml_string() def _xpath_query(self, query_string, namespaces=None): if _LXML_AVAILABLE: return self.__root.xpath(query_string, namespaces=namespaces) else: return self.__tree.findall(query_string, namespaces=namespaces) def groups(self, layer_only=False): if layer_only: groups = self._xpath_query(".//ns:g[@inkscape:groupmode='layer']", namespaces=SVG_NAMESPACES) else: groups = self._xpath_query(".//ns:g", namespaces=SVG_NAMESPACES) return [self.__build_group(g) for g in groups] def group(self, name, layer_only=False): if layer_only: if _LXML_AVAILABLE: groups = self._xpath_query(f".//ns:g[@inkscape:groupmode='layer' and @inkscape:label='{name}']", namespaces=SVG_NAMESPACES) else: groups = self._xpath_query(f".//ns:g[@inkscape:groupmode='layer'][@inkscape:label='{name}']", namespaces=SVG_NAMESPACES) else: groups = self._xpath_query(f".//ns:g[@inkscape:label='{name}']", namespaces=SVG_NAMESPACES) if groups: return self.__build_group(groups[0]) else: # some groups in Inkscape have empty name and use ID as name instead _try_group = self.group_by_id(name, layer_only=layer_only) if _try_group and not _try_group.label: return _try_group else: return None def group_by_id(self, id, layer_only=False): if layer_only: if _LXML_AVAILABLE: groups = self._xpath_query(f".//ns:g[@inkscape:groupmode='layer' and @id='{id}']", namespaces=SVG_NAMESPACES) else: groups = self._xpath_query(f".//ns:g[@inkscape:groupmode='layer'][@id='{id}']", namespaces=SVG_NAMESPACES) else: groups = self._xpath_query(f".//ns:g[@id='{id}']", namespaces=SVG_NAMESPACES) return self.__build_group(groups[0]) if groups else None
[docs] def layers(self): ''' Get all available layers in this canvas ''' return self.groups(layer_only=True)
[docs] def layer(self, name: str) -> Group: ''' Find the first layer with a name Layer names are not unique. If there are multiple layers with the same name, only the first one will be returned :param name: Name of the layer to search (Note: Layer names a not unique) :returns: A `Group` object if found, or None :rtype: pyinkscape.inkscape.Group ''' return self.group(name, layer_only=True)
[docs] def layer_by_id(self, id): ''' Find the first layer with an ID Layer IDs are unique :param id: ID of the layer to search :returns: A `Group` object if found, or None :rtype: pyinkscape.inkscape.Group ''' return self.group_by_id(id=id, layer_only=True)
def render(self, outpath, overwrite=False, encoding="utf-8"): if not overwrite and os.path.isfile(outpath): getLogger().warning(f"File {outpath} exists. SKIPPED") else: output = str(self) with open(outpath, mode='w', encoding=encoding) as outfile: outfile.write(output) getLogger().info("Written output to {}".format(outfile.name)) def getText(self, id): elems = self._xpath_query("/ns:svg/ns:g/ns:flowRoot[@id='{id}']/ns:flowPara".format(id=id), namespaces=SVG_NAMESPACES) if elems: return elems else: # try get <text> element instead of flowRoot ... elems = self._xpath_query("/ns:svg/ns:g/ns:text[@id='{id}']/ns:tspan".format(id=id), namespaces=SVG_NAMESPACES) getLogger().debug(f"Found: {elems}") return elems