# ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### # <pep8 compliant> """Reading SVG file format. """ __author__ = "howard.trickey@gmail.com" import re import xml.dom.minidom from . import geom TOL = 1e-5 def ParseSVGFile(filename): """Parse an SVG file name and return an Art object for it. Args: filename: string - name of file to read and parse Returns: geom.Art """ dom = xml.dom.minidom.parse(filename) return _SVGDomToArt(dom) def ParseSVGString(s): """Parse an SVG string and return an Art object for it. Args: s: string - contains svg Returns: geom.Art """ dom = xml.dom.minidom.parseString(s) return _SVGDomToArg(dom) class _SState: """Holds state that affects the conversion. """ def __init__(self): self.ctm = geom.TransformMatrix() self.fill = "black" self.fillrule = "nonzero" self.stroke = "none" self.dpi = 90 # default Inkscape DPI def _SVGDomToArt(dom): """Convert an svg file in dom form into an Art object. Args: dom: xml.dom.minidom.Document Returns: geom.Art """ art = geom.Art() svgs = dom.getElementsByTagName('svg') if len(svgs) == 0: return art gs = _SState() gs.ctm.d = -1.0 _ProcessChildren(svgs[0], art, gs) return art def _ProcessChildren(nodes, art, gs): """Process a list of SVG nodes, updating art. Args: nodes: list of xml.dom.Node art: geom.Art gs: _SState Side effects: Maybe adds paths to art. """ for node in nodes.childNodes: _ProcessNode(node, art, gs) def _ProcessNode(node, art, gs): """Process an SVG node, updating art. Args: node: xml.dom.Node art: geom.Art gs: _SState Side effects: Maybe adds paths to art. """ if node.nodeType != node.ELEMENT_NODE: return tag = node.tagName if tag == 'g': _ProcessChildren(node, art, gs) elif tag == 'defs': pass # TODO elif tag == 'path': _ProcessPath(node, art, gs) elif tag == 'polygon': _ProcessPolygon(node, art, gs) elif tag == 'rect': _ProcessRect(node, art, gs) elif tag == 'ellipse': _ProcessEllipse(node, art, gs) elif tag == 'circle': _ProcessCircle(node, art, gs) def _ProcessPolygon(node, art, gs): """Process a 'polygon' SVG node, updating art. Args: node: xml.dom.Node - a 'polygon' node arg: geom.Art gs: _SState Side effects: Adds path for polygon to art """ if node.hasAttribute('points'): coords = _ParseCoordPairList(node.getAttribute('points')) n = len(coords) if coords: c = [gs.ctm.Apply(coord) for coord in coords] sp = geom.Subpath() sp.segments = [('L', c[i], c[i % n]) for i in range(n)] sp.closed = True path = geom.Path() _SetPathAttributes(path, node, gs) path.subpaths = [sp] art.paths.append(path) def _ProcessPath(node, art, gs): """Process a 'polygon' SVG node, updating art. Args: node: xml.dom.Node - a 'polygon' node arg: geom.Art gs: _SState Side effects: Adds path for polygon to art """ if not node.hasAttribute('d'): return s = node.getAttribute('d') i = 0 path = geom.Path() _SetPathAttributes(path, node, gs) initpt = (0.0, 0.0) subpath = None while i < len(s): (i, subpath, initpt) = _ParseSubpath(s, i, initpt, gs) if subpath: if not subpath.Empty(): path.AddSubpath(subpath) else: break if path.subpaths: art.paths.append(path) def _ParseSubpath(s, i, initpt, gs): """Parse a moveto-drawto-command-group starting at s[i] and return Subpath. Args: s: string - should be the 'd' attribute of a 'path' element i: int - index in s to start parsing initpt: (float, float) - coordinates of initial point gs: _SState - used to transform coordinates Returns: (int, geom.Subpath, (float, float)) - (index after subpath and subsequent whitespace, the Subpath itself or Non if there was an error, final point) """ subpath = geom.Subpath() i = _SkipWS(s, i) n = len(s) if i >= n: return (i, None, initpt) if s[i] == 'M': move_cmd = 'M' elif s[i] == 'm': move_cmd = 'm' else: return (i, None, initpt) (i, cur) = _ParseCoordPair(s, _SkipWS(s, i + 1)) if not cur: return (i, None, initpt) prev_cmd = 'L' # implicit cmd if coords follow directly if move_cmd == 'm': cur = geom.VecAdd(initpt, cur) prev_cmd = 'l' while True: implicit_cmd = False if i < n: cmd = s[i] if _PeekCoord(s, i): cmd = prev_cmd implicit_cmd = True else: cmd = None if cmd == 'z' or cmd == 'Z' or cmd == None: if cmd: i = _SkipWS(s, i + 1) subpath.closed = True return (i, subpath, cur) if not implicit_cmd: i = _SkipWS(s, i + 1) if cmd == 'l' or cmd == 'L': (i, p1) = _ParseCoordPair(s, i) if not p1: break if cmd == 'l': p1 = geom.VecAdd(cur, p1) subpath.AddSegment(_LineSeg(cur, p1, gs)) cur = p1 elif cmd == 'c' or cmd == 'C': (i, p1, p2, p3) = _ParseThreeCoordPairs(s, i) if not p1: break if cmd == 'c': p1 = geom.VecAdd(cur, p1) p2 = geom.VecAdd(cur, p2) p3 = geom.VecAdd(cur, p3) subpath.AddSegment(_Bezier3Seg(cur, p3, p1, p2, gs)) cur = p3 elif cmd == 'a' or cmd == 'A': (i, p1, rad, rot, la, ccw) = _ParseArc(s, i) if not p1: break if cmd == 'a': p1 = geom.VecAdd(cur, p1) subpath.AddSegment(_ArcSeg(cur, p1, rad, rot, la, ccw, gs)) cur = p1 elif cmd == 'h' or cmd == 'H': (i, x) = _ParseCoord(s, i) if x is None: break if cmd == 'h': x += cur[0] subpath.AddSegment(_LineSeg(cur, (x, cur[1]), gs)) cur = (x, cur[1]) elif cmd == 'v' or cmd == 'V': (i, y) = _ParseCoord(s, i) if y is None: break if cmd == 'v': y += cur[1] subpath.AddSegment(_LineSeg(cur, (cur[0], y), gs)) cur = (cur[0], y) elif cmd == 's' or cmd == 'S': (i, p2, p3) = _ParseTwoCoordPairs(s, i) if not p2: break if cmd == 's': p2 = geom.VecAdd(cur, p2) p3 = geom.VecAdd(cur, p3) # p1 is reflection of cp2 of previous command # through current point (but p1 is cur if no previous) if len(subpath.segments) > 0 and subpath.segments[-1][0] == 'B': p4 = subpath.segments[-1][4] else: p4 = cur p1 = geom.VecAdd(cur, geom.VecSub(cur, p4)) subpath.AddSegment(_Bezier3Seg(cur, p3, p1, p2, gs)) cur = p3 else: # TODO: quadratic beziers, 'q', and 't' break i = _SkipCommaSpace(s, i) prev_cmd = cmd return (i, None, cur) def _ProcessRect(node, art, gs): """Process a 'rect' SVG node, updating art. Args: node: xml.dom.Node - a 'polygon' node arg: geom.Art gs: _SState Side effects: Adds path for rectangle to art """ if not (node.hasAttribute('width') and node.hasAttribute('height')): return w = _ParseLengthAttrOrDefault(node, 'width', gs, 0.0) h = _ParseLengthAttrOrDefault(node, 'height', gs, 0.0) if w <= 0.0 or h <= 0.0: return x = _ParseCoordAttrOrDefault(node, 'x', 0.0) y = _ParseCoordAttrOrDefault(node, 'y', 0.0) rx = _ParseLengthAttrOrDefault(node, 'rx', gs, 0.0) ry = _ParseLengthAttrOrDefault(node, 'ry', gs, 0.0) if rx == 0.0 and ry > 0.0: rx = ry elif rx > 0.0 and ry == 0.0: ry = rx if rx > w / 2.0: rx = w / 2.0 if ry > h / 2.0: ry = h / 2.0 subpath = geom.Subpath() subpath.closed = True if rx == 0.0 and ry == 0.0: subpath.AddSegment(_LineSeg((x, y), (x + w, y), gs)) subpath.AddSegment(_LineSeg((x + w, y), (x + w, y + h), gs)) subpath.AddSegment(_LineSeg((x + w, y + h), (x, y + h), gs)) subpath.AddSegment(_LineSeg((x, y + h), (x, y), gs)) else: wmid = w - 2 * rx hmid = h - 2 * ry # top line if wmid > TOL: subpath.AddSegment(_LineSeg((x + rx, y), (x + rx + wmid, y), gs)) # top right corner: remember, y positive downward, so this clockwise subpath.AddSegment(_ArcSeg((x + rx + wmid, y), (x + w, y + ry), (rx, ry), 0.0, False, False, gs)) # right line if hmid > TOL: subpath.AddSegment(_LineSeg((x + w, y + ry), (x + w, y + ry + hmid), gs)) # bottom right corner subpath.AddSegment(_ArcSeg((x + w, y + ry + hmid), (x + rx + wmid, y + h), (rx, ry), 0.0, False, False, gs)) # bottom line if wmid > TOL: subpath.AddSegment(_LineSeg((x + rx + wmid, y + h), (x + rx, y + h), gs)) # bottom left corner subpath.AddSegment(_ArcSeg((x + rx, y + h), (x, y + ry + hmid), (rx, ry), 0.0, False, False, gs)) # left line if hmid > TOL: subpath.AddSegment(_LineSeg((x, y + ry + hmid), (x, y + ry), gs)) # top left corner subpath.AddSegment(_ArcSeg((x, y + ry), (x + rx, y), (rx, ry), 0.0, False, False, gs)) path = geom.Path() _SetPathAttributes(path, node, gs) path.subpaths = [subpath] art.paths.append(path) def _ProcessEllipse(node, art, gs): """Process an 'ellipse' SVG node, updating art. Args: node: xml.dom.Node - a 'polygon' node arg: geom.Art gs: _SState Side effects: Adds path for ellipse to art """ if not (node.hasAttribute('rx') and node.hasAttribute('ry')): return rx = _ParseLengthAttrOrDefault(node, 'rx', gs, 0.0) ry = _ParseLengthAttrOrDefault(node, 'ry', gs, 0.0) if rx < TOL or ry < TOL: return cx = _ParseCoordAttrOrDefault(node, 'cx', 0.0) cy = _ParseCoordAttrOrDefault(node, 'cy', 0.0) subpath = _FullEllipseSubpath(cx, cy, rx, ry, gs) path = geom.Path() path.subpaths = [subpath] _SetPathAttributes(path, node, gs) art.paths.append(path) def _ProcessCircle(node, art, gs): """Process a 'circle' SVG node, updating art. Args: node: xml.dom.Node - a 'polygon' node arg: geom.Art gs: _SState Side effects: Adds path for circle to art """ if not node.hasAttribute('r'): return r = _ParseLengthAttrOrDefault(node, 'r', gs, 0.0) if r < TOL: return cx = _ParseCoordAttrOrDefault(node, 'cx', 0.0) cy = _ParseCoordAttrOrDefault(node, 'cy', 0.0) subpath = _FullEllipseSubpath(cx, cy, r, r, gs) path = geom.Path() path.subpaths = [subpath] _SetPathAttributes(path, node, gs) art.paths.append(path) def _FullEllipseSubpath(cx, cy, rx, ry, gs): """Return a Subpath for a full ellipse. Args: cx: float - center x cy: float - center y rx: float - x radius ry: float - y radius gs: _SState - for transform Returns: geom.Subpath """ # arc starts at 3 o'clock # TODO: if gs has rotate transform, figure that out # and use that as angle for arc x-rotation subpath = geom.Subpath() subpath.closed = True subpath.AddSegment(_ArcSeg((cx + rx, cy), (cx, cy + ry), (rx, ry), 0.0, False, False, gs)) subpath.AddSegment(_ArcSeg((cx, cy + ry), (cx - rx, cy), (rx, ry), 0.0, False, False, gs)) subpath.AddSegment(_ArcSeg((cx - rx, cy), (cx, cy - ry), (rx, ry), 0.0, False, False, gs)) subpath.AddSegment(_ArcSeg((cx, cy - ry), (cx + rx, cy), (rx, ry), 0.0, False, False, gs)) return subpath def _LineSeg(p1, p2, gs): """Return an 'L' segment, transforming coordinates. Args: p1: (float, float) - start point p2: (float, float) - end point gs: _SState - used to transform coordinates Returns: tuple - an 'L' type geom.Subpath segment """ return ('L', gs.ctm.Apply(p1), gs.ctm.Apply(p2)) def _Bezier3Seg(p1, p2, c1, c2, gs): """Return a 'B' segment, transforming coordinates. Args: p1: (float, float) - start point p2: (float, float) - end point c1: (float, float) - first control point c2: (float, float) - second control point gs: _SState - used to transform coordinates Returns: tuple - an 'L' type geom.Subpath segment """ return ('B', gs.ctm.Apply(p1), gs.ctm.Apply(p2), gs.ctm.Apply(c1), gs.ctm.Apply(c2)) def _ArcSeg(p1, p2, rad, rot, la, ccw, gs): """Return an 'A' segment, with attempt to transform. Our A segments don't allow modeling the effect of arbitrary transforms, but we can handle translation and scaling. Args: p1: (float, float) - start point p2: (float, float) - end point rad: (float, float) - (x radius, y radius) rot: float - x axis rotation, in degrees la: bool - large arc if True ccw: bool - counter-clockwise if True gs: _SState - used to transform Returns: tuple - an 'A' type geom.Subpath segment """ tp1 = gs.ctm.Apply(p1) tp2 = gs.ctm.Apply(p2) rx = rad[0] * gs.ctm.a ry = rad[1] * gs.ctm.d # if one of axes is mirrored, invert the ccw flag if rx * ry < 0.0: ccw = not ccw trad = (abs(rx), abs(ry)) # TODO: abs(gs.ctm.a) != abs(ts.ctm.d), adjust xrot return ('A', tp1, tp2, trad, rot, la, ccw) def _SetPathAttributes(path, node, gs): """Set the attributes related to filling/stroking in path. Use attribute settings in node, if there, else those in the current graphics state, gs. Arguments: path: geom.Path node: xml.dom.Node gs: _SState Side effects: May set filled, fillevenodd, stroked, fillpaint, strokepaint in path. """ fill = gs.fill stroke = gs.stroke fillrule = gs.fillrule if node.hasAttribute('style'): style = _CSSInlineDict(node.getAttribute('style')) if 'fill' in style: fill = style['fill'] if 'stroke' in style: stroke = style['stroke'] if 'fill-rule' in style: fillrule = style['fill-rule'] if node.hasAttribute('fill'): fill = node.getAttribute('fill') if fill != 'none': paint = _ParsePaint(fill) if paint is not None: path.fillpaint = paint path.filled = True if node.hasAttribute('stroke'): stroke = node.getAttribute('stroke') if stroke != 'none': paint = _ParsePaint(stroke) if stroke is not None: path.strokepaint = paint path.stroked = True if node.hasAttribute('fill-rule'): fillrule = node.getAttribute('fill-rule') path.fillevenodd = (fillrule == 'evenodd') # Some useful regular expressions _re_float = re.compile(r"(\+|-)?(([0-9]+\.[0-9]*)|(\.[0-9]+)|([0-9]+))") _re_int = re.compile(r"(\+|-)?[0-9]+") _re_wsopt = re.compile(r"\s*") _re_wscommaopt = re.compile(r"(\s*,\s*)|(\s*)") _re_namevalue = re.compile(r"\s*(\S+)\s*:\s*(\S+)\s*(?:;|$)") def _CSSInlineDict(s): """Parse string s as CSS inline spec, and return a dictionary for it. An inline CSS spec is semi-colon separated list of prop : value pairs, such as: "fill:none;fill-rule : evenodd" Args: s: string - inline CSS spec Returns: dict : maps string (prop name) -> string (value) """ pairs = _re_namevalue.findall(s) return dict(pairs) def _ParsePaint(s): """Parse an SVG paint definition and return our version of Paint. If is 'none', return None. If fail to parse (e.g., a TODO syntax), return black_paint. Args: s: string - should contain an SVG paint spec Returns: geom.Paint or None """ if len(s) == 0 or s == 'none': return None if s[0] == '#': if len(s) == 7: # 6 hex digits return geom.Paint( \ int(s[1:3], 16) / 255.0, int(s[3:5], 16) / 255.0, int(s[5:7], 16) / 255.0) elif len(s) == 4: # 3 hex digits return geom.Paint( \ int(s[1], 16) * 17 / 255.0, int(s[2], 16) * 17 / 255.0, int(s[3], 16) * 17 / 255.0) else: if s in geom.ColorDict: return geom.ColorDict[s] return geom.black_paint def _ParseLengthAttrOrDefault(node, attr, gs, default): """Parse the given attribute as a length, else return default. Args: node: xml.dom.Node attr: string - the attribute name gs: _SState - for dots-per-inch, for units conversion default: float - to return if no attr or error parsing it Returns: float - the length """ if not node.hasAttribute(attr): return default (_, v) = _ParseLength(node.getAttribute(attr), gs, 0) if v is None: return default else: return v def _ParseCoordAttrOrDefault(node, attr, default): """Parse the given attribute as a coordinate, else return default. Args: node: xml.dom.Node attr: string - the attribute name default: float - to return if no attr or error parsing it Returns: float - the coordinate """ if not node.hasAttribute(attr): return default (_, v) = _ParseCoord(node.getAttribute(attr), 0) if v is None: return default else: return v def _ParseCoord(s, i): """Parse a coordinate (floating point number). Args: s: string i: int - where to start parsing Returns: (int, float or None) - int is index after the coordinate and subsequent white space """ m = _re_float.match(s, i) if m: return (_SkipWS(s, m.end()), float(m.group())) else: return (i, None) def _PeekCoord(s, i): """Return True if s[i] starts a coordinate. Args: s: string i: int - place in s to start looking Returns: bool - True if s[i] starts a coordinate, perhaps after comma / space """ i = _SkipCommaSpace(s, i) m = _re_float.match(s, i) return True if m else False def _ParseCoordPair(s, i): """Parse pair of coordinates, with optional comma between. Args: s: string i: int - where to start parsing Returns: (int, (float, float) or None) - int is index after the coordinate and subsequent white space """ (j, x) = _ParseCoord(s, i) if x is not None: j = _SkipCommaSpace(s, j) (j, y) = _ParseCoord(s, j) if y is not None: return (_SkipWS(s, j), (x, y)) return (i, None) def _ParseTwoCoordPairs(s, i): """Parse two coordinate pairs, optionally separated by commas. Args: s: string i: int - where to start parsing Returns: (int, (float, float) or None, (float, float) or None) - int is index after the coordinate and subsequent white space """ (j, pair1) = _ParseCoordPair(s, i) if pair1: j = _SkipCommaSpace(s, j) (j, pair2) = _ParseCoordPair(s, j) if pair2: return (j, pair1, pair2) return (i, None, None) def _ParseThreeCoordPairs(s, i): """Parse three coordinate pairs, optionally separated by commas. Args: s: string i: int - where to start parsing Returns: (int, (float, float) or None, (float, float) or None, (float, float) or None) - int is index after the coordinateand subsequent white space """ (j, pair1) = _ParseCoordPair(s, i) if pair1: j = _SkipCommaSpace(s, j) (j, pair2) = _ParseCoordPair(s, j) if pair2: j = _SkipCommaSpace(s, j) (j, pair3) = _ParseCoordPair(s, j) if pair3: return (j, pair1, pair2, pair3) return (i, None, None, None) def _ParseCoordPairList(s): """Parse a list of coordinate pairs. The numbers should be separated by whitespace or a comma with optional whitespace around it. Args: s: string - should contain coordinate pairs Returns: list of (float, float) """ ans = [] i = _SkipWS(s, 0) while i < len(s): (i, pair) = _ParseCoordPair(s, i) if not pair: break ans.append(pair) return ans # units to be scaled by 'dots-per-inch' with these factors _UnitDict = { 'in': 1.0, 'mm': 0.0393700787, 'cm': 0.393700787, 'pt': 0.0138888889, 'pc': 0.166666667, # assume 10pt font, 5pt font x-height 'em': 0.138888889, 'ex': 0.0138888889 * 5} def _ParseLength(s, gs, i): """Parse a length (floating point number, with possible units). Args: s: string gs: _SState, for dpi if needed for units conversion i: int - where to start parsing Returns: (int, float or None) - int is index after the coordinate and subsequent white space; float is converted to user coords """ (i, v) = _ParseCoord(s, i) if v is None: return (i, None) upi = 1.0 if i < len(s): if s[i] == '%': # supposed to be percentage of nearest enclosing # viewport in appropriate direction. # for now, assume viewport is 10in in each dir upi = dpi * 10.0 / 100.0 elif i < len(s) - 1: cc = s[i:i + 2] if cc == 'px': upi = 1.0 i += 2 elif cc in _UnitDict: upi = gs.dpi * _UnitDict[cc] i += 2 return (i, v * upi) def _ParseArc(s, i): """Parse an elliptical arc specification. Args: s: string i: int - where to start parsing Returns: (int, (float, float) or None, (float, float), float, bool, bool) - int is index after spec and subsequent white space, first (float, float) is end point of arc second (float, float) is (x-radius, y-radius) float is x-axis rotation, in degrees first bool is True if larger arc is to be used second bool is True if arc follows ccw direction """ (j, rad) = _ParseCoordPair(s, i) if rad: j = _SkipCommaSpace(s, j) (j, rot) = _ParseCoord(s, j) if rot is not None: j = _SkipCommaSpace(s, j) (j, f) = _ParseCoord(s, j) # should really just look for 0 or 1 if f is not None: laf = (f != 0.0) j = _SkipCommaSpace(s, j) (j, f) = _ParseCoord(s, j) if f is not None: ccw = (f != 0.0) j = _SkipCommaSpace(s, j) (j, pt) = _ParseCoordPair(s, j) if pt: return (j, pt, rad, rot, laf, ccw) return (i, None, None, None, None, None) def _SkipWS(s, i): """Skip optional whitespace at s[i]... and return new i. Args: s: string i: int - index into s Returns: int - index of first none-whitespace character from s[i], or len(s) """ m = _re_wsopt.match(s, i) if m: return m.end() else: return i def _SkipCommaSpace(s, i): """Skip optional space with optional comma in it. Args: s: string i: int - index into s Returns: int - index after optional space with optional comma """ m = _re_wscommaopt.match(s, i) if m: return m.end() else: return i