From 3b12847da86d55385ad7604dc81645e10c2adb20 Mon Sep 17 00:00:00 2001 From: tavo Date: Sun, 24 Nov 2024 02:51:04 -0600 Subject: [PATCH] tikz extension --- inkscape/extensions/tikz_export.py | 1622 ++++++++++++++++++++ inkscape/extensions/tikz_export_effect.inx | 90 ++ inkscape/extensions/tikz_export_output.inx | 87 ++ inkscape/extensions/update_svg2tikz.sh | 9 + 4 files changed, 1808 insertions(+) create mode 100644 inkscape/extensions/tikz_export.py create mode 100644 inkscape/extensions/tikz_export_effect.inx create mode 100644 inkscape/extensions/tikz_export_output.inx create mode 100644 inkscape/extensions/update_svg2tikz.sh diff --git a/inkscape/extensions/tikz_export.py b/inkscape/extensions/tikz_export.py new file mode 100644 index 0000000..af5d603 --- /dev/null +++ b/inkscape/extensions/tikz_export.py @@ -0,0 +1,1622 @@ +#!/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() diff --git a/inkscape/extensions/tikz_export_effect.inx b/inkscape/extensions/tikz_export_effect.inx new file mode 100644 index 0000000..08d9286 --- /dev/null +++ b/inkscape/extensions/tikz_export_effect.inx @@ -0,0 +1,90 @@ + + + Export to TikZ path v3.2.1 + net.texample.tools.svg.export_tikz.effect + tikz_export.py + + + + none + false + + + + + + + + false + true + true + 1 + + + + + + + + + + + + + false + 1 + + + + + + + + + + + false + + + + + + + + + + + + + + + + + false + + + + + + + + + + + all + + + + + + diff --git a/inkscape/extensions/tikz_export_output.inx b/inkscape/extensions/tikz_export_output.inx new file mode 100644 index 0000000..a0ef488 --- /dev/null +++ b/inkscape/extensions/tikz_export_output.inx @@ -0,0 +1,87 @@ + + + Export as TikZ code for use with LaTeX v3.2.1 + net.texample.tools.svg.export_tikz.output + tikz_export.py + + + + + + + + + false + true + true + 1 + + + + + + + + + + + + + false + 1 + + + + + + + + + + + false + + + + + + + + + + + + + + + + + false + + + + + + + + + + output + + .tex + text/plain + TikZ code (*.tex) + Exports drawing as TikZ code. + + + diff --git a/inkscape/extensions/update_svg2tikz.sh b/inkscape/extensions/update_svg2tikz.sh new file mode 100644 index 0000000..20e4664 --- /dev/null +++ b/inkscape/extensions/update_svg2tikz.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +RAW_GITHUB_URL="https://raw.githubusercontent.com/xyz2tex/svg2tikz/refs/heads/master/svg2tikz" +INKSCAPE_EXTENSIONS="$HOME/.config/inkscape/extensions" +FILES="tikz_export.py tikz_export_effect.inx tikz_export_output.inx" + +for file in $FILES; do + wget -qO "$INKSCAPE_EXTENSIONS/$file" "$RAW_GITHUB_URL/$file" +done