#!/usr/bin/env python # -*- coding: utf-8 -*- """\ Convert SVG to TikZ/PGF commands for use with (La)TeX This script is an Inkscape extension for exporting from SVG to (La)TeX. The extension recreates the SVG drawing using TikZ/PGF commands, a high quality TeX macro package for creating graphics programmatically. The script is tailored to Inkscape SVG, but can also be used to convert arbitrary SVG files from the command line. Author: Kjell Magne Fauske, Devillez Louis """ import platform __version__ = "3.2.1" __author__ = "Devillez Louis, Kjell Magne Fauske" __maintainer__ = "Deville Louis" __email__ = "louis.devillez@gmail.com" import sys from textwrap import wrap import codecs import io import os from subprocess import Popen, PIPE from math import sin, cos, atan2, radians, degrees from math import pi as mpi import logging import ctypes import inkex from inkex.transforms import Vector2d from lxml import etree try: SYS_OUTPUT_BUFFER = sys.stdout.buffer except AttributeError: # pragma: no cover logging.warning("Sys has no output buffer, redirecting to None") SYS_OUTPUT_BUFFER = None #### Utility functions and classes TIKZ_BASE_COLOR = [ "black", "red", "green", "blue", "cyan", "yellow", "magenta", "white", "gray", ] LIST_OF_SHAPES = [ "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", ] SPECIAL_TEX_CHARS = ["$", "\\", "%", "_", "#", "{", r"}", "^", "&"] SPECIAL_TEX_CHARS_REPLACE = [ r"\$", r"$\backslash$", r"\%", r"\_", r"\#", r"\{", r"\}", r"\^{}", r"\&", ] _tex_charmap = dict(list(zip(SPECIAL_TEX_CHARS, SPECIAL_TEX_CHARS_REPLACE))) def escape_texchars(input_string): r"""Escape the special LaTeX-chars %{}_^ Examples: >>> escape_texchars('10%') '10\\%' >>> escape_texchars('%{}_^\\$') '\\%\\{\\}\\_\\^{}$\\backslash$\\$' """ return "".join([_tex_charmap.get(c, c) for c in input_string]) def copy_to_clipboard(text): # pragma: no cover """Copy text to the clipboard Returns True if successful. False otherwise. """ text_type = str def _do_windows_clipboard(text): # from http://pylabeditor.svn.sourceforge.net/viewvc/pylabeditor/trunk/src/shells.py?revision=82&view=markup cf_unicode_text = 13 ghnd = 66 ctypes.windll.kernel32.GlobalAlloc.restype = ctypes.c_void_p ctypes.windll.kernel32.GlobalLock.restype = ctypes.c_void_p text = text_type(text, "utf8") buffer_size = (len(text) + 1) * 2 h_global_mem = ctypes.windll.kernel32.GlobalAlloc( ctypes.c_uint(ghnd), ctypes.c_size_t(buffer_size) ) lp_global_mem = ctypes.windll.kernel32.GlobalLock(ctypes.c_void_p(h_global_mem)) ctypes.cdll.msvcrt.memcpy( ctypes.c_void_p(lp_global_mem), ctypes.c_wchar_p(text), ctypes.c_int(buffer_size), ) ctypes.windll.kernel32.GlobalUnlock(ctypes.c_void_p(h_global_mem)) if ctypes.windll.user32.OpenClipboard(0): ctypes.windll.user32.EmptyClipboard() ctypes.windll.user32.SetClipboardData( ctypes.c_int(cf_unicode_text), ctypes.c_void_p(h_global_mem) ) ctypes.windll.user32.CloseClipboard() return True return False def _call_command(command, text): # see https://bugs.launchpad.net/ubuntu/+source/inkscape/+bug/781397/comments/2 try: devnull = os.open(os.devnull, os.O_RDWR) with Popen(command, stdin=PIPE, stdout=devnull, stderr=devnull) as proc: proc.communicate(text) if not proc.returncode: return True except OSError: pass return False def _do_linux_clipboard(text): # try xclip first, then xsel xclip_cmd = ["xclip", "-selection", "clipboard"] success = _call_command(xclip_cmd, text) if success: return True xsel_cmd = ["xsel"] success = _call_command(xsel_cmd, text) return success def _do_osx_clipboard(text): pbcopy_cmd = ["pbcopy"] return _call_command(pbcopy_cmd, text) # try os /linux if os.name == "nt" or platform.system() == "Windows": return _do_windows_clipboard(text) if os.name == "mac" or platform.system() == "Darwin": return _do_osx_clipboard(text) return _do_linux_clipboard(text) def filter_tag(node): """ A function to see if a node should be draw or not """ # pylint: disable=comparison-with-callable # As it is done in lxml if node.tag == etree.Comment: return False if node.TAG in [ "desc", "namedview", "defs", "svg", "symbol", "title", "style", "metadata", ]: return False return True #### Output configuration section TEXT_INDENT = " " CROP_TEMPLATE = r""" \usepackage[active,tightpage]{preview} \PreviewEnvironment{tikzpicture} """ # Templates STANDALONE_TEMPLATE = ( r""" \documentclass{article} \usepackage[utf8]{inputenc} \usepackage{tikz} %(cropcode)s \begin{document} %(colorcode)s %(gradientcode)s \def \globalscale {%(scale)f} \begin{tikzpicture}[y=1%(unit)s, x=1%(unit)s, yscale=%(ysign)s\globalscale,""" r"""xscale=\globalscale, every node/.append style={scale=\globalscale}, inner sep=0pt, outer sep=0pt] %(pathcode)s \end{tikzpicture} \end{document} """ ) FIG_TEMPLATE = ( r""" %(colorcode)s %(gradientcode)s \def \globalscale {%(scale)f} \begin{tikzpicture}[y=1%(unit)s, x=1%(unit)s, yscale=%(ysign)s\globalscale,""" r"""xscale=\globalscale, every node/.append style={scale=\globalscale}, inner sep=0pt, outer sep=0pt] %(pathcode)s \end{tikzpicture} """ ) SCALE = "scale" DICT = "dict" DIMENSION = "dimension" FACTOR = "factor" # >= 1 # Map Inkscape/SVG stroke and fill properties to corresponding TikZ options. # Format: # 'svg_name' : ('tikz_name', value_type, data) PROPERTIES_MAP = { "text-anchor": ( "anchor", DICT, {"start": "south west", "middle": "south", "end": "south east"}, ), "opacity": ("opacity", SCALE, ""), # filling "fill-opacity": ("fill opacity", SCALE, ""), "fill-rule": ("", DICT, {"nonzero": "nonzero rule", "evenodd": "even odd rule"}), # stroke "stroke-opacity": ("draw opacity", SCALE, ""), "stroke-linecap": ( "line cap", DICT, {"butt": "butt", "round": "round", "rect": "rect"}, ), "stroke-linejoin": ( "line join", DICT, { "miter": "miter", "round": "round", "bevel": "bevel", }, ), "stroke-width": ("line width", DIMENSION, ""), "stroke-miterlimit": ("miter limit", FACTOR, ""), "stroke-dashoffset": ("dash phase", DIMENSION, "0"), } def calc_arc(cp: Vector2d, r_i: Vector2d, ang, fa, fs, pos: Vector2d): """ Calc arc paths It computes the start and end angle for a non rotated ellipse cp: initial control point r_i: x and y radius ang: x-axis-rotation fa: sweep flag fs: large sweep flag pos: final control point The calc_arc function is based on the calc_arc function in the paths_svg2obj.py script bundled with Blender 3D Copyright (c) jm soler juillet/novembre 2004-april 2007, Resource: https://developer.mozilla.org/fr/docs/Web/SVG/Tutorial/Paths#elliptical_arc (in french) """ # print(ang) ang = radians(ang) r = Vector2d(abs(r_i.x), abs(r_i.y)) p_pos = Vector2d( abs((cos(ang) * (cp.x - pos.x) + sin(ang) * (cp.y - pos.y)) * 0.5) ** 2.0, abs((cos(ang) * (cp.y - pos.y) - sin(ang) * (cp.x - pos.x)) * 0.5) ** 2.0, ) rp = Vector2d( p_pos.x / (r.x**2.0) if abs(r.x) > 0.0 else 0.0, p_pos.y / (r.y**2.0) if abs(r.y) > 0.0 else 0.0, ) p_l = rp.x + rp.y if p_l > 1.0: p_l = p_l**0.5 r.x *= p_l r.y *= p_l car = Vector2d( cos(ang) / r.x if abs(r.x) > 0.0 else 0.0, cos(ang) / r.y if abs(r.y) > 0.0 else 0.0, ) sar = Vector2d( sin(ang) / r.x if abs(r.x) > 0.0 else 0.0, sin(ang) / r.y if abs(r.y) > 0.0 else 0.0, ) p0 = Vector2d(car.x * cp.x + sar.x * cp.y, (-sar.y) * cp.x + car.y * cp.y) p1 = Vector2d(car.x * pos.x + sar.x * pos.y, (-sar.y) * pos.x + car.y * pos.y) hyp = (p1.x - p0.x) ** 2 + (p1.y - p0.y) ** 2 if abs(hyp) > 0.0: s_q = 1.0 / hyp - 0.25 else: s_q = -0.25 s_f = max(0.0, s_q) ** 0.5 if fs == fa: s_f *= -1 c = Vector2d( 0.5 * (p0.x + p1.x) - s_f * (p1.y - p0.y), 0.5 * (p0.y + p1.y) + s_f * (p1.x - p0.x), ) ang_0 = atan2(p0.y - c.y, p0.x - c.x) ang_1 = atan2(p1.y - c.y, p1.x - c.x) ang_arc = ang_1 - ang_0 if ang_arc < 0.0 and fs == 1: ang_arc += 2.0 * mpi elif ang_arc > 0.0 and fs == 0: ang_arc -= 2.0 * mpi ang0 = degrees(ang_0) ang1 = degrees(ang_1) if ang_arc > 0: if ang_0 < ang_1: pass else: ang0 -= 360 else: if ang_0 < ang_1: ang1 -= 360 return ang0, ang1, r def parse_arrow_style(arrow_name): """ Convert an svg arrow_name to tikz name of the arrow """ strip_name = arrow_name.split("url")[1][1:-1] if "Arrow1" in strip_name: return "latex" if "Arrow2" in strip_name: return "stealth" if "Stop" in strip_name: return "|" return "latex" def marking_interpret(marker): """ Interpret the arrow from its name and its direction and convert it to tikz code """ raw_marker = "" if marker: arrow_style = parse_arrow_style(marker) raw_marker = arrow_style[:] if "end" in marker: raw_marker += " reversed" return raw_marker def options_to_str(options: list) -> str: """ Convert a list of options to a str with comma separated value. If the list is empty, return an empty str """ return f"[{','.join(options)}]" if len(options) > 0 else "" def return_arg_parser_doc(): """ Methode to return the arg parser of TikzPathExporter to help generate the doc """ tzp = TikZPathExporter() return tzp.arg_parser # pylint: disable=too-many-ancestors class TikZPathExporter(inkex.Effect, inkex.EffectExtension): """Class to convert a svg to tikz code""" def __init__(self, inkscape_mode=True): self.inkscape_mode = inkscape_mode inkex.Effect.__init__(self) inkex.EffectExtension.__init__(self) self._set_up_options() self.text_indent = TEXT_INDENT self.colors = [] self.color_code = "" self.gradient_code = "" self.output_code = "" self.used_gradients = set() self.height = 0 self.args_parsed = False def _set_up_options(self): parser = self.arg_parser parser.set_defaults( codeoutput="standalone", crop=False, clipboard=False, wrap=False, indent=True, returnstring=False, scale=1, mode="effect", notext=False, verbose=False, texmode="escape", markings="ignore", ) parser.add_argument( "--codeoutput", dest="codeoutput", choices=("standalone", "codeonly", "figonly"), help="Amount of boilerplate code (standalone, figonly, codeonly).", ) parser.add_argument( "-t", "--texmode", dest="texmode", default="escape", choices=("math", "escape", "raw", "attribute"), help="Set text mode (escape, math, raw, attribute). Defaults to 'escape'", ) parser.add_argument( "--texmode-attribute", default=None, action="store", dest="texmode_attribute", help="The SVG attribute that specifies how to handle text", ) parser.add_argument( "--markings", dest="markings", default="arrows", choices=("ignore", "include", "interpret", "arrows"), help="Set markings mode. Defaults to 'ignore'", ) parser.add_argument( "--arrow", dest="arrow", default="latex", choices=("latex", "stealth", "to", ">"), help="Set arrow style for markings mode arrow. Defaults to 'latex'", ) parser.add_argument( "--output-unit", dest="output_unit", default="cm", choices=("mm", "cm", "m", "in", "pt", "px", "Q", "pc"), help="Set output units. Defaults to 'cm'", ) self._add_booloption( parser, "--crop", dest="crop", help="Use the preview package to crop the tikzpicture", ) self._add_booloption( parser, "--clipboard", dest="clipboard", help="Export to clipboard" ) self._add_booloption(parser, "--wrap", dest="wrap", help="Wrap long lines") self._add_booloption(parser, "--indent", default=True, help="Indent lines") parser.add_argument( "--round-number", dest="round_number", type=int, default=4, help="Number of significative number after the decimal", ) self._add_booloption( parser, "--latexpathtype", dest="latexpathtype", default=True, help="Allow path modification for image", ) self._add_booloption( parser, "--noreversey", dest="noreversey", help="Do not reverse the y axis (Inkscape axis)", default=False, ) parser.add_argument( "--removeabsolute", dest="removeabsolute", default="", help="Remove the value of removeabsolute from image path", ) if self.inkscape_mode: parser.add_argument( "--returnstring", action="store_true", dest="returnstring", help="Return as string", ) parser.add_argument( "--tab" ) # Dummy option. Needed because Inkscape passes the notebook # tab as an option. parser.add_argument( "-m", "--mode", dest="mode", choices=("output", "effect", "cli"), default="cli", help="Extension mode (effect default)", ) self._add_booloption( parser, "--notext", dest="ignore_text", default=False, help="Ignore all text", ) parser.add_argument( "--scale", dest="scale", type=float, default=1, help="Apply scale to resulting image, defaults to 1.0", ) if not self.inkscape_mode: parser.add_argument( "--standalone", dest="codeoutput", action="store_const", const="standalone", help="Generate a standalone document", ) parser.add_argument( "--figonly", dest="codeoutput", action="store_const", const="figonly", help="Generate figure only", ) parser.add_argument( "--codeonly", dest="codeoutput", action="store_const", const="codeonly", help="Generate drawing code only", ) parser.add_argument( "-V", "--version", dest="printversion", action="store_true", help="Print version information and exit", default=False, ) self._add_booloption( parser, "--verbose", dest="verbose", default=False, help="Verbose output (useful for debugging)", ) def _add_booloption(self, parser, *args, **kwargs): if self.inkscape_mode: kwargs["action"] = "store" kwargs["type"] = inkex.Boolean parser.add_argument(*args, **kwargs) else: kwargs["action"] = "store_true" parser.add_argument(*args, **kwargs) def sanitize_angles(self, start_raw: float, end_raw: float): """ Sanitizes angles from arc to put them in [-360, 360] range start_raw: start angle of the arc end_raw: end angle of the arc """ start_ang = self.round_value(start_raw % 360) end_ang = self.round_value(end_raw % 360) # # Does not to seem a problem anymore if start_raw < end_raw and not start_ang < end_ang: start_ang -= 360 elif start_raw > end_raw and not start_ang > end_ang: end_ang -= 360 return start_ang, end_ang def convert_unit(self, value: float) -> float: """Convert value from the user unit to the output unit which is an option""" ret = self.svg.unit_to_viewport(value, self.options.output_unit) return ret def convert_unit_coord(self, coord: Vector2d, update_height=True) -> Vector2d: """ Convert a coord (Vector2D)) from the user unit to the output unit """ y = self.convert_unit(coord[1]) return Vector2d( self.convert_unit(coord[0]), self.update_height(y) if update_height else y, ) def convert_unit_coords(self, coords, update_height=True): """ Convert a list of coords (Vector2D)) from the user unit to the output unit """ return [self.convert_unit_coord(coord, update_height) for coord in coords] def round_value(self, value): """Round a value with respect to the round number of the class""" return round(value, self.options.round_number) def round_coord(self, coord): """Round a coordinante(Vector2D) with respect to the round number of the class""" return Vector2d(self.round_value(coord[0]), self.round_value(coord[1])) def round_coords(self, coords): """Round a coordinante(Vector2D) with respect to the round number of the class""" return [self.round_coord(coord) for coord in coords] def rotate_coord(self, coord: Vector2d, angle: float) -> Vector2d: """ rotate a coordinate around (0,0) of angle radian """ return Vector2d( coord.x * cos(angle) - coord.y * sin(angle), coord.x * sin(angle) + coord.y * cos(angle), ) def coord_to_tz(self, coord: Vector2d) -> str: """ Convert a coord (Vector2d) which is round and converted to tikz code """ c = self.round_coord(coord) return f"({c.x}, {c.y})" def update_height(self, y_val): """Compute the distance between the point and the bottom of the document""" if not self.options.noreversey: return self.height - y_val return y_val def convert_color_to_tikz(self, color): """ Convert a svg color to tikzcode and add it to the list of known colors """ color = color.to_rgb() xcolorname = str(color.to_named()).replace("#", "c") if xcolorname in TIKZ_BASE_COLOR: return xcolorname if xcolorname not in self.colors: self.colors.append(xcolorname) self.color_code += "\\definecolor{" + f"{xcolorname}" + "}{RGB}{" self.color_code += f"{color.red},{color.green},{color.blue}" + "}\n" return xcolorname # def _convert_gradient(self, gradient_node, gradient_tikzname): # """Convert an SVG gradient to a PGF gradient""" # # http://www.w3.org/TR/SVG/pservers.html # def bpunit(offset): # bp_unit = "" # if offset.endswith("%"): # bp_unit = offset[0:-1] # else: # bp_unit = str(int(round((float(offset)) * 100))) # return bp_unit # if gradient_node.tag == _ns("linearGradient"): # c = "" # c += ( # r"\pgfdeclarehorizontalshading{" # + f"{gradient_tikzname}" # + "}{100bp}{\n" # ) # stops = [] # for n in gradient_node: # if n.tag == _ns("stop"): # stops.append( # f"color({bpunit(n.get('offset'))}pt)=" # f"({self.get_color(n.get('stop-color'))})" # ) # c += ";".join(stops) # c += "\n}\n" # return c # return "" # def _handle_gradient(self, gradient_ref): # grad_node = self.get_node_from_id(gradient_ref) # gradient_id = grad_node.get("id") # if grad_node is None: # return [] # gradient_tikzname = gradient_id # if gradient_id not in self.used_gradients: # grad_code = self._convert_gradient(grad_node, gradient_tikzname) # if grad_code: # self.gradient_code += grad_code # self.used_gradients.add(gradient_id) # if gradient_id in self.used_gradients: # return ["shade", f"shading={gradient_tikzname}"] # return [] def _handle_markers(self, style): """ Convert marking style from svg to tikz code """ # http://www.w3.org/TR/SVG/painting.html#MarkerElement ms = style.get("marker-start") me = style.get("marker-end") # Avoid options "-" on empty path if ms is None and me is None: return [] if self.options.markings == "ignore": return [] if self.options.markings == "include": # TODO to implement: # Include arrow as path object # Define custom arrow and use them return [] if self.options.markings == "interpret": start_arrow = marking_interpret(ms) end_arrow = marking_interpret(me) return [start_arrow + "-" + end_arrow] if self.options.markings == "arrows": start_arrow = self.options.arrow[:] if ms is not None else "" if ms is not None and "end" in ms: start_arrow += " reversed" if start_arrow == self.options.arrow: start_arrow = "<" if me is not None and "end" in me: start_arrow = ">" end_arrow = self.options.arrow[:] if me is not None else "" if me and "start" in me: end_arrow += " reversed" if end_arrow == self.options.arrow: end_arrow = ">" if me is not None and "start" in me: end_arrow = "<" return [start_arrow + "-" + end_arrow] return [] def _handle_dasharray(self, style): """ Convert dasharry style from svg to tikz code """ dasharray = style.get("stroke-dasharray") if dasharray is None or dasharray == "none": return [] lengths = dasharray.replace(",", " ").replace(" ", " ").split(" ") dashes = [] for idx, length in enumerate(lengths): l = self.round_value(self.convert_unit(float(length))) lenstr = f"{l}{self.options.output_unit}" if idx % 2: dashes.append(f"off {lenstr}") else: dashes.append(f"on {lenstr}") return [f"dash pattern={' '.join(dashes)}"] def get_shape_inside(self, node=None): """ Get back the shape from the shape_inside style attribute """ style = node.specified_style() url = style.get("shape-inside") if url is None: return None shape = inkex.properties.match_url_and_return_element(url, self.svg) return shape def style_to_tz(self, node=None): # pylint: disable=too-many-branches """ Convert the style from the svg to the option to apply to tikz code """ style = node.specified_style() # No display of the node # Special handling of switch as they are meta elements if node.TAG == "switch": if style.get("display") == "none": return ["none"] elif style.get("display") == "none" or not node.is_visible: if node.TAG == "g": return ["none"] return [] options = [] # Stroke and fill for use_path in ( [("fill", "text")] if node.TAG == "text" else [("stroke", "draw"), ("fill", "fill")] ): value = style.get(use_path[0]) if value != "none" and value is not None: options.append( f"{use_path[1]}={self.convert_color_to_tikz(style.get_color(use_path[0]))}" ) if value is None and use_path[0] == "fill" and node.TAG in LIST_OF_SHAPES: # svg shapes with no fill option should fill by black # https://www.w3.org/TR/2011/REC-SVG11-20110816/painting.html#FillProperty options.append("fill") # Other props for svgname, tikzdata in PROPERTIES_MAP.items(): tikzname, valuetype, data = tikzdata value = style.get(svgname) if value is None or value == "none": continue if valuetype in [SCALE, FACTOR]: val = float(value) if val < 1 and valuetype == FACTOR: continue if val != 1: options.append(f"{tikzname}={self.round_value(float(value))}") elif valuetype == DICT: if tikzname: options.append(f"{tikzname}={data.get(value, '')}") else: options.append(data.get(value, "")) elif valuetype == DIMENSION and value and value != data: options.append( f"{tikzname}=" f"{self.round_value(self.convert_unit(value))}" f"{self.options.output_unit}" ) # Arrow marker handling options += self._handle_markers(style) # Dash-array options += self._handle_dasharray(style) return options def trans_to_tz(self, node=None, is_node=False): """ Convert inkex transform to tikz code """ transform = node.transform # TODO decompose matrix in list of transform options = [] for trans in [transform]: # Empty transform if str(trans) == "": continue # Translation if trans.is_translate(): tr = self.convert_unit_coord(Vector2d(trans.e, trans.f), False) # Global scale do not impact transform if not self.options.noreversey or is_node: tr.y *= -1 tr.x *= self.options.scale tr.y *= self.options.scale options.append("shift={" + self.coord_to_tz(tr) + "}") # Rotation elif trans.is_rotate(): # get angle ang = -self.round_value(trans.rotation_degrees()) # If reverse coord, we rotate around old origin if not self.options.noreversey: options.append( "rotate around={" + f"{ang}:{self.coord_to_tz(Vector2d(0.0, self.update_height(0)))}" + "}" ) else: options.append(f"rotate={ang}") elif trans.is_scale(): x = self.round_value(trans.a) y = self.round_value(trans.d) if not self.options.noreversey and not is_node: options.append("shift={(0," + f"{y * self.update_height(0)}" + ")}") if x == y: options.append(f"scale={x}") else: options.append(f"xscale={x},yscale={y}") elif "matrix" in str(trans): tr = self.convert_unit_coord(Vector2d(trans.e, trans.f), False) a = self.round_value(trans.a) b = self.round_value(trans.b) c = self.round_value(trans.c) d = self.round_value(trans.d) # globalscale do not impact transform if not self.options.noreversey or is_node: tr.y *= -1 b *= -1 c *= -1 if not self.options.noreversey and not is_node: tr.x += -c * self.update_height(0) tr.y += (1 - d) * self.update_height(0) tr.x *= self.options.scale tr.y *= self.options.scale options.append(f"cm={{ {a},{b},{c}" f",{d},{self.coord_to_tz(tr)}}}") # Not possible to get them directly # elif "skewX" in str(trans): # options.append(f"xslant={math.tan(trans.c * math.pi / 180)}") # elif "skewY" in str(trans): # options.append(f"yslant={math.tan(trans.b * math.pi / 180)}") # elif "scale" in str(trans): # if trans.a == trans.d: # options.append(f"scale={trans.a}") # else: # options.append(f"xscale={trans.a},yscale={trans.d}") return options def _handle_group(self, groupnode): """ Convert a svg group to tikzcode """ options = self.style_to_tz(groupnode) + self.trans_to_tz(groupnode) old_indent = self.text_indent if len(options) > 0: self.text_indent += TEXT_INDENT group_id = groupnode.get_id() code = self._output_group(groupnode) self.text_indent = old_indent if code == "": return "" extra = "" if self.options.verbose and group_id: extra = f"%% {group_id}" hide = "none" in options s = "" if len(options) > 0 or self.options.verbose: # Remove it from the list if hide or self.options.verbose: if "none" in options: options.remove("none") pstyles = [",".join(options)] if "opacity" in pstyles[0]: pstyles.append("transparency group") s += self.text_indent + "\\begin{scope}" s += f"[{','.join(pstyles)}]{extra}\n{code}" s += self.text_indent + "\\end{scope}\n" if hide: s = "%" + s.replace("\n", "\n%")[:-1] else: s = code return s def _handle_switch(self, groupnode): """ Convert a svg switch to tikzcode All the elements are returned for now """ options = self.style_to_tz(groupnode) + self.trans_to_tz(groupnode) old_indent = self.text_indent if len(options) > 0: self.text_indent += TEXT_INDENT group_id = groupnode.get_id() code = self._output_group(groupnode) self.text_indent = old_indent if code == "": return "" extra = "" if self.options.verbose and group_id: extra = f"%% {group_id}" hide = "none" in options s = "" if len(options) > 0 or self.options.verbose: # Remove it from the list if hide: options.remove("none") # TODO ID of foreignObject are not consistent pstyles = [",".join(options)] if "opacity" in pstyles[0]: pstyles.append("transparency group") s += self.text_indent + "\\begin{scope}" s += f"[{','.join(pstyles)}]{extra}\n{code}" s += self.text_indent + "\\end{scope}\n" if hide: s = "%" + s.replace("\n", "\n%")[:-1] else: s = code return s def _handle_image(self, node): """Handles the image tag and returns tikz code""" p = self.convert_unit_coord(Vector2d(node.left, node.top)) width = self.round_value(self.convert_unit(node.width)) height = self.round_value(self.convert_unit(node.height)) href = node.get("xlink:href") isvalidhref = href is not None and "data:image/png;base64" not in href if not isvalidhref: href = "base64 still not supported" return f"% Image {node.get_id()} not included. Base64 still not supported" if self.options.latexpathtype: href = href.replace(self.options.removeabsolute, "") return ( r"\node[anchor=north west,inner sep=0, scale=\globalscale]" + f" ({node.get_id()}) at {self.coord_to_tz(p)} " + r"{\includegraphics[width=" + f"{width}{self.options.output_unit},height={height}{self.options.output_unit}]" + "{" + href + "}}" ) def convert_path_to_tikz(self, path): """ Convert a path from inkex to tikz code """ s = "" for command in path.proxy_iterator(): letter = command.letter.upper() # transform coords tparams = self.convert_unit_coords(command.control_points) # moveto if letter == "M": s += self.coord_to_tz(tparams[0]) # lineto elif letter in ["L", "H", "V"]: s += f" -- {self.coord_to_tz(tparams[0])}" # cubic bezier curve elif letter in ["C", "S"]: s += f".. controls {self.coord_to_tz(tparams[0])} and {self.coord_to_tz(tparams[1])} .. {self.coord_to_tz(tparams[2])}" # s_point = 2 * tparams[2] - tparams[1] # quadratic bezier curve elif letter == "Q": # http://fontforge.sourceforge.net/bezier.html # current_pos is qp0 qp1, qp2 = tparams cp1 = current_pos + (2.0 / 3.0) * (qp1 - current_pos) cp2 = cp1 + (qp2 - current_pos) / 3.0 s += f" .. controls {self.coord_to_tz(cp1)} and {self.coord_to_tz(cp2)} .. {self.coord_to_tz(qp2)}" # close path elif letter == "Z": s += " -- cycle" # arc elif letter == "A": # Do not shift other values command = command.to_absolute() r = Vector2d( self.convert_unit(command.rx), self.convert_unit(command.ry) ) # Get acces to this vect2D ? pos = Vector2d(command.x, command.y) pos = self.convert_unit_coord(pos) sweep = command.sweep if not self.options.noreversey: current_pos.y = self.update_height(current_pos.y) pos.y = self.update_height(pos.y) start_ang_o, end_ang_o, r = calc_arc( current_pos, r, command.x_axis_rotation, command.large_arc, sweep, pos, ) r = self.round_coord(r) if not self.options.noreversey: r.y *= -1 # For Pgf 2.0 start_ang, end_ang = self.sanitize_angles(start_ang_o, end_ang_o) if not self.options.noreversey: command.x_axis_rotation *= -1 ang = self.round_value(command.x_axis_rotation) if r.x == r.y: # Todo: Transform radi radi = f"{r.x}" else: radi = f"{r.x} and {r.y}" if ang != 0.0: s += ( "{" + f"[rotate={ang}] arc({start_ang}" f":{end_ang}:{radi})" + "}" ) else: s += f"arc({start_ang}:{end_ang}:{radi})" # Get the last position current_pos = tparams[-1] return s def _handle_shape(self, node): """Extract shape data from node""" options = [] if node.TAG == "rect": inset = node.rx or node.ry x = node.left y = node.top corner_a = self.convert_unit_coord(Vector2d(x, y)) width = node.width height = node.height # map from svg to tikz if width == 0.0 or height == 0.0: return "", [] corner_b = self.convert_unit_coord(Vector2d(x + width, y + height)) if inset and abs(inset) > 1e-5: unit_to_scale = self.round_value(self.convert_unit(inset)) options = [f"rounded corners={unit_to_scale}{self.options.output_unit}"] return ( f"{self.coord_to_tz(corner_a)} rectangle {self.coord_to_tz(corner_b)}", options, ) if node.TAG in ["polyline", "polygon"]: points = node.get_path().control_points points = self.round_coords(self.convert_unit_coords(points)) points = [f"({vec.x}, {vec.y})" for vec in points] path = " -- ".join(points) if node.TAG == "polygon": path += "-- cycle" return f"{path};", [] if node.TAG == "line": p_a = self.convert_unit_coord(Vector2d(node.x1, node.y1)) p_b = self.convert_unit_coord(Vector2d(node.x2, node.y2)) # check for zero lenght line if not ((p_a[0] == p_b[0]) and (p_a[1] == p_b[1])): return f"{self.coord_to_tz(p_a)} -- {self.coord_to_tz(p_b)}", [] if node.TAG == "circle": center = self.convert_unit_coord(Vector2d(node.center.x, node.center.y)) r = self.round_value(self.convert_unit(node.radius)) if r > 0.0: return ( f"{self.coord_to_tz(center)} circle ({r}{self.options.output_unit})", [], ) if node.TAG == "ellipse": center = Vector2d(node.center.x, node.center.y) center = self.round_coord(self.convert_unit_coord(center)) r = self.round_coord(self.convert_unit_coord(node.radius, False)) if r.x > 0.0 and r.y > 0.0: return ( f"{self.coord_to_tz(center)} ellipse ({r.x}{self.options.output_unit} and {r.y}{self.options.output_unit})", [], ) return "", [] def _find_attribute_in_hierarchy(self, current, attr): """Try to find the attribute with the given name in the current node or any of its parents. If the attribute is found, return its value, otherwise None.""" while current is not None: value = current.get(attr) if value: return value return self._find_attribute_in_hierarchy(current.getparent(), attr) return None def _handle_text(self, node): if self.options.ignore_text: return "" raw_textstr = node.get_text(" ").strip() mode = self.options.texmode if mode == "attribute": attribute = self._find_attribute_in_hierarchy( node, self.options.texmode_attribute ) if attribute: mode = attribute if mode == "raw": textstr = raw_textstr elif mode == "math": textstr = f"${raw_textstr}$" else: textstr = escape_texchars(raw_textstr) shape = self.get_shape_inside(node) if shape is None: p = Vector2d(node.x, node.y) else: # pragma: no cover # TODO Not working yet p = Vector2d(shape.left, shape.bottom) # We need to apply a rotation to coord # In tikz rotate only rotate the node, not its coordinate ang = 0.0 trans = node.transform if trans.is_rotate(): # get angle ang = atan2(trans.b, trans.a) p = self.convert_unit_coord(self.rotate_coord(p, ang)) # scale do not impact node if self.options.noreversey: p.y *= -1 return f"({node.get_id()}) at {self.coord_to_tz(p)}" + "{" + f"{textstr}" + "}" def get_text(self, node): """Return content of a text node as string""" return etree.tostring(node, method="text").decode("utf-8") # pylint: disable=too-many-branches def _output_group(self, group): """Process a group of SVG nodes and return corresponding TikZ code The group is processed recursively if it contains sub groups. """ string = "" for node in group: if not filter_tag(node): continue if node.TAG == "use": node = node.unlink() if node.TAG == "switch": string += self._handle_switch(node) continue if node.TAG == "g": string += self._handle_group(node) continue try: goptions = self.style_to_tz(node) + self.trans_to_tz( node, node.TAG in ["text", "flowRoot", "image"] ) except AttributeError as msg: attr = msg.args[0].split("attribute")[1].split(".")[0] logging.warning("%s attribute cannot be represented", attr) pathcode = "" if self.options.verbose: string += self.text_indent + f"%{node.get_id()}\n" if node.TAG == "path": optionscode = options_to_str(goptions) pathcode = f"\\path{optionscode} {self.convert_path_to_tikz(node.path)}" elif node.TAG in LIST_OF_SHAPES: # Add indent pathcode, options = self._handle_shape(node) optionscode = options_to_str(goptions + options) pathcode = f"\\path{optionscode} {pathcode}" elif node.TAG in ["text", "flowRoot"]: pathcode = self._handle_text(node) # Check if the anchor is set, otherwise default to south west contains_anchor = False for goption in goptions: if goption.startswith("anchor="): contains_anchor = True if not contains_anchor: goptions += ["anchor=south west"] optionscode = options_to_str(goptions) # Convert a rotate around to a rotate option if "rotate around={" in optionscode: splited_options = optionscode.split("rotate around={") ang = splited_options[1].split(":")[0] optionscode = ( splited_options[0] + f"rotate={ang}" + splited_options[1].split("}", 1)[1] ) pathcode = f"\\node{optionscode} {pathcode}" elif node.TAG == "image": pathcode = self._handle_image(node) # elif node.TAG == "symbol": # # to implement: handle symbol as reusable code # pass else: logging.debug("Unhandled element %s", node.tag) continue if self.options.wrap: string += "\n".join( wrap( self.text_indent + pathcode, 80, subsequent_indent=" ", break_long_words=False, drop_whitespace=False, replace_whitespace=False, ) ) else: string += self.text_indent + pathcode string += ";\n\n\n\n" return string def effect(self): """Apply the conversion on the svg and fill the template""" string = "" if not self.options.indent: self.text_indent = "" root = self.document.getroot() if "height" in root.attrib: self.height = self.convert_unit(self.svg.viewbox_height) nodes = self.svg.selected # If no nodes is selected convert whole document. if len(nodes) == 0: nodes = root # Recursively process list of nodes or root node string = self._output_group(nodes) # Add necessary boiling plate code to the generated TikZ code. codeoutput = self.options.codeoutput if not self.options.crop: cropcode = "" else: cropcode = CROP_TEMPLATE if codeoutput == "standalone": output = STANDALONE_TEMPLATE % { "pathcode": string, "colorcode": self.color_code, "unit": self.options.output_unit, "ysign": "-" if self.options.noreversey else "", "cropcode": cropcode, "gradientcode": self.gradient_code, "scale": self.options.scale, } elif codeoutput == "figonly": output = FIG_TEMPLATE % { "pathcode": string, "colorcode": self.color_code, "unit": self.options.output_unit, "ysign": "-" if self.options.noreversey else "", "gradientcode": self.gradient_code, "scale": self.options.scale, } else: output = string self.output_code = output if self.options.returnstring: return output return "" def save_raw(self, _): """Save the file from the save as menu from inkscape""" if self.options.clipboard: # pragma: no cover success = copy_to_clipboard(self.output_code.encode("utf8")) if not success: logging.error("Failed to put output on clipboard") elif self.options.output is not None: if isinstance(self.options.output, str): with codecs.open(self.options.output, "w", "utf8") as stream: stream.write(self.output_code) else: out = self.output_code if isinstance(self.options.output, (io.BufferedWriter, io.FileIO)): out = out.encode("utf8") self.options.output.write(out) def run(self, args=None, output=SYS_OUTPUT_BUFFER): """ Custom inkscape entry point to remove agr processing """ try: # We parse it ourself in command line but letting it with inkscape if not self.args_parsed: # pragma: no cover if args is None: args = sys.argv[1:] self.parse_arguments(args) if ( isinstance(self.options.input_file, str) and "DOCUMENT_PATH" not in os.environ ): os.environ["DOCUMENT_PATH"] = self.options.input_file if self.options.output is None: self.options.output = output self.load_raw() self.save_raw(self.effect()) except inkex.utils.AbortExtension as err: # pragma: no cover inkex.utils.errormsg(str(err)) sys.exit(inkex.utils.ABORT_STATUS) finally: self.clean_up() def convert(self, svg_file=None, no_output=False, **kwargs): """Convert SVG file to tikz path""" self.options = self.arg_parser.parse_args() self.args_parsed = True # Update args before everything else self.options.__dict__.update(kwargs) # Get version if self.options.printversion: print_version_info() return "" # if attribute mode, texmode_attribute should not be None if ( self.options.texmode == "attribute" and self.options.texmode_attribute is None ): print("Need to specify a texmode attribute with --texmode-attribute") return "" # Updating input source if svg_file is not None: self.options.input_file = svg_file # If there is no file, end the code if self.options.input_file is None: print("No input file -- aborting") return "" # Run the code if no_output: self.run(output=None) else: self.run() # Return the output if necessary if self.options.returnstring: return self.output_code return "" def convert_file(svg_file, no_output=True, returnstring=True, **kwargs): """ Convert SVG file to tikz code :param svg_file: input file representend by a path or a stream :type svg_file: str, stream object :param no_output: If the output is redirected to None (default: True) :type no_output: Bool :param returnstring: if the output code should be returned :type returnstring: Bool :param kwargs: See argparse output / svg2tikz -h / commandline documentation :return: tikz code or empty string :rtype: str """ kwargs["returnstring"] = returnstring effect = TikZPathExporter(inkscape_mode=False) return effect.convert(svg_file, no_output, **kwargs) def convert_svg(svg_source, no_output=True, returnstring=True, **kwargs): """ Convert SVG to tikz code :param svg_source: content of svg file :type svg_source: str :param no_output: If the output is redirected to None (default: True) :type no_output: Bool :param returnstring: if the output code should be returned :type returnstring: Bool :param kwargs: See argparse output / svg2tikz -h / commandline documentation :return: tikz code or empty string :rtype: str """ kwargs["returnstring"] = returnstring effect = TikZPathExporter(inkscape_mode=False) return effect.convert(io.StringIO(svg_source), no_output, **kwargs) def main_inkscape(): # pragma: no cover """Inkscape interface""" # Create effect instance and apply it. effect = TikZPathExporter(inkscape_mode=True) effect.run() def print_version_info(): """Print the version of svg2tikz""" print(f"svg2tikz version {__version__}") def main_cmdline(**kwargs): # pragma: no cover """Main command line interface""" effect = TikZPathExporter(inkscape_mode=False) effect.convert(**kwargs) if __name__ == "__main__": # pragma: no cover main_inkscape()