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

638 lines
26 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 Transform
import math, copy
from dhelpers import bbox
from inkex.text.utils import uniquetol
It = Transform([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
# Define global tags
PATHLIKE_TAGS = [
inkex.PathElement.ctag,
inkex.Rectangle.ctag,
inkex.Line.ctag,
inkex.Polyline.ctag,
]
RECTANGLE_TAG = inkex.Rectangle.ctag
TEXTLIKE_TAGS = [inkex.TextElement.ctag, inkex.FlowRoot.ctag]
GROUP_TAG = inkex.Group.ctag
EXCLUDE_TAGS = [
inkex.Tspan.ctag,
inkex.NamedView.ctag,
inkex.Defs.ctag,
inkex.Metadata.ctag,
inkex.ForeignObject.ctag,
]
def geometric_bbox(el, vis_bbox, irange=None):
gbb = copy.copy(vis_bbox)
if el.tag in PATHLIKE_TAGS: # if path-like, use nodes instead
xs, ys = dh.get_points(el, irange=irange)
# For clipped objects the list of points is a bad description of the
# geometric bounding box. As a rough workaround, use the visual bbox if
# its limits are smaller than the geometric bbox. I think this is almost
# always fine since clips use the geometric bbox.
minx = max(min(xs), vis_bbox[0])
maxx = min(max(xs), vis_bbox[0] + vis_bbox[2])
miny = max(min(ys), vis_bbox[1])
maxy = min(max(ys), vis_bbox[1] + vis_bbox[3])
gbb = [minx, miny, maxx - minx, maxy - miny] # geometric bounding box
return bbox(gbb)
# Determines plot area from a list of elements and their geometric bounding boxes
def Find_Plot_Area(els, gbbs):
vl = dict() # vertical lines
hl = dict() # horizontal lines
boxes = dict()
solids = dict()
plotareas = dict()
for el in list(reversed(els)):
isrect = False
if el.tag in PATHLIKE_TAGS:
gbb = gbbs[el.get_id()]
xs, ys = dh.get_points(el)
if (max(xs) - min(xs)) < 0.001 * gbb[3]:
vl[el.get_id()] = gbb
if (max(ys) - min(ys)) < 0.001 * gbb[2]:
hl[el.get_id()] = gbb
tol = 1e-3 * max(max(xs) - min(xs), max(ys) - min(ys))
if (
3 <= len(xs) <= 5
and len(uniquetol(xs, tol)) == 2
and len(uniquetol(ys, tol)) == 2
):
isrect = True
if isrect or el.tag == RECTANGLE_TAG:
sf = dh.get_strokefill(el)
hasfill = sf.fill is not None and sf.fill != [255, 255, 255, 1]
hasstroke = sf.stroke is not None and sf.stroke != [255, 255, 255, 1]
if hasfill and (not (hasstroke) or sf.stroke == sf.fill): # solid rectangle
solids[el.get_id()] = gbb
elif hasstroke: # framed rectangle
boxes[el.get_id()] = gbb
if el.get("inkscape-scientific-scaletype") == "plot_area":
plotareas[el.get_id()] = gbb
vels = dict()
hels = dict()
for k, gbb in vl.items():
vels[k] = gbb[3]
for k, gbb in hl.items():
hels[k] = gbb[2]
for k, gbb in boxes.items():
hels[k] = gbb[2]
vels[k] = gbb[3]
for k, gbb in plotareas.items():
hels[k] = gbb[2]
vels[k] = gbb[3]
lvel = lhel = None
if len(vels) != 0:
lvel = max(vels, key=vels.get) # largest vertical
if len(hels) != 0:
lhel = max(hels, key=hels.get) # largest horizontal
return vl, hl, lvel, lhel
# Get the proper suffix for an integer (1st, 2nd, 3rd, etc.)
def appendInt(num):
if num > 9:
secondToLastDigit = str(num)[-2]
if secondToLastDigit == "1":
return "th"
lastDigit = num % 10
if lastDigit == 1:
return "st"
elif lastDigit == 2:
return "nd"
elif lastDigit == 3:
return "rd"
else:
return "th"
def trtf(x, y):
return Transform("translate(" + str(x) + ", " + str(y) + ")")
def sctf(x, y):
return Transform("scale(" + str(x) + ", " + str(y) + ")")
class ScalePlots(inkex.EffectExtension):
def add_arguments(self, pars):
pars.add_argument(
"--hscale", type=float, default=100, help="Horizontal scaling"
)
pars.add_argument("--vscale", type=float, default=100, help="Vertical scaling")
pars.add_argument(
"--figuremode", type=int, default=1, help="Scale by bounding box?"
)
pars.add_argument("--matchprop", type=int, default=1, help="Match what?")
pars.add_argument(
"--hmatchopts", type=int, default=1, help="Horizontal matching"
)
pars.add_argument("--vmatchopts", type=int, default=1, help="Vertical matching")
pars.add_argument(
"--deletematch",
type=inkex.Boolean,
default=False,
help="Delete first selection?",
)
pars.add_argument("--marksf", type=int, default=1, help="Mark objects as")
pars.add_argument("--tab", help="The selected UI-tab when OK was pressed")
pars.add_argument(
"--tickcorrect", type=inkex.Boolean, default=True, help="Auto tick correct?"
)
pars.add_argument(
"--tickthreshold", type=int, default=10, help="Tick threshold"
)
pars.add_argument(
"--wholeplot1",
type=inkex.Boolean,
default=False,
help="Treat whole selection as plot area?",
)
pars.add_argument(
"--wholeplot2",
type=inkex.Boolean,
default=False,
help="Treat whole selection as plot area?",
)
pars.add_argument(
"--wholeplot3",
type=inkex.Boolean,
default=False,
help="Treat whole selection as plot area?",
)
def effect(self):
sel = [self.svg.selection[ii] for ii in range(len(self.svg.selection))]
sel = [k for k in sel if k.tag not in EXCLUDE_TAGS]
# regular selectable objects only
itag = inkex.Image.ctag
if all([el.tag == itag for el in sel]) and self.options.tab != "options":
inkex.utils.errormsg(
"Thanks for using Scientific Inkscape!\n\n"
"It appears that you're attempting to scale a raster Image object. Please note that "
"Inkscape is mainly for working with vector images, not raster images. Vector images "
"preserve all of the information used to generate them, whereas raster images do not. "
"Read about the difference here: \nhttps://en.wikipedia.org/wiki/Vector_graphics\n\n"
"Unfortunately, this means that there is not much Scale Plots can do to edit raster images "
"beyond simple stretching or scaling. If you want to edit a raster image, you will need to "
"use a program like Photoshop or GIMP."
)
quit()
tickcorrect = self.options.tickcorrect
tickthr = self.options.tickthreshold / 100
# layerfix = self.options.layerfix
if self.options.tab == "scaling":
hscale = self.options.hscale
vscale = self.options.vscale
scalex = hscale / 100
scaley = vscale / 100
wholesel = self.options.wholeplot1
elif self.options.tab == "matching":
hmatch = self.options.hmatchopts in [2, 3]
vmatch = self.options.vmatchopts in [2, 3]
matchxpos = self.options.hmatchopts == 3
matchypos = self.options.vmatchopts == 3
wholesel = self.options.wholeplot2
elif self.options.tab == "correction":
scalex = 1
scaley = 1
wholesel = self.options.wholeplot3
if not all(k.tag == GROUP_TAG for k in sel):
inkex.utils.errormsg(
"Correction mode requires that every selected object be a group that has already been scaled."
)
return
else:
self.options.marksf = {
1: "scale_free",
2: "aspect_locked",
3: "normal",
4: "plot_area",
5: None,
}[self.options.marksf]
for el in sel:
el.set("inkscape-scientific-scaletype", self.options.marksf)
return
self.options.matchwhat = {1: "plotarea", 2: "bbox"}[self.options.matchprop]
self.options.matchto = {1: "plotarea", 2: "bbox"}[self.options.matchprop]
self.options.figuremode = {1: False, 2: True}[self.options.figuremode]
if wholesel:
tickcorrect = False
dsfels = [] # designated scale-free els, whether or not they're selected
# full visual bbs
fbbs = dh.BB2(self.svg, dh.unique([d for el in sel for d in el.descendants2()]))
firstsel = sel[0]
if self.options.tab == "matching":
sel = sel[1:]
all_pels = [sel]
if all(k.tag == GROUP_TAG for k in sel): # grouped mode
trs = [s.ctransform for s in sel]
# for correction mode
all_pels = [list(s) for s in sel]
for i0 in range(len(all_pels)): # sel in asel:
pels = [
k for k in all_pels[i0] if k.get_id() in list(fbbs.keys())
] # plot elements list
# Calculate geometric (tight) bounding boxes of plot elements
gbbs = dict()
for el in [firstsel] + pels + dsfels + list(firstsel):
if el.get_id() in fbbs:
gbbs[el.get_id()] = geometric_bbox(el, fbbs[el.get_id()]).sbb
vl, hl, lvel, lhel = Find_Plot_Area(pels, gbbs)
if lvel is None or lhel is None or wholesel:
noplotarea = True
lvel = None
lhel = None
if not (wholesel):
# Display warning message
numgroup = str(i0 + 1) + appendInt(i0 + 1)
inkex.utils.errormsg(
"A box-like plot area could not be automatically detected on the "
+ numgroup
+ " selected plot (group ID "
+ sel[i0].get_id()
+ ").\n\nDraw a box with a stroke to define the plot area or mark objects as plot area-determining in the Advanced tab."
+ "\nScaling will still be performed, but the results may not be ideal."
)
else:
noplotarea = False
# A class that contains geometric and full bounding boxes
class bbox2:
def __init__(self, g, f):
if isinstance(g, list) or g is None:
g = bbox(g)
if isinstance(f, list) or f is None:
f = bbox(f)
self.g = g
self.f = f
def union(self, g, f):
return bbox2(self.g.union(g), self.f.union(f))
bba = bbox2(None, None)
# all elements
bbp = bbox2(None, None)
# plot area
for el in pels:
bba = bba.union(gbbs[el.get_id()], fbbs[el.get_id()])
if el.get_id() in [lvel, lhel] or noplotarea:
bbp = bbp.union(gbbs[el.get_id()], fbbs[el.get_id()])
if self.options.tab == "correction":
# Invert the existing transform so we can run the rest of the code regularly
extr = trs[i0]
# existing transform
sx = math.sqrt(extr.a**2 + extr.b**2)
sy = (-extr.b * extr.c + extr.a * extr.d) / math.sqrt(
extr.a**2 + extr.b**2
)
if sx < 0:
sx = -sx
sy = -sy
scalex = sx
scaley = sy
# allow for rotations
if not (self.options.figuremode):
refx = bbp.g.xc
refy = bbp.g.yc
else:
refx = bba.f.x1
refy = bba.f.y1
trl = trtf(refx, refy)
scl = sctf(1 / scalex, 1 / scaley)
iextr = trl @ scl @ (-trl)
# invert existing transform
dh.global_transform(sel[i0], iextr)
# Invert the transform on the bounding boxes (fix later)
actfbbs = {k: v for k, v in fbbs.items()}
actgbbs = {k: v for k, v in gbbs.items()}
fbbs = {k: bbox(v).transform(iextr).sbb for k, v in fbbs.items()}
gbbs = {k: bbox(v).transform(iextr).sbb for k, v in gbbs.items()}
tr_bba = bba
# bb with transform to be corrected
bba = bbox2(None, None)
# bbox of all elements
bbp = bbox2(None, None)
# bbox of plot area
for el in pels:
bba = bba.union(gbbs[el.get_id()], fbbs[el.get_id()])
if el.get_id() in [lvel, lhel] or noplotarea:
bbp = bbp.union(gbbs[el.get_id()], fbbs[el.get_id()])
if self.options.figuremode:
oscalex = scalex
oscaley = scaley
scalex = (
(tr_bba.f.x2 - tr_bba.f.x1)
- (bba.f.x2 - bba.f.x1 - (bbp.g.x2 - bbp.g.x1))
) / (bbp.g.x2 - bbp.g.x1)
scaley = (
(tr_bba.f.y2 - tr_bba.f.y1)
- (bba.f.y2 - bba.f.y1 - (bbp.g.y2 - bbp.g.y1))
) / (bbp.g.y2 - bbp.g.y1)
tlx = (tr_bba.f.x1 - refx) / oscalex + refx
# where top left is now
dxl = bbp.g.x1 - tlx
if scalex != 1:
refx = (tr_bba.f.x1 + dxl - bbp.g.x1 * scalex) / (1 - scalex)
# what refx needs to be to maintain top-left
else:
refx = tr_bba.f.x1 + dxl
tly = (tr_bba.f.y1 - refy) / oscaley + refy
# where top left is now
dyl = bbp.g.y1 - tly
if scaley != 1:
refy = (tr_bba.f.y1 + dyl - bbp.g.y1 * scaley) / (1 - scaley)
# what refx needs to be to maintain top-left
else:
refy = tr_bba.f.y1 + dyl
if self.options.tab == "matching":
bbmatch = None
if self.options.matchto == "bbox":
bbmatch = bbox(gbbs[firstsel.get_id()])
elif self.options.matchto == "plotarea":
if firstsel.tag == GROUP_TAG:
plotareaels = list(firstsel)
else:
plotareaels = [firstsel]
vl0, hl0, lvel0, lhel0 = Find_Plot_Area(plotareaels, gbbs)
if lvel0 is None or lhel0 is None:
inkex.utils.errormsg(
"A box-like plot area could not be automatically detected on the "
+ "first selected object (ID "
+ firstsel.get_id()
+ ").\n\nIts bounding box will be matched instead. If this is not ideal,"
+ " draw an outlined box to define the plot area or mark objects as plot area-determining in the Advanced tab.\n"
)
bbmatch = bbox(gbbs[firstsel.get_id()])
else:
bbmatch = bbox(gbbs[lvel0]).union(bbox(gbbs[lhel0]))
scalex = scaley = 1
if hmatch:
if self.options.matchwhat == "plotarea":
scalex = bbmatch.w / bbp.g.w
elif self.options.matchwhat == "bbox":
scalex = (bbmatch.w + bbp.g.w - bba.g.w) / bbp.g.w
if vmatch:
if self.options.matchwhat == "plotarea":
scaley = bbmatch.h / bbp.g.h
elif self.options.matchwhat == "bbox":
scaley = (bbmatch.h + bbp.g.h - bba.g.h) / bbp.g.h
# Compute global transformation
if self.options.tab != "correction":
if self.options.matchwhat == 'plotarea':
refx = bbp.g.xc
refy = bbp.g.yc
else:
refx = bba.g.xc
refy = bba.g.yc
finx = refx # final x
finy = refy # final y
if self.options.tab == "matching":
if matchxpos:
finx = bbmatch.xc
if matchypos:
finy = bbmatch.yc
if self.options.matchwhat == "bbox":
# Following scaling, margins stay the same size so the
# bounding box center has moved
finx -= 1/2*( (bba.g.x2 - bbp.g.x2) - (bbp.g.x1 - bba.g.x1)) * (1-scalex)
finy -= 1/2*( (bba.g.y2 - bbp.g.y2) - (bbp.g.y1 - bba.g.y1)) * (1-scaley)
gtr = trtf(finx, finy) @ sctf(scalex, scaley) @ (-trtf(refx, refy))
# global transformation
iscl = sctf(1 / scalex, 1 / scaley) # inverse scale
liscl = (
sctf(math.sqrt(scalex * scaley), math.sqrt(scalex * scaley)) @ iscl
) # aspect-scaled and inverse scaled
trul = gtr.apply_to_point([bbp.g.x1, bbp.g.y1]) # transformed upper-left
trbr = gtr.apply_to_point([bbp.g.x2, bbp.g.y2]) # transformed bottom-right
# Diagnostic mode
diagmode = False
if diagmode:
r = inkex.Rectangle()
r.set("x", bbp.g.x1)
r.set("y", bbp.g.y1)
r.set("width", abs(bbp.g.x2 - bbp.g.x1))
r.set("height", abs(bbp.g.y2 - bbp.g.y1))
r.cstyle = "fill-opacity:0.5"
self.svg.append(r)
dh.global_transform(r, gtr)
dh.debug("Largest vertical line: " + lvel)
dh.debug("Largest horizontal line: " + lhel)
# Make a list of elements to be transformed
sclels = []
for el in pels:
if el not in sclels:
sclels.append(el)
# Apply transform and compute corrections (if needed)
for el in sclels:
dh.global_transform(el, gtr)
# apply the transform
elid = el.get_id()
gbb = gbbs[elid]
fbb = fbbs[elid]
if el.tag in TEXTLIKE_TAGS + [GROUP_TAG] or el in dsfels:
stype = "scale_free"
else:
stype = "normal"
mtype = el.get("inkscape-scientific-scaletype")
if mtype is not None:
stype = mtype
if hasattr(self.options, "hcall") and self.options.hcall:
if el.tag in TEXTLIKE_TAGS + [GROUP_TAG]:
stype = "normal"
vtickt = vtickb = htickl = htickr = False
# el is a tick
if tickcorrect and (
(elid in list(vl.keys())) or (elid in list(hl.keys()))
):
isvert = elid in list(vl.keys())
ishorz = elid in list(hl.keys())
gbb = gbbs[elid]
if isvert and gbb[3] < tickthr * (
bbp.g.y2 - bbp.g.y1
): # vertical tick
if gbb[1] + gbb[3] < bbp.g.y1 + tickthr * (bbp.g.y2 - bbp.g.y1):
vtickt = True
elif gbb[1] > bbp.g.y2 - tickthr * (bbp.g.y2 - bbp.g.y1):
vtickb = True
if ishorz and gbb[2] < tickthr * (
bbp.g.x2 - bbp.g.x1
): # horizontal tick
if gbb[0] + gbb[2] < bbp.g.x1 + tickthr * (bbp.g.x2 - bbp.g.x1):
htickl = True
elif gbb[0] > bbp.g.x2 - tickthr * (bbp.g.x2 - bbp.g.x1):
htickr = True
if any([vtickt, vtickb, htickl, htickr]):
# If a tick, scale using the edge as a reference point
gbb_tr = bbox(gbb).transform(gtr)
cx = gbb_tr.xc
cy = gbb_tr.yc
if vtickt:
if cy > trul[1]:
trl = trtf(cx, gbb_tr.y1) # inner tick
else:
trl = trtf(cx, gbb_tr.y2) # outer tick
elif vtickb:
if cy < trbr[1]:
trl = trtf(cx, gbb_tr.y2) # inner tick
else:
trl = trtf(cx, gbb_tr.y1) # outer tick
elif htickl:
if cx > trul[0]:
trl = trtf(gbb_tr.x1, cy) # inner tick
else:
trl = trtf(gbb_tr.x2, cy) # outer tick
elif htickr:
if cx < trbr[0]:
trl = trtf(gbb_tr.x2, cy) # inner tick
else:
trl = trtf(gbb_tr.x1, cy) # outer tick
tr1 = trl @ iscl @ (-trl)
dh.global_transform(el, tr1)
# elif isalwayscorr or isoutsideplot or issf:
elif stype in ["scale_free", "aspect_locked"]:
# dh.idebug(el.get_id())
# Invert the transformation for text/groups, anything outside the plot, scale-free
cbc = el.get("inkscape-scientific-combined-by-color")
if cbc is None:
gbb_tr = bbox(gbb).transform(gtr)
cx = gbb_tr.xc
cy = gbb_tr.yc
trl = trtf(cx, cy)
if stype == "scale_free":
tr1 = trl @ iscl @ (-trl)
else:
tr1 = trl @ liscl @ (-trl)
# For elements outside the plot area, adjust position to maintain
# the distance to the plot area
dx = 0
dy = 0
if cx < trul[0]:
ox = gbb[0] + gbb[2] / 2 - bbp.g.x1
dx = ox - (cx - trul[0])
if cx > trbr[0]:
ox = gbb[0] + gbb[2] / 2 - bbp.g.x2
dx = ox - (cx - trbr[0])
if cy < trul[1]:
oy = gbb[1] + gbb[3] / 2 - bbp.g.y1
dy = oy - (cy - trul[1])
if cy > trbr[1]:
oy = gbb[1] + gbb[3] / 2 - bbp.g.y2
dy = oy - (cy - trbr[1])
tr2 = trtf(dx, dy)
dh.global_transform(el, (tr2 @ tr1))
else: # If previously combined, apply to subpaths instead
cbc = [int(v) for v in cbc.split()]
fbb_tr = bbox(fbb).transform(gtr).sbb
irng = []
trng = []
for ii in range(len(cbc) - 1):
gbb_tr = geometric_bbox(
el, fbb_tr, irange=[cbc[ii], cbc[ii + 1]]
)
gbb = gbb_tr.transform(-gtr).sbb
cx = gbb_tr.xc
cy = gbb_tr.yc
trl = trtf(cx, cy)
# tr1 = trl @ iscl @ (-trl)
if stype == "scale_free":
tr1 = trl @ iscl @ (-trl)
else:
tr1 = trl @ liscl @ (-trl)
dx = 0
dy = 0
if cx < trul[0]:
ox = gbb[0] + gbb[2] / 2 - bbp.g.x1
dx = ox - (cx - trul[0])
if cx > trbr[0]:
ox = gbb[0] + gbb[2] / 2 - bbp.g.x2
dx = ox - (cx - trbr[0])
if cy < trul[1]:
oy = gbb[1] + gbb[3] / 2 - bbp.g.y1
dy = oy - (cy - trul[1])
if cy > trbr[1]:
oy = gbb[1] + gbb[3] / 2 - bbp.g.y2
dy = oy - (cy - trbr[1])
tr2 = trtf(dx, dy)
irng.append([cbc[ii], cbc[ii + 1]])
trng.append((tr2 @ tr1))
dh.global_transform(el, It, irange=irng, trange=trng)
# restore bbs
if self.options.tab == "correction":
fbbs = actfbbs
gbbs = actgbbs
if self.options.tab == "matching" and self.options.deletematch:
firstsel.delete()
if __name__ == "__main__":
dh.Run_SI_Extension(ScalePlots(), "Scale plots")