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

538 lines
23 KiB
Python

#!/usr/bin/env python
# coding=utf-8
#
# Copyright (c) 2023 David Burghoff <burghoff@utexas.edu>
#
# 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.
#
import dhelpers as dh
import inkex
from inkex import (
TextElement,
FlowRoot,
FlowPara,
FlowRegion,
FlowSpan,
Tspan,
TextPath,
Rectangle,
PathElement,
Line,
StyleElement,
NamedView,
Defs,
Metadata,
ForeignObject,
Group,
)
from inkex.text.utils import isrectangle
import lxml
from remove_kerning import remove_kerning
class FlattenPlots(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument("--tab", help="The selected UI-tab when OK was pressed")
pars.add_argument(
"--deepungroup", type=inkex.Boolean, default=True, help="Deep ungroup"
)
pars.add_argument(
"--fixtext", type=inkex.Boolean, default=True, help="Text fixes"
)
pars.add_argument(
"--removerectw",
type=inkex.Boolean,
default=True,
help="Remove white rectangles",
)
pars.add_argument(
"--splitdistant",
type=inkex.Boolean,
default=True,
help="Split distant text",
)
pars.add_argument(
"--mergenearby", type=inkex.Boolean, default=True, help="Merge nearby text"
)
pars.add_argument(
"--removemanualkerning",
type=inkex.Boolean,
default=True,
help="Fix text shattering",
)
pars.add_argument(
"--mergesubsuper",
type=inkex.Boolean,
default=True,
help="Import superscripts and subscripts",
)
pars.add_argument(
"--setreplacement",
type=inkex.Boolean,
default=False,
help="Replace missing fonts",
)
pars.add_argument(
"--reversions",
type=inkex.Boolean,
default=True,
help="Revert known paths to text",
)
pars.add_argument(
"--revertpaths",
type=inkex.Boolean,
default=True,
help="Revert certain paths to strokes?",
)
pars.add_argument(
"--removeduppaths",
type=inkex.Boolean,
default=True,
help="Delete overlapping duplicate paths?",
)
pars.add_argument(
"--removetextclips",
type=inkex.Boolean,
default=True,
help="Remove clips and masks from text?",
)
pars.add_argument(
"--replacement", type=str, default="Arial", help="Missing font replacement"
)
pars.add_argument(
"--justification", type=int, default=1, help="Text justification"
)
pars.add_argument("--markexc", type=int, default=1, help="Exclude objects")
pars.add_argument(
"--testmode", type=inkex.Boolean, default=False, help="Test mode"
)
pars.add_argument("--v", type=str, default="1.2", help="Version for debugging")
pars.add_argument(
"--debugparser",
type=inkex.Boolean,
default=False,
help="Use parser debugger?",
)
def duplicate_layer1(self):
# For testing, duplicate selection and flatten its elements
sel = [self.svg.selection[ii] for ii in range(len(self.svg.selection))]
# should work with both v1.0 and v1.1
for el in sel:
d = el.duplicate()
el.getparent().insert(list(el.getparent()).index(el), d)
if d.get("inkscape:label") is not None:
el.set("inkscape:label", el.get("inkscape:label") + " flat")
d.set("inkscape:label", d.get("inkscape:label") + " original")
d.set("sodipodi:insensitive", "true")
# lock original
d.set("opacity", 0.3)
sel = [list(el) for el in sel]
import itertools
sel = list(itertools.chain.from_iterable(sel))
return sel
def effect(self):
if self.options.testmode:
if not hasattr(self.options, "enabled_profile"):
self.options.enabled_profile = True
self.options.lyr1 = self.duplicate_layer1()
dh.ctic()
self.effect()
dh.ctoc()
return
else:
sel = self.options.lyr1
self.options.deepungroup = True
self.options.fixtext = True
self.options.removerectw = True
self.options.revertpaths = True
self.options.splitdistant = True
self.options.mergenearby = True
self.options.removemanualkerning = True
self.options.mergesubsuper = True
self.options.setreplacement = True
self.options.reversions = True
self.options.removetextclips = True
self.options.replacement = "sans-serif"
self.options.justification = 1
else:
sel = [self.svg.selection[ii] for ii in range(len(self.svg.selection))]
splitdistant = self.options.splitdistant and self.options.fixtext
removemanualkerning = self.options.removemanualkerning and self.options.fixtext
mergesubsuper = self.options.mergesubsuper and self.options.fixtext
mergenearby = self.options.mergenearby and self.options.fixtext
setreplacement = self.options.setreplacement and self.options.fixtext
reversions = self.options.reversions and self.options.fixtext
removetextclips = self.options.removetextclips and self.options.fixtext
sel = [el for el in self.svg.descendants2() if el in sel] # doc order
if self.options.tab == "Exclusions":
self.options.markexc = {1: True, 2: False}[self.options.markexc]
for el in sel:
if self.options.markexc:
el.set("inkscape-scientific-flattenexclude", self.options.markexc)
else:
el.set("inkscape-scientific-flattenexclude", None)
return
for el in sel:
if el.get("inkscape-scientific-flattenexclude"):
sel.remove(el)
seld = [v for el in sel for v in el.descendants2()]
for el in seld:
if el.get("inkscape-scientific-flattenexclude"):
seld.remove(el)
# Move selected defs/clips/mask into global defs
defstag = inkex.Defs.ctag
clipmask = {inkex.addNS("mask", "svg"), inkex.ClipPath.ctag}
if self.options.deepungroup:
seldefs = [el for el in seld if el.tag == defstag]
for el in seldefs:
self.svg.cdefs.append(el)
for d in el.descendants2():
if d in seld:
seld.remove(d) # no longer selected
selcm = [el for el in seld if el.tag in clipmask]
for el in selcm:
self.svg.cdefs.append(el)
for d in el.descendants2():
if d in seld:
seld.remove(d) # no longer selected
gtag = inkex.Group.ctag
gigtags = dh.tags((NamedView, Defs, Metadata, ForeignObject) + (Group,))
gs = [el for el in seld if el.tag == gtag]
ngs = [el for el in seld if el.tag not in gigtags]
if len(gs) == 0 and len(ngs) == 0:
inkex.utils.errormsg("No objects selected!")
return
if self.options.deepungroup:
# Unlink all clones
nels = []
oels = []
for el in seld:
if isinstance(el, inkex.Use):
useel = el.get_link("xlink:href")
if useel is not None and not (isinstance(useel, (inkex.Symbol))):
ul = dh.unlink2(el)
nels.append(ul)
oels.append(el)
for nel in nels:
seld += nel.descendants2()
for oel in oels:
seld.remove(oel)
gs = [el for el in seld if el.tag == gtag]
ngs = [el for el in seld if el.tag not in gigtags]
commenttag = lxml.etree.Comment
commentdefs = {commenttag, defstag}
sorted_gs = sorted(gs, key=lambda group: len(list(group)))
# ascending order of size to reduce number of calls
for g in sorted_gs:
ks = g.getchildren()
if any([k.tag == commenttag for k in ks]) and all(
[
k.tag in commentdefs or dh.EBget(k, "unlinked_clone") == "True"
for k in ks
]
):
# Leave Matplotlib text glyphs grouped together
cmnt = ";".join(
[
str(k).strip("<!-- ").strip(" -->")
for k in ks
if k.tag == commenttag
]
)
g.set("mpl_comment", cmnt)
[g.remove(k) for k in ks if isinstance(k, lxml.etree._Comment)]
# remove comment, but leave grouped
elif dh.EBget(g, "mpl_comment") is not None:
pass
else:
dh.ungroup(g, removetextclips)
# dh.flush_stylesheet_entries(self.svg)
prltag = dh.tags((PathElement, Rectangle, Line))
if self.options.removerectw or reversions or self.options.revertpaths:
from inkex.text.utils import default_style_atts as dsa
fltag = dh.tags((FlowPara, FlowRegion, FlowRoot))
nones = {None, "none"}
wrects = []
minusp = inkex.Path(
"M 106,355 H 732 V 272 H 106 Z"
) # Matplotlib minus sign
RECT_THRESHOLD = 2.49 # threshold ratio for rectangle reversion
for ii, el in enumerate(ngs):
if el.tag in prltag:
myp = el.getparent()
if (
myp is not None
and not (myp.tag in fltag)
and isrectangle(el, includingtransform=False)
):
sty = el.cspecified_style
strk = sty.get("stroke", dsa.get("stroke"))
fill = sty.get("fill", dsa.get("fill"))
if strk in nones and fill not in nones:
sf = dh.get_strokefill(el)
if sf.fill is not None and tuple(sf.fill) == (
255,
255,
255,
1,
):
wrects.append(el)
if reversions:
dv = el.get("d")
if (
dv is not None
and inkex.Path(dv)[0:3] == minusp[0:3]
):
t0 = el.ccomposed_transform
if t0.a * t0.d - t0.b * t0.c < 0:
bb = dh.bounding_box2(
el, includestroke=False, dotransform=False
)
trl = inkex.Transform(
"translate({0},{1})".format(bb.xc, bb.yc)
)
scl = inkex.Transform("scale(1,-1)")
t0 = t0 @ trl @ scl @ (-trl)
nt = inkex.TextElement()
nt.set("id", el.get_id())
myi = list(myp).index(el)
el.delete()
myp.insert(myi, nt)
nt.text = chr(0x2212) # minus sign
nt.ctransform = (-myp.ccomposed_transform) @ t0
nt.set("x", str(19.3964))
nt.set("y", str(626.924))
nt.cstyle = "font-size:999.997; font-family:sans-serif; fill:{0};".format(
sf.fill
)
ngs.append(nt)
ngs.remove(el)
if self.options.revertpaths:
bb = dh.bounding_box2(
el, includestroke=False, dotransform=False, includeclipmask=False
)
if not bb.isnull:
if bb.w < bb.h / RECT_THRESHOLD and not sf.fill_isurl:
el.object_to_path()
npv = "m {0},{1} v {2}".format(bb.xc, bb.y1, bb.h)
el.set("d", npv)
el.cstyle["stroke"] = sf.fill.to_rgb()
if sf.fill.alpha != 1.0:
el.cstyle["stroke-opacity"] = sf.fill.alpha
el.cstyle["opacity"] = 1
el.cstyle["fill"] = "none"
el.cstyle["stroke-width"] = str(bb.w)
el.cstyle["stroke-linecap"] = "butt"
elif bb.h < bb.w / RECT_THRESHOLD and not sf.fill_isurl:
el.object_to_path()
npv = "m {0},{1} h {2}".format(bb.x1, bb.yc, bb.w)
el.set("d", npv)
el.cstyle["stroke"] = sf.fill.to_rgb()
if sf.fill.alpha != 1.0:
el.cstyle["stroke-opacity"] = sf.fill.alpha
el.cstyle["opacity"] = 1
el.cstyle["fill"] = "none"
el.cstyle["stroke-width"] = str(bb.h)
el.cstyle["stroke-linecap"] = "butt"
TE_TAG = TextElement.ctag;
if self.options.fixtext:
if setreplacement:
repl = self.options.replacement
ttags = dh.tags((TextElement, Tspan))
for el in ngs:
if el.tag in ttags and el.getparent() is not None:
# textelements not deleted
ff = el.cspecified_style.get("font-family")
el.cstyle["-inkscape-font-specification"] = None
if ff == None or ff == "none" or ff == "":
el.cstyle["font-family"] = repl
elif ff == repl:
pass
else:
ff = [
x.strip("'").strip('"').strip() for x in ff.split(",")
]
if not (ff[-1].lower() == repl.lower()):
ff.append(repl)
el.cstyle["font-family"] = ",".join(ff)
if removemanualkerning or mergesubsuper or splitdistant or mergenearby:
jdict = {1: "middle", 2: "start", 3: "end", 4: None}
justification = jdict[self.options.justification]
ngs = remove_kerning(
ngs,
removemanualkerning,
mergesubsuper,
splitdistant,
mergenearby,
justification,
self.options.debugparser,
)
if removetextclips:
for el in ngs:
if el.tag in dh.ttags:
el.set("clip-path", None)
el.set("mask", None)
if self.options.removerectw or self.options.removeduppaths:
ngset = set(ngs)
ngs2 = [
el for el in self.svg.descendants2() if el in ngset and dh.isdrawn(el)
]
bbs = dh.BB2(self.svg, ngs2, roughpath=True, parsed=True)
if self.options.removeduppaths:
# Prune identical overlapping paths
bbsp = {el:bbs[el.get_id()] for el in ngs2 if el.get_id() in bbs}
txts = [el for el in self.svg.descendants2() if el.tag==TE_TAG and 'shape-inside' in el.cspecified_style]
txt_inside = [el.cspecified_style.get_link("shape-inside",el.croot) for el in txts]
bbsp = {el:v for el,v in bbsp.items() if el.tag in prltag and el not in txt_inside}
els = list(bbsp.keys())
sfs = [None]*len(els)
import numpy as np
if len(els)>0:
bbox_values = np.array(list(bbsp.values()))
left = bbox_values[:, 0]
top = bbox_values[:, 1]
width = bbox_values[:, 2]
height = bbox_values[:, 3]
right = left + width
bottom = top + height
size = np.maximum(width, height)
is_empty = (width == 0) | (height == 0)
left_diff = np.abs(left[:, np.newaxis] - left[np.newaxis, :])
top_diff = np.abs(top[:, np.newaxis] - top[np.newaxis, :])
right_diff = np.abs(right[:, np.newaxis] - right[np.newaxis, :])
bottom_diff = np.abs(bottom[:, np.newaxis] - bottom[np.newaxis, :])
size_i = size[:, np.newaxis]
size_j = size[np.newaxis, :]
size_max = np.maximum(size_i, size_j)
tol = 1e-6 * size_max
is_empty_pair = is_empty[:, np.newaxis] | is_empty[np.newaxis, :]
equal = (~is_empty_pair) & \
(left_diff <= tol) & \
(top_diff <= tol) & \
(right_diff <= tol) & \
(bottom_diff <= tol)
else:
equal = np.zeros((0, 0), dtype=bool)
for jj in reversed(range(len(els))):
for ii in range(jj):
if equal[ii,jj]:
for kk in [ii,jj]:
if sfs[kk] is None:
sfs[kk] = dh.get_strokefill(els[kk])
mysf = sfs[jj]
othsf = sfs[ii]
if mysf.stroke is None and mysf.fill is None:
continue
if mysf.stroke is not None:
if mysf.stroke.alpha != 1.0 or othsf.stroke is None:
continue
if not mysf.stroke == othsf.stroke:
continue
if mysf.fill is not None:
if mysf.fill.alpha != 1.0 or othsf.fill is None:
continue
if not mysf.fill == othsf.fill:
continue
if not els[jj].cspecified_style == els[ii].cspecified_style:
continue
mypth = els[jj].cpath.transform(els[jj].ccomposed_transform).to_absolute();
othpth= els[ii].cpath.transform(els[ii].ccomposed_transform).to_absolute();
if mypth!=othpth and mypth!=othpth.reverse():
continue
# dh.idebug(els[ii].get_id())
els[ii].delete(deleteup=True)
equal[ii,:] = False
ngs2.remove(els[ii])
if self.options.removerectw:
ngs3 = [el for el in ngs2 if el.get_id() in bbs]
bbs3 = [dh.bbox(bbs.get(el.get_id())) for el in ngs3]
wriis = [ii for ii, el in enumerate(ngs3) if el in wrects]
wrbbs = [bbs3[ii] for ii in wriis]
intrscts = dh.bb_intersects(bbs3, wrbbs)
for jj, ii in enumerate(wriis):
if not any(intrscts[:ii, jj]):
ngs3[ii].delete(deleteup=True)
intrscts[ii, :] = False
ngs2.remove(ngs3[ii])
# Remove any unused clips we made, unnecessary white space in document
ds = self.svg.iddict.descendants
clips = [dh.EBget(el, "clip-path") for el in ds]
masks = [dh.EBget(el, "mask") for el in ds]
clips = [url[5:-1] for url in clips if url is not None]
masks = [url[5:-1] for url in masks if url is not None]
ctag = inkex.ClipPath.ctag
if hasattr(self.svg, "newclips"):
for el in self.svg.newclips:
if el.tag == ctag and not (el.get_id() in clips):
el.delete(deleteup=True)
elif dh.isMask(el) and not (el.get_id() in masks):
el.delete(deleteup=True)
ttags = dh.tags((Tspan, TextPath, FlowPara, FlowRegion, FlowSpan))
ttags2 = dh.tags(
(
StyleElement,
TextElement,
Tspan,
TextPath,
inkex.FlowRoot,
inkex.FlowPara,
inkex.FlowRegion,
inkex.FlowSpan,
)
)
for el in reversed(ds):
if not (el.tag in ttags):
if el.tail is not None:
el.tail = None
if not (el.tag in ttags2):
if el.text is not None:
el.text = None
if __name__ == "__main__":
dh.Run_SI_Extension(FlattenPlots(), "Flattener")