dots/config/inkscape/extensions/org.inkscape.extension.30306/scientific_inkscape/dhelpers.py
2026-06-05 13:11:08 +02:00

1963 lines
64 KiB
Python

#!/usr/bin/env python
# coding=utf-8
#
# Copyright (c) 2023 David Burghoff <burghoff@utexas.edu>
#
# Functions modified from Inkex were made by
# Martin Owens <doctormo@gmail.com>
# Sergei Izmailov <sergei.a.izmailov@gmail.com>
# Thomas Holder <thomas.holder@schrodinger.com>
# Jonathan Neuhauser <jonathan.neuhauser@outlook.com>
# Functions modified from Deep_Ungroup made by Nikita Kitaev
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
# Locate the installed Inkex so we can assess the version. Do not import!
import pkgutil
installed_inkex = None
for finder in pkgutil.iter_importers():
if hasattr(finder, "find_spec"):
try:
spec = finder.find_spec("inkex")
if spec and spec.origin:
installed_inkex = spec.origin
except TypeError:
continue
# Import the packaged version of Inkex (currently v1.3.0)
import sys, os
inkex_to_use = "inkex1_3_0"
si_dir = os.path.dirname(os.path.realpath(__file__)) # my install location
sys.path.insert(0, os.path.join(si_dir, inkex_to_use))
sys.path.insert(1, os.path.join(si_dir, inkex_to_use, "site-packages"))
import inkex
# For SI we override Inkex's Style with a modified version, Style0
# To do this we import Style0, then replace inkex.Style with it
import styles0
inkex.Style = styles0.Style0
# Next we make sure we have the text submodule
sys.path.append(
os.path.join(si_dir, inkex_to_use, "site-packages", "python_fontconfig")
)
import inkex.text # noqa
import speedups # noqa
from inkex import Style
from inkex.text.cache import BaseElementCache
from inkex.text.parser import TextTree, TYP_TEXT
from inkex.text.utils import (
composed_width,
unique,
isrectangle,
subprocess_repeat,
tags,
bbox,
ipx,
)
from inkex import Tspan, Transform, Path, PathElement, BaseElement
from applytransform_mod import fuseTransform
import lxml, math, re, os, random, sys
from functools import lru_cache
# Parsed Inkex version, with extension back to v0.92.4
if not hasattr(inkex, "__version__"):
try:
tmp = BaseElement.unittouu # introduced in 1.1
inkex.__version__ = "1.1.0"
except:
try:
from inkex.paths import Path, CubicSuperPath # noqa
inkex.__version__ = "1.0.0"
except:
inkex.__version__ = "0.92.4"
inkex.vparse = lambda x: [int(v) for v in x.split(".")] # type: ignore
inkex.ivp = inkex.vparse(inkex.__version__) # type: ignore
# Returns non-comment children
def list2(el):
return [k for k in list(el) if not (k.tag == ctag)]
EBget = lxml.etree.ElementBase.get
EBset = lxml.etree.ElementBase.set
import inspect
def count_callers():
caller_frame = inspect.stack()[2]
filename = caller_frame.filename
line_number = caller_frame.lineno
lstr = f"{filename} at line {line_number}"
global callinfo
try:
callinfo
except:
callinfo = dict()
if lstr in callinfo:
callinfo[lstr] += 1
else:
callinfo[lstr] = 1
# Discover the version of Inkex installed, NOT the version packaged with SI
if installed_inkex is not None:
with open(installed_inkex, "r") as file:
content = file.read()
match = re.search(r'__version__\s*=\s*"(.*?)"', content)
vstr = match.group(1) if match else "1.0.0"
if vstr=='1.2.0': # includes 1.2.0, 1.2.1, 1.2.2
extpy = os.path.join(os.path.dirname(installed_inkex),'extensions.py')
with open(extpy, "r") as file:
content = file.read()
match = re.search(r'Pattern', content) # appeared in 1.2.2
if match:
vstr='1.2.2'
else:
# No installed Inkex, probably not called by Inkscape
vstr = inkex.__version__
inkex.installed_ivp = inkex.vparse(vstr) # type: ignore
inkex.installed_haspages = inkex.installed_ivp[0] >= 1 and inkex.installed_ivp[1] >= 2
# On v1.1-1.2.1 gi produces an error for some reason that is actually fine
import platform
if platform.system().lower() == "windows" and inkex.installed_ivp[0:2] == [1, 1] or (inkex.installed_ivp[0:2] == [1, 2] and inkex.installed_ivp[2]<2):
if inkex.text.font_properties.HASPANGOFT2:
from gi.repository import GLib
def custom_log_writer(log_domain, log_level, message, user_data):
return GLib.LogWriterOutput.UNHANDLED
GLib.log_set_writer_func(custom_log_writer, None)
# Replace an element with another one
# Puts it in the same location, update the cache dicts
def replace_element(el1, el2):
# replace el1 with el2
myp = el1.getparent()
myi = list(myp).index(el1)
myp.insert(myi + 1, el2)
newid = el1.get_id()
oldid = el2.get_id()
el1.delete()
el2.set_id(newid)
el2.croot.iddict.add(el2)
el2.croot.cssdict.dupe_entry(oldid, newid)
# For style components that are a list (stroke-dasharray), calculate
# the true size reported by Inkscape, inheriting any styles/transforms
def listsplit(x):
# split list on commas or spaces
return [ipx(v) for v in re.split("[ ,]", x) if v]
def composed_list(el, comp):
cs = el.cspecified_style
ct = el.ccomposed_transform
sc = cs.get(comp)
if sc == "none":
return "none", None
elif sc is not None:
sv = listsplit(sc)
sf = math.sqrt(abs(ct.a * ct.d - ct.b * ct.c))
sv = [x * sf for x in sv]
return sv, sf
else:
return None, None
# Unit parser and renderer
def uparse(str):
if str is not None:
uv = inkex.units.parse_unit(str, default_unit=None)
return uv[0], uv[1]
else:
return None, None
def urender(v, u):
if v is not None:
if u is not None:
return inkex.units.render_unit(v, u)
else:
return str(v)
else:
return None
# Get points of a path, element, or rectangle in the global coordinate system
def get_points(el, irange=None):
pth = el.cpath.to_absolute()
if irange is not None:
pnew = Path()
for ii in range(irange[0], irange[1]):
pnew.append(pth[ii])
pth = pnew
pts = list(pth.end_points)
ct = el.ccomposed_transform
xs = []
ys = []
for p in pts:
p = ct.apply_to_point(p)
xs.append(p.x)
ys.append(p.y)
return xs, ys
# Unlinks clones and composes transform/clips/etc, along with descendants
def unlink2(el):
if el.tag == usetag:
useel = el.get_link("xlink:href")
if useel is not None:
d = useel.duplicate()
# xy translation treated as a transform (applied first, then clip/mask, then full xform)
tx = EBget(el, "x")
ty = EBget(el, "y")
if tx is None:
tx = 0
if ty is None:
ty = 0
# order: x,y translation, then clip/mask, then transform
compose_all(
d,
None,
None,
Transform("translate(" + str(tx) + "," + str(ty) + ")"),
None,
)
compose_all(
d,
el.get_link("clip-path", llget=True),
el.get_link("mask", llget=True),
el.ctransform,
el.ccascaded_style,
)
replace_element(el, d)
d.set("unlinked_clone", True)
for k in d.descendants2()[1:]:
unlink2(k)
# To match Unlink Clone behavior, convert Symbol to Group
if isinstance(d, (inkex.Symbol)):
g = group(list(d))
ungroup(d)
d = g
return d
else:
return el
else:
return el
# unungroupable = (NamedView, Defs, Metadata, ForeignObject, lxml.etree._Comment)
unungroupable = tags((inkex.NamedView, inkex.Defs, inkex.Metadata, inkex.ForeignObject))
ctag = lxml.etree.Comment("").tag
unungroupable.add(ctag)
def ungroup(g, removetextclip=False):
# Ungroup a group, preserving style, clipping, and masking
# Remove any comments
if g.croot is not None:
gparent = g.getparent()
gindex = gparent.index(g) # group's location in parent
gtransform = g.ctransform
gclip = g.get_link("clip-path", llget=True)
gmask = g.get_link("mask", llget=True)
gstyle = g.ccascaded_style
for el in reversed(list(g)):
if el.tag == ctag: # remove comments
g.remove(el)
if el.tag not in unungroupable:
clippedout = compose_all(
el, gclip, gmask, gtransform, gstyle, removetextclip=removetextclip
)
if clippedout:
el.delete()
else:
gparent.insert(gindex + 1, el) # places above
if len(g) == 0:
g.delete()
# Group a list of elements, placing the group in the location of the first element
def group(el_list, moveTCM=False):
g = inkex.Group()
myi = list(el_list[0].getparent()).index(el_list[0])
el_list[0].getparent().insert(myi + 1, g)
for el in el_list:
g.append(el)
# If moveTCM is set and are grouping one element, move transform/clip/mask to group
# Handy for adding and properly composing transforms/clips/masks
if moveTCM and len(el_list) == 1:
g.ctransform = el.ctransform
el.ctransform = None
g.set("clip-path", el.get("clip-path"))
el.set("clip-path", None)
g.set("mask", el.get("mask"))
el.set("mask", None)
return g
# For composing a group's properties onto its children (also group-like objects like Uses)
Itmat = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0))
def compose_all(el, clip, mask, transform, style, removetextclip=False):
if style is not None: # style must go first since we may change it with CSS
mysty = el.ccascaded_style
compsty = style + mysty
compsty["opacity"] = str(
float(mysty.get("opacity", "1")) * float(style.get("opacity", "1"))
) # opacity accumulates at each layer
el.cstyle = compsty
if removetextclip and el.tag in ttags:
el.set("clip-path", None)
el.set("mask", None)
cout = False
else:
if clip is not None:
cout = merge_clipmask(el, clip) # clip applied before transform, fix first
if mask is not None:
merge_clipmask(el, mask, mask=True)
if clip is not None:
fix_css_clipmask(el)
if mask is not None:
fix_css_clipmask(el, mask=True)
if transform is not None and transform.matrix != Itmat:
if el.ctransform is None or el.ctransform.matrix == Itmat:
el.ctransform = transform
else:
el.ctransform = transform @ el.ctransform
if clip is None:
return False
else:
return cout
# If an element has clipping/masking specified in a stylesheet, this will override any attributes
# I think this is an Inkscape bug
# Fix by creating a style specific to my id that includes the new clipping/masking
def fix_css_clipmask(el, mask=False):
cm = "clip-path" if not mask else "mask"
svg = el.croot
if svg is not None:
mycss = svg.cssdict.get(el.get_id())
if (
mycss is not None
and mycss.get(cm) is not None
and mycss.get(cm) != el.get(cm)
):
sty = el.croot.crootsty
sty.text = (sty.text or "") + "\n#{0}{{{1}:{2}}}".format(
el.get_id(), cm, el.get(cm)
)
mycss[cm] = el.get(cm)
if el.cstyle.get(cm) is not None: # also clear local style
el.cstyle[cm] = None
def intersect_paths(ptha, pthb):
# Intersect two rectangular paths. Could be generalized later
ptsa = list(ptha.end_points)
ptsb = list(pthb.end_points)
x1c = max(min([p.x for p in ptsa]), min([p.x for p in ptsb]))
x2c = min(max([p.x for p in ptsa]), max([p.x for p in ptsb]))
y1c = max(min([p.y for p in ptsa]), min([p.y for p in ptsb]))
y2c = min(max([p.y for p in ptsa]), max([p.y for p in ptsb]))
w = x2c - x1c
h = y2c - y1c
if w > 0 and h > 0:
return Path("M {},{} h {} v {} h {} Z".format(x1c, y1c, w, h, -w))
else:
return Path("")
usetag = inkex.Use.ctag
def merge_clipmask(node, newclip, mask=False):
# Modified from Deep Ungroup
def compose_clips(el, ptha, pthb):
newpath = intersect_paths(ptha, pthb)
isempty = str(newpath) == ""
if not (isempty):
p = PathElement()
el.getparent().append(p)
p.set("d", newpath)
el.delete()
return isempty # if clipped out, safe to delete element
if newclip is not None:
svg = node.croot
cmstr = "mask" if mask else "clip-path"
if node.ctransform is not None:
# Clip-paths on nodes with a transform have the transform
# applied to the clipPath as well, which we don't want.
# Duplicate the new clip and apply node's inverse transform to its children.
if newclip is not None:
d = newclip.duplicate()
if not (hasattr(svg, "newclips")):
svg.newclips = []
svg.newclips.append(d) # for later cleanup
for k in list(d):
compose_all(k, None, None, -node.ctransform, None)
# newclipurl = d.get_id(2)
newclip = d
if newclip is not None:
for k in list(newclip):
if k.tag == usetag:
k = unlink2(k)
oldclip = node.get_link(cmstr, llget=True)
if oldclip is not None:
# Existing clip is replaced by a duplicate, then apply new clip to children of duplicate
for k in list(oldclip):
if k.tag == usetag:
k = unlink2(k)
d = oldclip.duplicate()
if not (hasattr(svg, "newclips")):
svg.newclips = []
svg.newclips.append(d) # for later cleanup
node.set(cmstr, d.get_id(2))
newclipisrect = False
if newclip is not None and len(newclip) == 1:
newclipisrect = isrectangle(list(newclip)[0])
couts = []
for k in reversed(list(d)): # may be deleting, so reverse
oldclipisrect = isrectangle(k)
if newclipisrect and oldclipisrect and mask == False:
# For rectangular clips, we can compose them easily
# Since most clips are rectangles this semi-fixes the PDF clip export bug
newclippth = list(newclip)[0].cpath.transform(
list(newclip)[0].ctransform
)
oldclippth = k.cpath.transform(k.ctransform)
cout = compose_clips(k, newclippth, oldclippth)
else:
cout = merge_clipmask(k, newclip, mask)
couts.append(cout)
cout = all(couts)
if oldclip is None:
node.set(cmstr, newclip.get_id(2))
cout = False
return cout
# A cached list of all descendants of an svg in order
# Currently only handles deletions appropriately
class dtree:
def __init__(self, svg):
ds, pts = svg.descendants2(True)
self.ds = ds
self.iids = {d: ii for ii, d in enumerate(ds)} # desc. index by el
iipts = {
ptv: (ii, jj) for ii, pt in enumerate(pts) for jj, ptv in enumerate(pt)
}
self.range = [(ii, iipts[d][0]) for ii, d in enumerate(ds)]
def iterel(self, el):
try:
eli = self.iids[el]
for ii in range(self.range[eli][0], self.range[eli][1]):
yield self.ds[ii]
except:
pass
def delel(self, el):
try:
eli = self.iids[el]
strt = self.range[eli][0]
stop = self.range[eli][1]
self.ds = self.ds[:strt] + self.ds[stop:]
self.range = self.range[:strt] + self.range[stop:]
N = stop - strt
self.range = [
(x - N if x > strt else x, y - N if y > strt else y)
for x, y in self.range
]
self.iids = {d: ii for ii, d in enumerate(self.ds)} # desc. index by el
except:
pass
def get_cd2(svg):
if not (hasattr(svg, "_cd2")):
svg._cd2 = dtree(svg)
return svg._cd2
def set_cd2(svg, sv):
if sv is None and hasattr(svg, "_cd2"):
delattr(svg, "_cd2")
inkex.SvgDocumentElement.cdescendants2 = property(get_cd2, set_cd2)
masktag = inkex.addNS("mask", "svg")
svgtag = inkex.SvgDocumentElement.ctag
unrendered = tags(
(
inkex.NamedView,
inkex.Defs,
inkex.Metadata,
inkex.ForeignObject,
inkex.Guide,
inkex.ClipPath,
inkex.StyleElement,
Tspan,
inkex.FlowRegion,
inkex.FlowPara,
)
)
unrendered.update(
{
masktag,
inkex.addNS("RDF", "rdf"),
inkex.addNS("Work", "cc"),
inkex.addNS("format", "dc"),
inkex.addNS("type", "dc"),
}
)
def wrapped_binary(filename, inkscape_binary=None, extra_args=None, svg=None, get_bbs=True,cwd=None):
"""
Retrieves all of a document's bounding boxes using a call to the Inkscape binary.
Parameters:
filename (str): The path to the SVG file.
inkscape_binary (str): The path to the Inkscape binary. If not provided,
it will attempt to find it.
extra_args (list): Additional arguments to pass to the Inkscape command.
svg: An optional svg to use instead of loading from file.
Returns:
dict: A dictionary where keys are element IDs and values are bounding
boxes in user units.
"""
if inkscape_binary is None:
inkscape_binary = inkex.inkscape_system_info.binary_location
extra_args = [] if extra_args is None else extra_args
if get_bbs:
arg2 = [inkscape_binary, "--query-all"] + extra_args + [filename]
proc = subprocess_repeat(arg2,cwd=cwd)
else:
arg2 = [inkscape_binary] + extra_args + [filename]
proc = subprocess_repeat(arg2,cwd=cwd)
return None
tfstr = proc.stdout
# Parse the output
tbbli = tfstr.splitlines()
bbs = dict()
for line in tbbli:
keyv = str(line).split(",", maxsplit=1)[0]
if keyv[0:2] == "b'": # pre version 1.1
keyv = keyv[2:]
if str(line)[2:52] == "WARNING: Requested update while update in progress":
continue
# skip warnings (version 1.0 only?)
data = [float(x.strip("'")) for x in str(line).split(",")[1:]]
if keyv != "'": # sometimes happens in v1.3
bbs[keyv] = data
# Inkscape always reports a bounding box in pixels, relative to the viewbox
# Convert to user units for the output
if svg is None:
# If SVG not supplied, load from file from load_svg
svg = load_svg(filename).getroot()
dsz = svg.cdocsize
for k in bbs:
bbs[k] = dsz.pxtouu(bbs[k])
return bbs
# Determine if object has a bbox
@lru_cache(maxsize=None)
def hasbbox(el):
myp = el.getparent()
if myp is None:
return el.tag == svgtag
else:
return el.tag not in unrendered if hasbbox(myp) else False
# Determine if object itself is drawn
@lru_cache(maxsize=None)
def isdrawn(el):
return (
el.tag not in grouplike_tags
and hasbbox(el)
and el.cspecified_style.get("display") != "none"
)
# A wrapper that replaces get_bounding_boxes with Pythonic calls only if possible
def BB2(svg, els=None, forceupdate=False, roughpath=False, parsed=False):
if els is None:
els = svg.descendants2()
if all([d.tag in bb2_support_tags or not (hasbbox(d)) for d in els]):
# All descendants of all els in the list
allds = set()
for el in els:
if el not in allds: # so we're not re-descendants2ing
allds.update(el.descendants2())
tels = [
d
for d in unique(allds)
if isinstance(d, (inkex.TextElement, inkex.FlowRoot))
]
if len(tels) > 0:
if forceupdate:
svg.char_table = None
for d in els:
d.cbbox = None
d.parsed_text = None
if not hasattr(svg, "_char_table"):
from inkex.text import parser # noqa
svg.make_char_table(els=tels)
# pts = [el.parsed_text for el in tels]
ptl = parser.ParsedTextList(tels)
ptl.precalcs()
ret = dict()
for d in els:
if d.tag in bb2_support_tags and hasbbox(d):
mbbox = bounding_box2(d, roughpath=roughpath, parsed=parsed)
if not (mbbox.isnull):
ret[d.get_id()] = mbbox.sbb
else:
import tempfile
# with tempfile.TemporaryFile() as temp:
# idebug(temp.name)
# tname = os.path.abspath(temp.name)
# overwrite_svg(svg, tname)
# ret = wrapped_binary(filename=tname, svg=svg)
with tempfile.NamedTemporaryFile(delete=False) as temp:
tname = os.path.abspath(temp.name)
try:
overwrite_svg(svg, tname)
ret = wrapped_binary(filename=tname, svg=svg)
finally:
if os.path.exists(tname):
os.remove(tname)
return ret
# For diagnosing BB2
def Check_BB2(svg):
bb2 = BB2(svg)
HIGHLIGHT_STYLE = "fill:#007575;fill-opacity:0.4675" # mimic selection
for el in svg.descendants2():
if el.get_id() in bb2 and not el.tag in grouplike_tags:
bb = bbox(bb2[el.get_id()])
# bb = bbox(bb2[el.get_id()])*(1/slf.svg.cscale);
r = inkex.Rectangle()
r.set("mysource", el.get_id())
r.set("x", bb.x1)
r.set("y", bb.y1)
r.set("height", bb.h)
r.set("width", bb.w)
r.set("style", HIGHLIGHT_STYLE)
el.croot.append(r)
# Vectorized calculation of bbox intersection bools
# Returns a matrix sized len(bbs) x len(bb2s)
def bb_intersects(bbs, bb2s=None):
if bb2s is None:
bb2s = bbs
import numpy as np
if len(bbs) == 0 or len(bb2s) == 0:
return np.zeros((len(bbs), len(bb2s)), dtype=bool)
else:
xc1, yc1, wd1, ht1 = np.array([
(bb.xc, bb.yc, bb.w, bb.h) if not bb.isnull else (np.nan, np.nan, np.nan, np.nan)
for bb in bbs
]).T
xc2, yc2, wd2, ht2 = np.array([
(bb.xc, bb.yc, bb.w, bb.h) if not bb.isnull else (np.nan, np.nan, np.nan, np.nan)
for bb in bb2s
]).T
return np.logical_and(
np.nan_to_num(abs(xc1.reshape(-1, 1) - xc2) * 2 < (wd1.reshape(-1, 1) + wd2), nan=False),
np.nan_to_num(abs(yc1.reshape(-1, 1) - yc2) * 2 < (ht1.reshape(-1, 1) + ht2), nan=False),
)
# Return list of objects on top of other objects
def overlapping_els(svg,tocheck):
els = [el for el in svg.descendants2() if isdrawn(el)]
bbs = BB2(svg, els, roughpath=True, parsed=True)
bbs = [bbox(bbs.get(el.get_id())) for el in els]
chki = [i for i,el in enumerate(els) if el in tocheck]
bbs_check = [bbs[i] for i in chki]
intrscts = bb_intersects(bbs, bbs_check)
ret = {el: [] for el in tocheck}
for j,ci in enumerate(chki):
elj = els[ci]
for i in range(ci+1,len(els)):
eli = els[i]
ds = elj.descendants2()
if intrscts[i,j] and eli not in ds:
ret[elj].append(eli)
# for k,v in ret.items():
# dh.idebug(k.get_id()+': '+str([v2.get_id() for v2 in v]))
return ret
# Get SVG from file
from inkex import load_svg
def svg_from_file(fin):
svg = load_svg(fin).getroot()
return svg
def el_from_string(strin):
prefix = """
<svg
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
"""
svgtxt = prefix + strin + "</svg>"
nsvg = svg_from_file(svgtxt)
return list(nsvg)[0]
# Write to disk, removing any existing file
def overwrite_svg(svg, fileout):
try:
os.remove(fileout)
except:
pass
# idebug(inkex)
import inkex.command # needed for a weird bug in v1.1
inkex.command.write_svg(svg, fileout)
global debugs
debugs = ""
def debug(x):
# inkex.utils.debug(x);
global debugs
if debugs != "":
debugs += "\n"
debugs += str(x)
def write_debug():
global debugs
if debugs != "":
debugname = "Debug.txt"
f = open(debugname, "w", encoding="utf-8")
f.write(debugs)
f.close()
def idebug(x, printids=True):
def is_nested_list_of_base_elements(x):
if not isinstance(x, (list, tuple)):
return False
for element in x:
if isinstance(element, (list, tuple)):
if not is_nested_list_of_base_elements(element):
return False
elif not isinstance(element, BaseElement):
return False
return True
if printids and is_nested_list_of_base_elements(x):
def process_nested_list(input_list):
if isinstance(input_list, list):
return [process_nested_list(e) for e in input_list]
if isinstance(input_list, tuple):
return tuple([process_nested_list(e) for e in input_list])
elif isinstance(input_list, BaseElement):
return input_list.get_id()
pv = process_nested_list(x)
else:
pv = x
inkex.utils.debug(pv)
import time
global lasttic
def tic():
global lasttic
lasttic = time.time()
def toc():
global lasttic
idebug(time.time() - lasttic)
def benchmark_functions(func1, func2):
import numpy as np
differences = []
time1s = []
MINIBATCH_TIME = 0.01
TOTAL_TIME = 10
strt = time.time()
f1cnt = 0
while time.time() - strt < MINIBATCH_TIME:
func1()
f1cnt += 1
strt = time.time()
f2cnt = 0
while time.time() - strt < MINIBATCH_TIME:
func2()
f2cnt += 1
for Nr in range(5):
strt = time.time()
for ii in range(f1cnt):
func1()
f1act = time.time() - strt
strt = time.time()
for ii in range(f2cnt):
func2()
f2act = time.time() - strt
f1cnt = int(f1cnt * MINIBATCH_TIME / f1act)
f2cnt = int(f2cnt * MINIBATCH_TIME / f2act)
M = int(TOTAL_TIME / MINIBATCH_TIME / 2)
N1 = f1cnt
N2 = f2cnt
for _ in range(M):
# Timing function 1
start_time = time.time()
for _ in range(N1):
func1()
end_time = time.time()
time_func1 = (end_time - start_time) / N1
time1s.append(time_func1)
# Timing function 2
start_time = time.time()
for _ in range(N2):
func2()
end_time = time.time()
time_func2 = (end_time - start_time) / N2
# Calculate the difference in times
time_difference = time_func2 - time_func1
differences.append(time_difference)
# Calculating mean and standard deviation of time differences
mean_difference = np.mean(differences)
std_deviation = np.std(differences)
return np.mean(time1s), mean_difference, std_deviation / np.sqrt(M)
# style atts that could have urls
urlatts = [
"fill",
"stroke",
"clip-path",
"mask",
"filter",
"marker-start",
"marker-mid",
"marker-end",
"marker",
]
# An efficient Pythonic version of Clean Up Document
def clean_up_document(svg):
# defs types that do nothing unless they are referenced
prune = [
"clipPath",
"mask",
"linearGradient",
"radialGradient",
"pattern",
"symbol",
"marker",
"filter",
"animate",
"animateTransform",
"animateMotion",
"textPath",
"font",
"font-face",
]
prune = [inkex.addNS(v, "svg") for v in prune]
# defs types that don't need to be referenced
exclude = [inkex.addNS(v, "svg") for v in ["style", "glyph"]]
def should_prune(el):
return el.tag in prune or (
el.getparent() == svg.cdefs and el.tag not in exclude
)
xlink = [inkex.addNS("href", "xlink"), "href"]
attids = {sa: dict() for sa in urlatts}
xlinks = dict()
def miterdescendants(el):
yield el
for d in el.iterdescendants():
yield d
# Make dicts of all url-containing style atts and xlinks
for d in svg.cdescendants2.ds:
for attName in d.attrib.keys():
if attName in urlatts:
if d.attrib[attName].startswith("url"):
attids[attName][d.get_id()] = d.attrib[attName][5:-1]
elif attName in xlink:
if d.attrib[attName].startswith("#"):
xlinks[d.get_id()] = d.attrib[attName][1:]
elif attName == "style":
if "url" in d.attrib[attName]:
sty = Style(d.attrib[attName])
for an2 in sty.keys():
if an2 in urlatts:
if sty[an2].startswith("url"):
attids[an2][d.get_id()] = sty[an2][5:-1]
deletedsome = True
while deletedsome:
allurls = set(
[v for sa in urlatts for v in attids[sa].values()] + list(xlinks.values())
)
# sets much faster than lists for membership testing
deletedsome = False
for el in svg.cdescendants2.ds:
if should_prune(el):
eldids = [dv.get_id() for dv in svg.cdescendants2.iterel(el)]
if not (any([idv in allurls for idv in eldids])):
el.delete()
deletedsome = True
for did in eldids:
for anm in urlatts:
if did in attids[anm]:
del attids[anm][did]
if did in xlinks:
del xlinks[did]
def global_transform(el, trnsfrm, irange=None, trange=None, preserveStroke=True):
# Transforms an object and fuses it to any paths
# If preserveStroke is set the stroke width will be unchanged, otherwise
# will also be scaled
# If parent layer is transformed, need to rotate out of its coordinate system
myp = el.getparent()
if myp is None:
prt = Transform([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
else:
prt = myp.ccomposed_transform
myt = el.ctransform
if myt == None:
newtr = (-prt) @ trnsfrm @ prt
if trange is not None:
for ii in range(len(trange)):
trange[ii] = (-prt) @ trange[ii] @ prt
else:
newtr = (-prt) @ trnsfrm @ prt @ Transform(myt)
if trange is not None:
for ii in range(len(trange)):
trange[ii] = (-prt) @ trange[ii] @ prt @ Transform(myt)
sw, _, _ = composed_width(el, "stroke-width")
sd, _ = composed_list(el, "stroke-dasharray")
el.ctransform = newtr # Add the new transform
fuseTransform(el, irange=irange, trange=trange)
if preserveStroke:
if sw is not None:
neww, sf, _ = composed_width(el, "stroke-width")
if sf != 0:
el.cstyle["stroke-width"] = str(sw / sf)
# fix width
if not (sd in [None, "none"]):
nd, sf = composed_list(el, "stroke-dasharray")
if sf != 0:
el.cstyle["stroke-dasharray"] = (
str([sdv / sf for sdv in sd]).strip("[").strip("]")
)
# fix dash
# Combines a group of path-like elements
def combine_paths(els, mergeii=0):
pnew = Path()
si = []
# start indices
for el in els:
pth = el.cpath.to_absolute().transform(el.ccomposed_transform)
if el.get("inkscape-scientific-combined-by-color") is None:
si.append(len(pnew))
else:
cbc = el.get(
"inkscape-scientific-combined-by-color"
) # take existing ones and weld them
cbc = [int(v) for v in cbc.split()]
si += [v + len(pnew) for v in cbc[0:-1]]
for p in pth:
pnew.append(p)
si.append(len(pnew))
# Set the path on the mergeiith element
mel = els[mergeii]
if mel.get("d") is None: # Polylines and lines have to be converted to a path
mel.object_to_path()
mel.set("d", str(pnew.transform(-mel.ccomposed_transform)))
# Release clips/masks
mel.set("clip-path", "none")
# release any clips
mel.set("mask", "none")
# release any masks
fix_css_clipmask(mel, mask=False) # fix CSS bug
fix_css_clipmask(mel, mask=True)
mel.set("inkscape-scientific-combined-by-color", " ".join([str(v) for v in si]))
for s in range(len(els)):
if s != mergeii:
# deleteup(els[s])
els[s].delete(deleteup=True)
# Gets all of the stroke and fill properties from a style
# Alpha is its effective alpha including opacity
# Note to self: inkex.Color inherits from list
from inkex.text.utils import default_style_atts as dsa
def get_strokefill(el):
sty = el.cspecified_style
strk = sty.get("stroke", dsa.get("stroke"))
fill = sty.get("fill", dsa.get("fill"))
op = float(sty.get("opacity", 1.0))
nones = [None, "none"]
strk_isurl, fill_isurl = False, False
if not (strk in nones):
try:
strk = inkex.Color(strk).to_rgb()
strkl = strk.lightness
strkop = float(sty.get("stroke-opacity", 1.0))
strk.alpha = strkop * op
strkl = strk.alpha * strkl / 255 + (1 - strk.alpha) * 1
# effective lightness frac with a white bg
strk.efflightness = strkl
except: # inkex.colors.ColorIdError:
if "url(#" in strk:
strk = sty.get_link("stroke", el.croot)
strk_isurl = True
else:
strk = None
else:
strk = None
if not (fill in nones):
try:
fill = inkex.Color(fill).to_rgb()
filll = fill.lightness
fillop = float(sty.get("fill-opacity", 1.0))
fill.alpha = fillop * op
filll = fill.alpha * filll / 255 + (1 - fill.alpha) * 1
# effective lightness frac with a white bg
fill.efflightness = filll
except: # inkex.colors.ColorIdError:
if "url(#" in fill:
fill = sty.get_link("fill", el.croot)
fill_isurl = True
else:
fill = None
else:
fill = None
sw, _, _ = composed_width(el, "stroke-width")
sd, _ = composed_list(el, "stroke-dasharray")
if sd in nones:
sd = None
if sw in nones or sw == 0 or strk is None:
sw = None
strk = None
sd = None
ms = sty.get("marker-start", None)
mm = sty.get("marker-mid", None)
me = sty.get("marker-end", None)
class StrokeFill:
def __init__(self, *args):
(
self.stroke,
self.fill,
self.strokewidth,
self.strokedasharray,
self.markerstart,
self.markermid,
self.markerend,
self.strk_isurl,
self.fill_isurl,
) = args
return StrokeFill(strk, fill, sw, sd, ms, mm, me, strk_isurl, fill_isurl)
# Gets the caller's location
def get_script_path():
return os.path.dirname(os.path.realpath(sys.argv[0]))
# Return a document's visible descendants not in Defs/Metadata/etc
def visible_descendants(svg):
ndefs = [el for el in list(svg) if not (el.tag in unungroupable)]
return [v for el in ndefs for v in el.descendants2()]
# Get document location or prompt
def Get_Current_File(ext, msgstr):
tooearly = inkex.installed_ivp[0] <= 1 and inkex.installed_ivp[1] < 1
if not (tooearly):
myfile = ext.document_path()
else:
myfile = None
if myfile is None or myfile == "":
if tooearly:
msg = msgstr + "Inkscape must be version 1.1.0 or higher."
else:
msg = (
msgstr
+ "the SVG must first be saved. Please retry after you have done so."
)
inkex.utils.errormsg(msg)
quit()
return None
else:
return myfile
import threading
sema_temp = threading.Semaphore(1)
def shared_temp(headprefix = None, filename=None):
"""
Generate a temporary file in the system temp folder or SI's location
(tempfile does not always work with Linux Snap distributions)
Can also generate a unique temp_head that can be used to prefix all temp files.
Since the Inkscape binary cannot handle multiple cwd arguments when multithreading,
(and might switch dirs unexpectedly), any exports with multiple cwd's and relative
paths must be done here.
"""
if sys.executable[0:4] == "/tmp" or sys.executable[0:5] == "/snap":
si_dir = os.path.dirname(
os.path.realpath(__file__)
) # in case si_dir is not loaded
system_temp = si_dir
else:
import tempfile
system_temp = tempfile.gettempdir()
if not os.path.exists(system_temp):
os.mkdir(system_temp)
tempdir = os.path.join(os.path.abspath(system_temp), 'si_temp')
if not os.path.exists(tempdir):
os.mkdir(tempdir)
if headprefix is not None:
with sema_temp:
pnum = random.randint(1, 100000)
while any(t.startswith(f"{headprefix}{pnum:05d}") for t in os.listdir(tempdir)):
pnum = random.randint(1, 100000)
temphead = f"{headprefix}{pnum:05d}"
tempbase = os.path.join(tempdir, temphead)
open(tempbase+'.lock', 'w').close()
return tempdir, temphead
if filename is not None:
return os.path.join(tempdir,filename)
return tempdir
ttags = tags((inkex.TextElement, inkex.FlowRoot))
line_tag = inkex.Line.ctag
cpath_support_tags = tags(BaseElementCache.cpath_support)
mask_tag = inkex.addNS("mask", "svg")
grouplike_tags = tags(
(
inkex.SvgDocumentElement,
inkex.Group,
inkex.Layer,
inkex.ClipPath,
inkex.Symbol,
)
)
grouplike_tags.add(mask_tag)
def bounding_box2(
self, dotransform=True, includestroke=True, roughpath=False, parsed=False, includeclipmask=True
):
"""
Cached bounding box that requires no command call
Uses extents for text
dotransform: whether or not we want the element's bbox or its true
transformed bbox
includestroke: whether or not to add the stroke to the calculation
roughpath: use control points for a path's bbox, which is faster and an
upper bound for the true bbox
"""
if not (hasattr(self, "_cbbox")):
self._cbbox = dict()
inputs = (dotransform, includestroke, roughpath, parsed, includeclipmask)
if inputs not in self._cbbox:
try:
ret = bbox(None)
if self.tag in ttags:
ret = self.parsed_text.get_full_extent(parsed=parsed)
elif self.tag in cpath_support_tags:
pth = self.cpath
if len(pth) > 0:
swd = ipx(self.cspecified_style.get("stroke-width", "0px"))
if self.cspecified_style.get("stroke") is None or not (
includestroke
):
swd = 0
if self.tag == line_tag:
x = [ipx(self.get("x1", "0")), ipx(self.get("x2", "0"))]
y = [ipx(self.get("y1", "0")), ipx(self.get("y2", "0"))]
ret = bbox(
[
min(x) - swd / 2,
min(y) - swd / 2,
max(x) - min(x) + swd,
max(y) - min(y) + swd,
]
)
elif not roughpath:
bbx = pth.bounding_box()
ret = bbox(
[
bbx.left - swd / 2,
bbx.top - swd / 2,
bbx.width + swd,
bbx.height + swd,
]
)
else:
anyarc = any(s.letter in ["a", "A"] for s in pth)
pth = inkex.Path(inkex.CubicSuperPath(pth)) if anyarc else pth
pts = list(pth.control_points)
x = [p.x for p in pts]
y = [p.y for p in pts]
ret = bbox(
[
min(x) - swd / 2,
min(y) - swd / 2,
max(x) - min(x) + swd,
max(y) - min(y) + swd,
]
)
elif self.tag in grouplike_tags:
for kid in list2(self):
dbb = bounding_box2(
kid,
dotransform=False,
includestroke=includestroke,
roughpath=roughpath,
parsed=parsed,
)
if not (dbb.isnull):
ret = ret.union(dbb.transform(kid.ctransform))
elif isinstance(self, (inkex.Image)):
ret = bbox(
[ipx(self.get(v, "0")) for v in ["x", "y", "width", "height"]]
)
elif isinstance(self, (inkex.Use,)):
lel = self.get_link("xlink:href")
if lel is not None:
ret = bounding_box2(
lel, dotransform=False, roughpath=roughpath, parsed=parsed
)
# clones have the transform of the link, followed by any
# xy transform
xyt = inkex.Transform(
"translate({0},{1})".format(
ipx(self.get("x", "0")), ipx(self.get("y", "0"))
)
)
ret = ret.transform(xyt @ lel.ctransform)
if not (ret.isnull):
if includeclipmask:
for cmv in ["clip-path", "mask"]:
clip = self.get_link(cmv, llget=True)
if clip is not None:
cbb = bounding_box2(
clip,
dotransform=False,
includestroke=False,
roughpath=roughpath,
parsed=parsed,
)
if not (cbb.isnull):
ret = ret.intersection(cbb)
else:
ret = bbox(None)
if dotransform:
if not (ret.isnull):
ret = ret.transform(self.ccomposed_transform)
except:
# For some reason errors are occurring silently
import traceback
inkex.utils.debug(traceback.format_exc())
self._cbbox[inputs] = ret
return self._cbbox[inputs]
def set_cbbox(self, val):
"""Invalidates the cached bounding box."""
if val is None and hasattr(self, "_cbbox"):
delattr(self, "_cbbox")
inkex.BaseElement.cbbox = property(bounding_box2, set_cbbox)
inkex.BaseElement.bounding_box2 = bounding_box2
bb2_support = (
inkex.TextElement,
inkex.FlowRoot,
inkex.Image,
inkex.Use,
inkex.SvgDocumentElement,
inkex.Group,
inkex.Layer,
) + BaseElementCache.cpath_support
bb2_support_tags = tags(bb2_support)
masktag = inkex.addNS("mask", "svg")
def isMask(el):
return el.tag == masktag
# cprofile tic and toc
def ctic():
import cProfile
global pr
pr = cProfile.Profile()
pr.enable()
def ctoc():
import io, pstats
global pr
pr.disable()
s = io.StringIO()
sortby = pstats.SortKey.CUMULATIVE
profiledir = os.path.dirname(os.path.abspath(__file__))
try:
pr.dump_stats(os.path.abspath(os.path.join(profiledir, "cprofile.prof")))
ps = pstats.Stats(pr, stream=s).sort_stats(sortby)
ps.print_stats()
ppath = os.path.abspath(os.path.join(profiledir, "cprofile.csv"))
result = s.getvalue()
prefix = result.split("ncalls")[0]
# chop the string into a csv-like buffer
result = "ncalls" + result.split("ncalls")[-1]
result = "\n".join(
[",".join(line.rstrip().split(None, 5)) for line in result.split("\n")]
)
result = prefix + "\n" + result
with open(ppath, "w") as f:
f.write(result)
except OSError: # occasional irreproducible error, doesn't matter
return
def Run_SI_Extension(effext, name):
Version_Check(name)
def run_and_cleanup():
effext.run()
# flush_stylesheet_entries(effext.svg)
alreadyran = False
lprofile = os.getenv("LINEPROFILE") == "True"
batexists = os.path.exists(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "cprofile open.bat")
)
cprofile = batexists if not lprofile else False
if cprofile or lprofile:
profiledir = get_script_path()
if cprofile:
ctic()
if lprofile:
try:
from line_profiler import LineProfiler
lp = LineProfiler()
from inkex.text import parser
from inkex.text import font_properties
from inkex.text import speedups
from inkex.text import cache
import remove_kerning
from inspect import getmembers, isfunction, isclass, getmodule
fns = []
for m in [
sys.modules[__name__],
parser,
remove_kerning,
Style,
font_properties,
inkex.transforms,
getmodule(effext),
speedups,
cache,
]:
fns += [v[1] for v in getmembers(m, isfunction)]
for c in getmembers(m, isclass):
if getmodule(c[1]) is m:
fns += [v[1] for v in getmembers(c[1], isfunction)]
for p in getmembers(
c[1], lambda o: isinstance(o, property)
):
if p[1].fget is not None:
fns += [p[1].fget]
if p[1].fset is not None:
fns += [p[1].fset]
for fn in fns:
lp.add_function(fn)
lp.add_function(ipx.__wrapped__)
lp.add_function(font_properties.true_style.__wrapped__)
lp.add_function(speedups.transform_to_matrix.__wrapped__)
lp.add_function(Style.parse_str.__wrapped__)
lp(run_and_cleanup)()
import io
stdouttrap = io.StringIO()
lp.dump_stats(
os.path.abspath(os.path.join(profiledir, "lprofile.prof"))
)
lp.print_stats(stdouttrap)
ppath = os.path.abspath(os.path.join(profiledir, "lprofile.csv"))
result = stdouttrap.getvalue()
with open(ppath, "w", encoding="utf-8") as f:
f.write(result)
# Copy lprofile.csv to the profiles subdirectory
profiles_dir = os.path.join(os.path.dirname(ppath), "profiles")
if not os.path.exists(profiles_dir):
os.makedirs(profiles_dir)
from datetime import datetime
import shutil
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
new_filename = f"lprofile_{timestamp}.csv"
dst_path = os.path.join(profiles_dir, new_filename)
shutil.copy2(ppath, dst_path)
alreadyran = True
except ImportError:
pass
if not (alreadyran):
try:
run_and_cleanup()
except lxml.etree.XMLSyntaxError:
try:
# Try getting Inkscape to write a new clean copy
s = effext
s.parse_arguments(sys.argv[1:])
if s.options.input_file is None:
s.options.input_file = sys.stdin
elif "DOCUMENT_PATH" not in os.environ:
os.environ["DOCUMENT_PATH"] = s.options.input_file
def overwrite_output(filein, fileout):
try:
os.remove(fileout)
except:
pass
arg2 = [
inkex.inkscape_system_info.binary_location,
"--export-filename",
fileout,
filein,
]
subprocess_repeat(arg2)
tmpname = s.options.input_file.strip(".svg") + "_tmp.svg"
overwrite_output(s.options.input_file, tmpname)
os.remove(s.options.input_file)
os.rename(tmpname, s.options.input_file)
try:
run_and_cleanup()
except lxml.etree.XMLSyntaxError:
# Try removing problematic bytes
with open(s.options.input_file, "rb") as f:
bytes_content = f.read()
cleaned_content = bytes_content.decode("utf-8", errors="ignore")
nfin = s.options.input_file.strip(".svg") + "_tmp.svg"
with open(nfin, "w", encoding="utf-8") as f:
f.write(cleaned_content)
os.remove(s.options.input_file)
os.rename(tmpname, s.options.input_file)
run_and_cleanup()
except:
inkex.utils.errormsg(
"Error reading file! Extensions can only run on SVG files.\n\nIf this is a file imported from another format, try saving as an SVG and restarting Inkscape. Alternatively, try pasting the contents into a new document."
)
if cprofile:
ctoc()
write_debug()
# Display accumulated caller info if any
# from inkex.transforms import callinfo
global callinfo
try:
callinfo
except:
callinfo = dict()
sorted_items = sorted(callinfo.items(), key=lambda x: x[1], reverse=True)
for key, value in sorted_items:
idebug(f"{key}: {value}")
# Give early versions of Style a .to_xpath function
def to_xpath_func(sty):
# pre-1.2: use v1.1 version of to_xpath from inkex.Style
import re
step_to_xpath = [
(
re.compile(r"\[(\w+)\^=([^\]]+)\]"),
r"[starts-with(@\1,\2)]",
), # Starts With
(re.compile(r"\[(\w+)\$=([^\]]+)\]"), r"[ends-with(@\1,\2)]"), # Ends With
(re.compile(r"\[(\w+)\*=([^\]]+)\]"), r"[contains(@\1,\2)]"), # Contains
(re.compile(r"\[([^@\(\)\]]+)\]"), r"[@\1]"), # Attribute (start)
(re.compile(r"#(\w+)"), r"[@id='\1']"), # Id Match
(re.compile(r"\s*>\s*([^\s>~\+]+)"), r"/\1"), # Direct child match
# (re.compile(r'\s*~\s*([^\s>~\+]+)'), r'/following-sibling::\1'),
# (re.compile(r'\s*\+\s*([^\s>~\+]+)'), r'/following-sibling::\1[1]'),
(re.compile(r"\s*([^\s>~\+]+)"), r"//\1"), # Decendant match
(
re.compile(r"\.([-\w]+)"),
r"[contains(concat(' ', normalize-space(@class), ' '), ' \1 ')]",
),
(re.compile(r"//\["), r"//*["), # Attribute only match
(re.compile(r"//(\w+)"), r"//svg:\1"), # SVG namespace addition
]
def style_to_xpath(styin):
return "|".join([rule_to_xpath(rule) for rule in styin.rules])
def rule_to_xpath(rulein):
ret = rulein.rule
for matcher, replacer in step_to_xpath:
ret = matcher.sub(replacer, ret)
return ret
return style_to_xpath(sty)
if not hasattr(Style, "to_xpath"):
Style.to_xpath = to_xpath_func
if not hasattr(inkex.Style, "to_xpath"):
inkex.Style.to_xpath = to_xpath_func
# Patch Style string conversion to restore single-quote strings
# FQUOTE = r'^[^\'"]*\"'
# def swap_quotes(s):
# return s.translate(str.maketrans({"'": '"', '"': "'"}))
# def to_str(self, sep=";"):
# return sep.join(
# [f"{key}:{value}" if not re.search(FQUOTE, str(value)) else f"{key}:{swap_quotes(value)}" for key, value in self.items()]
# )
# def __str__(self):
# return ";".join(
# [f"{key}:{value}" if not re.search(FQUOTE, str(value)) else f"{key}:{swap_quotes(value)}" for key, value in self.items()]
# )
# inkex.Style.to_str = to_str
# inkex.Style.__str__ = __str__
def nonascii(c):
"""Returns True if the character is non-ASCII."""
return ord(c) >= 128
def nonletter(c):
"""Returns True if the character is not a letter."""
return not ((ord(c) >= 65 and ord(c) <= 90) or (ord(c) >= 97 and ord(c) <= 122))
fixwith = {
"Avenir": (nonletter, "'Avenir Next', 'Arial'"),
"Whitney": (nonascii, "'Avenir Next', 'Arial'"),
"Whitney Book": (nonascii, "'Avenir Next', 'Arial'"),
}
fw2 = {k.lower(): val for k, val in fixwith.items()}
def shouldfixfont(ffam):
"""Checks if the font needs fixing based on non-ASCII or non-letter characters."""
shouldfix = (
ffam is not None
and ffam.split(",")[0].strip("'").strip('"').lower() in fw2.keys()
)
fixw = (
None if not shouldfix else fw2[ffam.split(",")[0].strip("'").strip('"').lower()]
)
return shouldfix, fixw
def character_fixer(els):
"""Fixes characters in a list of elements based on their text style."""
for elem in els:
tree = TextTree(elem)
for _, typ, tel, sel, txt in tree.dgenerator():
if txt is not None and len(txt) > 0:
sty = sel.cspecified_style
shouldfix, fixw = shouldfixfont(sty.get("font-family"))
if shouldfix:
# replace_non_ascii_font(sel, fixw)
elem.set("xml:space", "preserve") # so spaces don't vanish
fixcondition, fixw = fixw
if all(fixcondition(c) for c in txt) and typ == TYP_TEXT:
sel.cstyle["font-family"] = fixw
else:
prev_nonascii = False
for j, c in enumerate(reversed(txt)):
i = len(txt) - 1 - j
if fixcondition(c):
if not prev_nonascii:
t = Tspan()
t.text = c
if typ == TYP_TEXT:
tbefore = tel.text[0:i]
tafter = tel.text[i + 1 :]
tel.text = tbefore
tel.insert(0, t)
t.tail = tafter
else:
tbefore = tel.tail[0:i]
tafter = tel.tail[i + 1 :]
tel.tail = tbefore
grp = tel.getparent()
# parent is a Tspan, so insert it into
# the grandparent
grp.insert(grp.index(tel) + 1, t)
# after the parent
t.tail = tafter
t.cstyle = Style(
"font-family:" + fixw + ";baseline-shift:0%"
)
else:
t.text = c + t.text
if typ == TYP_TEXT:
tel.text = tel.text[0:i]
else:
tel.tail = tel.tail[0:i]
if tel.text is not None and tel.text == "":
tel.text = None
if tel.tail is not None and tel.tail == "":
tel.tail = None
prev_nonascii = nonascii(c)
spantags = tags((Tspan, inkex.FlowPara, inkex.FlowSpan))
TEtag = inkex.TextElement.ctag
def replace_non_ascii_font(elem, newfont, *args):
"""Replaces non-ASCII characters in an element with a specified font."""
def alltext(elem):
astr = elem.text
if astr is None:
astr = ""
for k in list(elem):
if k.tag in spantags:
astr += alltext(k)
tlv = k.tail
if tlv is None:
tlv = ""
astr += tlv
return astr
forcereplace = len(args) > 0 and args[0]
if forcereplace or any(nonascii(c) for c in alltext(elem)):
alltxt = [elem.text]
elem.text = ""
for k in list(elem):
if k.tag in spantags:
dupe = k.duplicate()
alltxt.append(dupe)
alltxt.append(k.tail)
k.tail = ""
k.delete()
lstspan = None
for t in alltxt:
if t is None:
pass
elif isinstance(t, str):
chks = []
sind = 0
for i in range(1, len(t)):
# split into chunks based on whether unicode or not
if nonletter(t[i - 1]) != nonletter(t[i]):
chks.append(t[sind:i])
sind = i
chks.append(t[sind:])
sty = "baseline-shift:0%;"
for chk in chks:
if any(nonletter(c) for c in chk):
chk = chk.replace(" ", "\u00a0")
# spaces can disappear, replace with NBSP
if elem.croot is not None:
nts = Tspan()
elem.append(nts)
nts.text = chk
nts.cstyle = Style(sty + "font-family:" + newfont)
nts.cspecified_style = None
nts.ccomposed_transform = None
lstspan = nts
else:
if lstspan is None:
elem.text = chk
else:
lstspan.tail = chk
elif t.tag in spantags:
replace_non_ascii_font(t, newfont, True)
elem.append(t)
t.cspecified_style = None
t.ccomposed_transform = None
lstspan = t
# Inkscape automatically prunes empty text/tails
# Do the same so future parsing is not affected
if elem.tag == TEtag:
for ddv in elem.descendants2():
if ddv.text is not None and ddv.text == "":
ddv.text = None
if ddv.tail is not None and ddv.tail == "":
ddv.tail = None
def split_text(elem):
"""
Splits a text or tspan into its constituent blocks of text
(i.e., each text and each tail in separate hierarchies)
"""
dups = []
dds = elem.descendants2()
for dgen in reversed(list(TextTree(elem).dgenerator())):
_, _, _, sel, txt = dgen
if txt is not None:
# For each block of text, spin off a copy of the structure
# that only has this block and only the needed ancestors.
dup = elem.duplicate()
d2s = dup.descendants2()
mydup = d2s[dds.index(sel)]
ancs = mydup.ancestors2(includeme=True)
for dd2 in d2s:
dd2.text = None
dd2.tail = None
if dd2 not in ancs:
dd2.delete()
mydup.text = txt
dups = [dup] + dups
if len(dups) > 0 and elem.tail is not None:
dups[-1].tail = elem.tail
elem.delete()
return dups
def Version_Check(caller):
siv = "v1.4.23" # Scientific Inkscape version
maxsupport = "1.4.2"
minsupport = "1.1.0"
logname = "Log.txt"
NFORM = 200
maxsupp = inkex.vparse(maxsupport)
minsupp = inkex.vparse(minsupport)
try:
f = open(logname, "r")
d = f.readlines()
f.close()
except:
d = []
displayedform = False
if len(d) > 0:
displayedform = d[-1] == "Displayed form screen"
if displayedform:
d = d[: len(d) - 1]
# idebug(ivp)
prevvp = [inkex.vparse(dv[-6:]) for dv in d]
if (inkex.ivp[0] < minsupp[0] or inkex.ivp[1] < minsupp[1]) and not (
inkex.ivp in prevvp
):
msg = (
"For best results, Scientific Inkscape requires Inkscape version "
+ minsupport
+ " or higher. "
+ "You are running an older version—all features may not work as expected.\n\nThis is a one-time message.\n\n"
)
inkex.utils.errormsg(msg)
if (inkex.ivp[0] > maxsupp[0] or inkex.ivp[1] > maxsupp[1]) and not (
inkex.ivp in prevvp
):
msg = (
"For best results, Scientific Inkscape requires Inkscape version "
+ maxsupport
+ " or lower. "
+ "You are running a newer version—you must be from the future!\n\n"
+ "It might work, it might not. Check if there is a more recent version of Scientific Inkscape available. \n\nThis is a one-time message.\n\n"
)
inkex.utils.errormsg(msg)
from datetime import datetime
dt = datetime.now().strftime("%Y.%m.%d, %H:%M:%S")
d.append(
dt
+ " Running "
+ caller
+ " "
+ siv
+ ", Inkscape v"
+ inkex.__version__
+ "\n"
)
if len(d) > NFORM:
d = d[-NFORM:]
if not (displayedform):
sif3 = "dt9mt3Br6"
sif1 = "https://forms.gle/"
sif2 = "RS6HythP"
msg = (
f"You have run Scientific Inkscape extensions over {NFORM} times! Thank you for being such a dedicated user!"
"\n\nBuilding and maintaining Scientific Inkscape is a time-consuming job,"
" and I have no real way of tracking the number of active users. For reporting purposes, I would greatly "
"appreciate it if you could sign my guestbook to indicate that you use Scientific Inkscape. "
f"It is located at\n\n{sif1}{sif2}{sif3}"
"\n\nPlease note that this is a one-time message. "
"You will never get this message again, so please copy the URL before you click OK.\n\n"
)
inkex.utils.errormsg(msg)
d.append("Displayed form screen")
try:
f = open(logname, "w")
f.write("".join(d))
f.close()
except:
err_msg = (
"Error: You do not have write access to the directory where the Scientific Inkscape "
"extensions are installed. You may have not installed them in the correct location. "
"\n\nMake sure you install them in the User Extensions directory, not the Inkscape Extensions "
"directory."
)
inkex.utils.errormsg(err_msg)
quit()