#!/usr/bin/env python # coding=utf-8 # # Copyright (c) 2023 David Burghoff # # Functions modified from Inkex were made by # Martin Owens # Sergei Izmailov # Thomas Holder # Jonathan Neuhauser # 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 = """ """ svgtxt = prefix + strin + "" 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()