1622 lines
50 KiB
Python
1622 lines
50 KiB
Python
#!/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()
|