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

1949 lines
No EOL
77 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.
# pylint: disable=import-error
"""
AutoExporter for Inkscape
This module provides an extension for Inkscape to automate the export of SVG files
into multiple formats. It supports watching directories, exporting immediately,
and offers various configuration options for exporting to PDF, PNG, EMF, EPS,
and plain SVG. The module handles preprocessing steps to ensure compatibility
and quality of exported files, such as embedding linked images, fixing markers,
scaling text, and more.
Main Features:
- Watches specified directories for changes and exports files automatically.
- Supports exporting to multiple formats with various options.
- Handles preprocessing of SVG files to fix common issues and improve compatibility.
- Provides postprocessing to clean up and adjust SVGs for better rendering in
different environments.
- Includes utility functions for handling subprocesses, file manipulations,
and SVG operations.
"""
import os
import sys
import time
import re
import copy
import subprocess
import warnings
import pickle
import math
import shutil
import tempfile
import hashlib
import lxml
import random
import threading
import dhelpers as dh
import inkex
from inkex import TextElement, Transform, Vector2d
from inkex.text.utils import default_style_atts, unique
from inkex.text.cache import BaseElementCache
from inkex.text.parser import ParsedText, xyset
import numpy as np
import image_helpers as ih
otp_support_tags = BaseElementCache.otp_support_tags
peltag = inkex.PathElement.ctag
USE_TERMINAL = False
DEBUGGING = False
DISPPROFILE = False
MAXATTEMPTS = 2
MAX_THREADS = 10;
sema1 = threading.Semaphore(MAX_THREADS)
sema2 = threading.Semaphore(MAX_THREADS)
class AutoExporter(inkex.EffectExtension):
"""Automates exporting of SVG files in multiple formats."""
def add_arguments(self, pars): # pylint: disable=no-self-use
"""Add arguments to the parser."""
pars.add_argument("--tab", help="The selected UI-tab when OK was pressed")
pars.add_argument("--watchdir", help="Watch directory")
pars.add_argument("--writedir", help="Write directory")
pars.add_argument(
"--usepdf", type=inkex.Boolean, default=False, help="Export PDF?"
)
pars.add_argument(
"--usepng", type=inkex.Boolean, default=False, help="Export PNG?"
)
pars.add_argument(
"--useemf", type=inkex.Boolean, default=False, help="Export EMF?"
)
pars.add_argument(
"--useeps", type=inkex.Boolean, default=False, help="Export EPS?"
)
pars.add_argument(
"--usepsvg", type=inkex.Boolean, default=False, help="Export plain SVG?"
)
pars.add_argument("--dpi", default=600, help="Rasterization DPI")
# pars.add_argument("--dpi_im", default=300, help="Resampling DPI")
pars.add_argument(
"--imagemode2",
type=inkex.Boolean,
default=True,
help="Embedded image handling",
)
pars.add_argument(
"--thinline",
type=inkex.Boolean,
default=True,
help="Prevent thin line enhancement",
)
pars.add_argument(
"--texttopath", type=inkex.Boolean, default=False, help="Text to paths?"
)
pars.add_argument(
"--backingrect",
type=inkex.Boolean,
default=True,
help="Add backing rectangle?",
)
pars.add_argument(
"--stroketopath", type=inkex.Boolean, default=False, help="Stroke to paths?"
)
pars.add_argument(
"--latexpdf", type=inkex.Boolean, default=False, help="Make LaTeX PDF?"
)
pars.add_argument(
"--testmode", type=inkex.Boolean, default=False, help="Test mode?"
)
pars.add_argument("--testpage", type=int, default=1, help="Test mode page")
pars.add_argument("--v", type=str, default="1.2", help="Version for debugging")
pars.add_argument(
"--rasterizermode", type=int, default=1, help="Mark for rasterization"
)
pars.add_argument(
"--margin", type=float, default=0.5, help="Document margin (mm)"
)
pars.add_argument(
"--exportwhat", type=int, default=1, help="Export what?"
)
def effect(self):
"""Start the Autoexporter or initiate export now."""
if self.options.tab == "rasterizer":
sel = [self.svg.selection[i] for i in range(len(self.svg.selection))]
for elem in sel:
if self.options.rasterizermode == 1:
elem.set("autoexporter_rasterize", "png")
elif self.options.rasterizermode == 2:
elem.set("autoexporter_rasterize", "jpg")
elif self.options.rasterizermode == 3:
elem.set("autoexporter_rasterize", "topath")
else:
elem.set("autoexporter_rasterize", None)
return
self.options.exportnow = self.options.exportwhat==3
self.options.watchhere = self.options.exportwhat==2
# self.options.testmode = True;
if self.options.testmode:
self.options.usepsvg = True
self.options.thinline = True
self.options.imagemode2 = True
self.options.texttopath = True
self.options.stroketopath = True
self.options.exportnow = True
self.options.margin = 0.5
self.options.latexpdf = False
if DISPPROFILE:
# pylint: disable=import-outside-toplevel
import cProfile
import pstats
import io
from pstats import SortKey
# pylint: enable=import-outside-toplevel
prf = cProfile.Profile()
prf.enable()
formats = [
self.options.usepdf,
self.options.usepng,
self.options.useemf,
self.options.useeps,
self.options.usepsvg,
]
formats = [
["pdf", "png", "emf", "eps", "psvg"][i]
for i in range(len(formats))
if formats[i]
]
# Make an options copy we can pass to the external program
optcopy = copy.copy(self.options)
delattr(optcopy, "output")
delattr(optcopy, "input_file")
optcopy.reduce_images = self.options.imagemode2
bfn = inkex.inkscape_system_info.binary_location
pyloc, pybin = os.path.split(sys.executable)
if not (self.options.exportnow):
aepy = os.path.abspath(
os.path.join(dh.get_script_path(), "autoexporter_script.py")
)
if self.options.watchhere:
pth = dh.Get_Current_File(self, "To watch this document's location, ")
optcopy.watchdir = os.path.dirname(pth)
optcopy.writedir = os.path.dirname(pth)
if not os.path.exists(optcopy.watchdir):
dh.idebug(
"Watch directory could not be found. Please be sure the "
"selected location is valid.\n"
)
dh.idebug(
"Note: Linux AppImage and Snap installations of Inkscape "
"cannot see locations outside the installation directory."
)
sys.exit()
# Pass settings using a config file. Include the current path so
# Inkex can be called if needed.
optcopy.inkscape_bfn = bfn
optcopy.formats = formats
optcopy.syspath = sys.path
try:
with warnings.catch_warnings():
# Ignore ImportWarning for Gtk/Pango
warnings.simplefilter("ignore")
# Prevent Gtk-Message: Failed to load module "xapp-gtk3-module"
os.environ["GTK_MODULES"] = ""
# pylint: disable=import-outside-toplevel, unused-import
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # noqa
# pylint: enable=import-outside-toplevel, unused-import
guitype = "gtk"
except ImportError:
guitype = "terminal"
if USE_TERMINAL:
guitype = "terminal"
optcopy.guitype = guitype
aes = os.path.join(
os.path.abspath(tempfile.gettempdir()), "si_ae_settings.p"
)
with open(aes, "wb") as file:
pickle.dump(optcopy, file)
warnings.simplefilter("ignore", ResourceWarning)
# prevent warning that process is open
if guitype == "gtk":
AutoExporter._gtk_call(pybin, aepy)
else:
AutoExporter._terminal_call(pybin, aepy, pyloc)
else:
if not (self.options.testmode):
pth = dh.Get_Current_File(self, "To do a direct export, ")
else:
pth = self.options.input_file
optcopy.original_file = pth
optcopy.debug = DEBUGGING
optcopy.prints = False
optcopy.linked_locations = ih.get_linked_locations(self)
# needed to find linked images in relative directories
optcopy.formats = formats
optcopy.outtemplate = pth
optcopy.bfn = bfn
Exporter(self.options.input_file, optcopy).export_all()
if DISPPROFILE:
prf.disable()
sio = io.StringIO()
sortby = SortKey.CUMULATIVE
pst = pstats.Stats(prf, stream=sio).sort_stats(sortby)
pst.print_stats()
dh.debug(sio.getvalue())
if self.options.testmode:
nfn = os.path.abspath(pth[0:-4] + "_plain.svg")
stream = self.options.output
if isinstance(stream, str):
# Copy the new file
shutil.copyfile(nfn, self.options.output)
else:
# Write to the output stream
svg2 = get_svg(nfn)
newdoc = lxml.etree.tostring(svg2, pretty_print=True)
try:
stream.write(newdoc)
except TypeError:
# we hope that this happens only when document needs to be encoded
stream.write(newdoc.encode("utf-8")) # type: ignore
self.options.output = None
os.remove(nfn)
# Runs a Python script using a Python binary in a working directory
# It detaches from Inkscape, allowing it to continue running after the
# extension has finished
@staticmethod
def _gtk_call(python_bin, python_script):
"""Run a Python script using GTK terminal."""
devnull_location = dh.shared_temp(filename="si_ae_output.txt")
with open(devnull_location, "w") as devnull:
subprocess.Popen(
[python_bin, python_script], stdout=devnull, stderr=devnull
)
@staticmethod
def _terminal_call(python_bin, python_script, python_wd):
"""Run a Python script using a terminal."""
def escp(x):
return x.replace(" ", "\\\\ ")
if sys.platform == "darwin":
# https://stackoverflow.com/questions/39840632/launch-python-script-in-new-terminal
os.system(
'osascript -e \'tell application "Terminal" to do script "'
+ escp(sys.executable)
+ " "
+ escp(python_script)
+ "\"' >/dev/null"
)
elif sys.platform == "win32":
if "pythonw.exe" in python_bin:
python_bin = python_bin.replace("pythonw.exe", "python.exe")
subprocess.Popen([python_bin, python_script], shell=False, cwd=python_wd)
# if 'pythonw.exe' in python_bin:
# python_bin = python_bin.replace('pythonw.exe', 'python.exe')
# DETACHED_PROCESS = 0x08000000
# subprocess.Popen([python_bin, python_script, 'standalone'],
# creationflags=DETACHED_PROCESS)
else:
if sys.executable[0:4] == "/tmp":
inkex.utils.errormsg(
"This appears to be an AppImage of Inkscape, which the "
"Autoexporter cannot support since AppImages are sandboxed."
)
return
if sys.executable[0:5] == "/snap":
inkex.utils.errormsg(
"This appears to be an Snap installation of Inkscape, which "
"the Autoexporter cannot support since Snap installations "
"are sandboxed."
)
return
terminals = [
"x-terminal-emulator",
"mate-terminal",
"gnome-terminal",
"terminator",
"xfce4-terminal",
"urxvt",
"rxvt",
"termit",
"Eterm",
"aterm",
"uxterm",
"xterm",
"roxterm",
"termite",
"lxterminal",
"terminology",
"st",
"qterminal",
"lilyterm",
"tilix",
"terminix",
"konsole",
"kitty",
"guake",
"tilda",
"alacritty",
"hyper",
"terminal",
"iTerm",
"mintty",
"xiterm",
"terminal.app",
"Terminal.app",
"terminal-w",
"terminal.js",
"Terminal.js",
"conemu",
"cmder",
"powercmd",
"terminus",
"termina",
"terminal-plus",
"iterm2",
"terminus-terminal",
"terminal-tabs",
]
terms = []
for terminal in terminals:
result = subprocess.run(
["which", terminal],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode == 0:
terms.append(terminal)
for t in reversed(terms):
if t == "x-terminal-emulator":
linux_terminal_call = "x-terminal-emulator -e bash -c '%CMD'"
elif t == "gnome-terminal":
linux_terminal_call = 'gnome-terminal -- bash -c "%CMD; exec bash"'
elif t == "konsole":
linux_terminal_call = "konsole -e bash -c '%CMD'"
os.system(
linux_terminal_call.replace(
"%CMD",
escp(sys.executable) + " " + escp(python_script) + " >/dev/null",
)
)
class Exporter():
def __init__(self,fin,opts):
self.filein = fin
self.__dict__.update(vars(opts))
sema_temp = threading.Semaphore(1)
def export_all(self):
"""Export all files in specified formats."""
# Make a temp directory
self.tempdir, self.temphead = dh.shared_temp('ae')
self.tempbase = joinmod(self.tempdir,self.temphead)
if self.debug:
if self.prints:
self.prints("\n " + joinmod(self.tempdir, ""))
# Make sure output directory exists
outdir = os.path.dirname(self.outtemplate)
if not os.path.exists(outdir):
os.makedirs(outdir)
# Add a document margin
cfile = self.filein # current file we're working on
if self.margin != 0:
svg = get_svg(cfile)
tmp = self.tempbase + "_marg.svg"
Exporter.add_margin(svg, self.margin, self.testmode)
dh.overwrite_svg(svg, tmp)
cfile = copy.copy(tmp)
# Do png before any preprocessing
if "png" in self.formats:
finished, myo = self.export_file(cfile, "png")
# Do preprocessing
if any(fmt in self.formats for fmt in ["pdf", "emf", "eps", "psvg"]):
cfile = self.preprocessing(cfile)
# Do vector outputs
newfiles = []
for fmt in self.formats:
if fmt != "png":
finished, myo = self.export_file(cfile, fmt)
if finished:
newfiles.append(myo)
# Remove temporary outputs and directory
failed_to_delete = None
if not (self.debug):
self.clear_temp()
else:
warnings.simplefilter("ignore", ResourceWarning)
# prevent warning that process is open
subprocess.Popen(f'explorer "{os.path.realpath(self.tempdir)}"')
return failed_to_delete
def clear_temp(self):
""" Clear any temporary files """
if not (self.debug):
tmps = []
for t in os.listdir(self.tempdir):
tmp = joinmod(self.tempdir, t)
try:
one_day_ago = time.time() - 24 * 60 * 60
if os.path.getmtime(tmp) < one_day_ago:
tmps.append(joinmod(self.tempdir, t))
except FileNotFoundError: # already deleted
pass
if tmp.startswith(self.tempbase):
tmps.append(joinmod(self.tempdir, t))
for tmp in tmps:
if os.path.exists(tmp):
deleted = False
nattempts = 0
while not (deleted) and nattempts < MAXATTEMPTS:
try:
os.remove(tmp)
deleted = True
except PermissionError:
time.sleep(1)
nattempts += 1
def preprocessing(self, fin):
"""Modifications that are done prior to conversion to any vector output"""
if self.prints:
fname = os.path.split(self.filein)[1]
try:
offset = round(os.get_terminal_size().columns / 2)
except OSError:
offset = 40
fname = fname + " " * max(0, offset - len(fname))
self.prints(fname + ": Preprocessing vector output", flush=True)
timestart = time.time()
tempdir = self.tempdir
temphead = self.temphead
# SVG modifications that should be done prior to any binary calls
cfile = fin
svg = get_svg(cfile)
# Prune hidden items and remove language switching
stag = inkex.addNS("switch", "svg")
todelete, todelang, switches = [], [], []
for elem in dh.visible_descendants(svg):
if elem.cspecified_style.get("display") == "none":
todelete.append(elem)
if elem.get("systemLanguage") is not None:
lang = inkex.inkscape_system_info.language
if elem.get("systemLanguage") == lang:
todelang.append(elem)
# Remove other languages from switches
if elem.getparent().tag == stag:
todelete.extend(
[
k
for k in elem.getparent()
if k.get("systemLanguage") != lang
]
)
else:
# Remove non-matching languages
todelete.append(elem)
if elem.tag == stag:
switches.append(elem)
for elem in unique(todelete):
elem.delete()
for elem in todelang:
elem.set("systemLanguage", None)
for elem in switches:
if len(elem)==1:
dh.ungroup(elem)
# Embed linked images into the SVG. This should be done prior to clone unlinking
# since some images may be cloned
if self.exportnow:
lls = self.linked_locations
else:
lls = ih.get_linked_locations_file(cfile, svg)
for k in lls:
elem = svg.getElementById(k)
ih.embed_external_image(elem, lls[k])
vds = dh.visible_descendants(svg)
raster_ids, image_ids, jpgs = [], [], []
for elem in vds:
# Unlink any clones for the PDF image and marker fixes
if isinstance(elem, (inkex.Use)) and not (isinstance(elem, (inkex.Symbol))):
newel = dh.unlink2(elem)
myi = vds.index(elem)
vds[myi] = newel
for ddv in newel.descendants2()[1:]:
vds.append(ddv)
# Remove trivial groups inside masks/transparent objects or clipped groups
if isinstance(elem, (inkex.Group)):
ancs = elem.ancestors2(includeme=True)
masked = any(
anc.get_link("mask", svg) is not None
or (
elem.ccascaded_style.get("opacity") is not None
and float(elem.ccascaded_style.get("opacity")) < 1
)
for anc in ancs
)
clipped = any(
anc.get_link("clip-path", svg) is not None for anc in ancs
)
if (
len(list(elem)) == 1
and (masked or clipped)
and elem.get("autoexporter_rasterize") not in ["png", "jpg", "True"]
):
dh.ungroup(elem)
# Remove groups inside clips
cpth = elem.get_link("clip-path", svg)
if cpth is not None:
while any(isinstance(v, inkex.Group) for v in list(cpth)):
for grp in list(cpth):
if isinstance(grp, inkex.Group):
dh.ungroup(grp)
stps = []
ttps = []
for elem in vds:
elid = elem.get_id()
# Fix marker export bug for PDFs
mkrs = Exporter.marker_fix(elem)
# Convert markers to path for Office
if len(mkrs) > 0 and self.usepsvg:
stps.append(elid)
# Fix opacity bug for Office PDF saving
if len(mkrs) == 0: # works poorly with markers
Exporter.opacity_fix(elem)
# Disable connectors
elem.set("inkscape:connector-type", None)
# Find out which objects need to be rasterized
sty = elem.cstyle
# only want the object with the filter
if sty.get_link("filter", svg) is not None:
raster_ids.append(elid) # filtered objects (always rasterized at PDF)
if (sty.get("fill") is not None and "url" in sty.get("fill")) or (
sty.get("stroke") is not None and "url" in sty.get("stroke")
):
raster_ids.append(elid) # gradient objects
if elem.get_link("mask") is not None:
raster_ids.append(elid) # masked objects (always rasterized at PDF)
if elem.get("autoexporter_rasterize") in ["png", "jpg", "True"]:
raster_ids.append(elid)
# rasterizer-marked
if elem.get("autoexporter_rasterize") == "jpg":
jpgs.append(elid)
if elem.get("autoexporter_rasterize")=='topath':
ttps.append(elid)
if isinstance(elem, (inkex.Image)):
image_ids.append(elid)
# Inkscape inappropriately clips non-'optimizeQuality' images
# when generating PDFs and calculating bounding boxes
elem.cstyle["image-rendering"] = "optimizeQuality"
# Set image x,y,width,height to standard values to prevent Office
# from mishandling clips
Exporter.standardize_image(elem)
# Remove style attributes with invalid URLs
# (Office doesn't display the element while Inkscape ignores it)
for satt in list(sty.keys()):
if (
satt in dh.urlatts
and sty.get(satt).startswith("url")
and sty.get_link(satt, svg) is None
):
elem.cstyle[satt] = None
# Prune single-point paths, which Inkscape doesn't show
if elem.tag in otp_support_tags:
pth = elem.cpath
firstpt = None
trivial = True
for pnt in pth.end_points:
if firstpt is None:
firstpt = (pnt.x, pnt.y)
elif (pnt.x, pnt.y) != firstpt:
trivial = False
break
if trivial:
elem.delete(deleteup=True)
# Fix Avenir/Whitney
tetag = inkex.TextElement.ctag
frtag = inkex.FlowRoot.ctag
tels = [elem for elem in vds if elem.tag in {tetag, frtag}]
dh.character_fixer(tels)
# Strip all sodipodi:role lines from document
# Conversion to plain SVG does this automatically but poorly
excludetxtids = []
self.duplicatelabels = dict()
# if self.usepsvg:
if len(tels) > 0:
svg.make_char_table()
self.ctable = svg.char_table
# store for later
nels = []
for elem in reversed(tels):
if elem.parsed_text.isflow:
nels += elem.parsed_text.flow_to_text()
tels.remove(elem)
tels += nels
for elem in tels:
elem.parsed_text.strip_text_baseline_shift()
elem.parsed_text.strip_sodipodi_role_line()
elem.parsed_text.fuse_fonts()
Exporter.subsuper_fix(elem)
# Preserve duplicate of text to be converted to paths
if self.texttopath and elem.get("display") != "none" and elem.croot is not None:
dup = elem.duplicate()
excludetxtids.append(dup.get_id())
grp = dh.group([dup])
grp.set("display", "none")
# tel = svg.new_element(inkex.TextElement, svg)
tel = inkex.TextElement()
grp.append(tel) # Remember the original ID
tel.text = "{0}: {1}".format(DUP_KEY, elem.get_id())
tel.set("display", "none")
excludetxtids.append(tel.get_id())
self.duplicatelabels[tel.get_id()] = elem.get_id()
# Make sure nested tspans have fill specified (STP bug)
for dsd in elem.descendants2()[1:]:
if (
"fill" in dsd.cspecified_style
and dsd.cspecified_style["fill"] != "#000000"
):
dsd.cstyle["fill"] = dsd.cspecified_style["fill"]
tmp = self.tempbase + "_mod.svg"
dh.overwrite_svg(svg, tmp)
cfile = tmp
self.excludetxtids = excludetxtids
do_rasterizations = len(raster_ids + image_ids) > 0
do_stroketopaths = self.texttopath or self.stroketopath or len(stps) > 0 or len(ttps)>0
dpi = self.dpi
class Act():
'''
Represents a single binary call Action.
type == 'stp' creates an action that stroke-to-path's a group of objects
type == 'imgt' exports a PNG copy of an image with a transparent background
type == 'imgo' exports a PNG copy of an image with an opaque background, and objects above hidden
'''
def __init__(self,typ,els,fname=None,overlaps=None):
self.type = typ
if isinstance(els,list):
self.els = els
else:
self.els = [els]
if fname is None:
elid = self.els[0].get_id()
if typ=='imgt':
fname = temphead + "_im_" + elid + "." + imgtype
else:
fname = temphead + "_imbg_" + elid + "." + imgtype
self.fname = fname;
self.overlaps = overlaps
if (
(self.stroketopath or len(stps) > 0)
and inkex.installed_ivp[0] >= 1
and inkex.installed_ivp[1] > 0
):
stpact = "object-stroke-to-path"
else:
stpact = "object-to-path"
def __str__(self):
if self.type == 'stp':
return "select:{0}; {1}; export-filename:{2}; export-do; unselect:{0}; ".format(
",".join(self.els), Act.stpact, self.fname
)
elif self.type == 'imgt':
fmt1 = (
"export-id:{0}; export-id-only; export-dpi:{1}; "
"export-filename:{2}; export-background-opacity:0.0; "
"export-do; "
)
return fmt1.format(self.els[0].get_id(), int(dpi), self.fname)
elif self.type == 'imgo':
el = self.els[0]
fmt2 = (
"export-id:{0}; export-dpi:{1}; "
"export-filename:{2}; export-background-opacity:1.0; "
"export-do; "
)
actv = fmt2.format(el.get_id(), int(dpi), self.fname)
# For export all, hide objects on top
displays = {el: el.cstyle.get('display') for el in overlaps[el]}
hides = ['select:{0}; object-set-property:display,none; unselect:{0}; '.format(el.get_id()) for el in overlaps[el]]
unhides = ['select:{0}; object-set-property:display,{1}; unselect:{0}; '.format(el.get_id(), \
displays[el] if displays[el] is not None else '') for el in overlaps[el]]
return ''.join(hides) + actv + ''.join(unhides)
def split(self,intermediate_fn):
''' Splits a STP act into two sub-acts '''
spl = math.ceil(len(self.els) / 2)
act1 = Act('stp',self.els[:spl],intermediate_fn)
act2 = Act('stp',self.els[spl:],self.fname)
return act1, act2
allacts = []
if do_stroketopaths:
svg = get_svg(cfile)
vdd = dh.visible_descendants(svg)
updatefile = False
tels = []
if self.texttopath or len(ttps)>0:
for elem in vdd:
if (elem.get_id() in ttps or self.texttopath) and (elem.tag == tetag and elem.get_id() not in excludetxtids):
tels.append(elem.get_id())
for dsd in elem.descendants2():
# Fuse fill and stroke to account for STP bugs
if "fill" in dsd.cspecified_style:
dsd.cstyle["fill"] = dsd.cspecified_style["fill"]
updatefile = True
if "stroke" in dsd.cspecified_style:
dsd.cstyle["stroke"] = dsd.cspecified_style["stroke"]
updatefile = True
pels, dgroups = [], []
if self.stroketopath or len(stps) > 0:
# Stroke to Path has a number of bugs, try to fix them
if self.stroketopath:
stpels = vdd
elif len(stps) > 0:
stpels = [el for el in vdd if el.get_id() in stps]
stpels = [el for el in stpels if el.get_id() not in raster_ids]
pels, dgroups = Exporter.stroke_to_path_fixes(stpels)
updatefile = True
if updatefile:
dh.overwrite_svg(svg, cfile)
tmpstp = self.tempbase + "_stp.svg"
allacts += [Act('stp',tels + pels,tmpstp)]
# Rasterizations
actts, actos = [], []
if do_rasterizations:
svg = get_svg(cfile)
vds = dh.visible_descendants(svg)
els = [el for el in vds if el.get_id() in list(set(raster_ids + image_ids))]
if len(els) > 0:
imgtype = "png"
overlaps = dh.overlapping_els(svg,els)
for elem in els:
elid = elem.get_id()
actts.append(Act('imgt',elem))
actos.append(Act('imgo',elem,overlaps=overlaps))
allacts += (actos + actts) if ih.hasPIL else actts
# export-id-onlys need to go last
imgs_trnp = {act.fname:act.els[0].get_id() for act in actts}
imgs_opqe = {act.fname:act.els[0].get_id() for act in actos}
# To reduce the number of binary calls, we collect the stroke-to-path and
# rasterization actions into a single call that also gets the Bounding Boxes.
# Skip binary call during testing
if len(allacts) > 0 and not self.testmode:
# use relative paths to reduce arg length
bbs = self.split_acts(fnm=cfile, acts=allacts)
imgs = imgs_opqe | imgs_trnp
missing_images = [t for t in imgs if not os.path.exists(os.path.join(tempdir, t)) and imgs[t] in bbs]
if missing_images:
raise TimeoutError(
"\nThe Inkscape binary could not generate the temporary images "
+ ", ".join(missing_images) + ' in ' + tempdir + '.\n\n'
+ "This may be a temporary issue; try running the extension again."
)
if do_stroketopaths:
cfile = tmpstp
if do_rasterizations:
svg = get_svg(cfile)
vds = dh.visible_descendants(svg)
els = [el for el in vds if el.get_id() in list(set(raster_ids + image_ids))]
if len(els) > 0:
jimgs_trnp = [os.path.join(tempdir, t) for t in imgs_trnp]
jimgs_opqe = [os.path.join(tempdir, t) for t in imgs_opqe]
for i, elem in enumerate(els):
img_trnp = jimgs_trnp[i]
img_opqe = jimgs_opqe[i]
if os.path.exists(img_trnp):
anyalpha0 = False
if ih.hasPIL:
bbox = ih.crop_images([img_trnp, img_opqe])
anyalpha0 = ih.Set_Alpha0_RGB(img_trnp, img_opqe)
if elem.get_id() in jpgs:
tmpjpg = img_trnp.replace(".png", ".jpg")
ih.to_jpeg(img_opqe, tmpjpg)
img_trnp = copy.copy(tmpjpg)
else:
bbox = None
# Compare size of old and new images
osz = ih.embedded_size(elem)
if osz is None:
osz = float("inf")
nsz = os.path.getsize(img_trnp)
hasmaskclip = (
elem.get_link("mask") is not None
or elem.get_link("clip-path") is not None
) # clipping and masking
embedimg = (nsz < osz) or (anyalpha0 or hasmaskclip)
if embedimg:
Exporter.replace_with_raster(
elem, img_trnp, bbs[elem.get_id()], bbox
)
tmp = self.tempbase + "_eimg.svg"
dh.overwrite_svg(svg, tmp)
cfile = tmp
if do_stroketopaths:
svg = get_svg(cfile)
# Remove temporary groups
if self.stroketopath:
for elid in dgroups:
elem = svg.getElementById(elid)
if elem is not None:
dh.ungroup(elem)
tmp = self.tempbase + "_poststp.svg"
dh.overwrite_svg(svg, tmp)
cfile = tmp
if self.prints:
self.prints(
fname
+ ": Preprocessing done ("
+ str(round(1000 * (time.time() - timestart)) / 1000)
+ " s)"
)
return cfile
def check(self,func, *args, **kwargs):
"""Wraps a binary call with a check if thread has been stopped; if so, clear the temp file and exit"""
with sema1 if func==dh.wrapped_binary else sema2:
if hasattr(self, "aeThread") and self.aeThread.stopped is True:
self.clear_temp()
sys.exit()
ret = func(*args, **kwargs)
if hasattr(self, "aeThread") and self.aeThread.stopped is True:
self.clear_temp()
sys.exit()
return ret
def split_acts(self, fnm, acts, reserved=None, get_bbs=True):
"""Split actions and run."""
if reserved is None:
reserved = set()
eargs = ["--actions", "".join([str(a) for a in acts])]
bbs = None
try:
bbs = self.check(dh.wrapped_binary,filename=fnm, inkscape_binary=self.bfn, extra_args=eargs, get_bbs = get_bbs, cwd=self.tempdir)
fnf_err = False
except FileNotFoundError:
fnf_err = True
missing_exports = []
missing_fns = []
found_fns = []
for i, act in enumerate(acts):
actfile = act.fname
if actfile is not None and not os.path.exists(joinmod(self.tempdir,actfile)):
missing_exports.append(act)
missing_fns.append(actfile)
if os.path.exists(joinmod(self.tempdir,actfile)):
found_fns.append(actfile)
# dh.idebug('Found '+str(found_fns))
# dh.idebug('FNF: '+str(fnf_err))
# dh.idebug('Missing ' + str(missing_fns) +'\n')
if fnf_err:
if len(acts)==1:
if missing_exports[0].type=='stp':
bbs = self.split_stp(
fnm, missing_exports, 0, reserved=reserved, get_bbs = get_bbs
)
else:
# Already simplified call as much as we can...
raise Exception('FileNotFoundError and cannot split:\n'+str(acts))
elif len(missing_exports)>0:
acts1 = missing_exports[: math.ceil(len(missing_exports) / 2)]
acts2 = missing_exports[math.ceil(len(missing_exports) / 2) :]
bbs = self.split_acts(
fnm=fnm, acts=acts1, reserved=reserved, get_bbs = get_bbs
)
if len(acts2)>0:
self.split_acts(
fnm=fnm, acts=acts2, reserved=reserved, get_bbs = False
)
elif any(act.type=='stp' for act in missing_exports):
stp_idx = [i for i, act in enumerate(missing_exports) if act.type=='stp'][0]
bbs = self.split_stp(
fnm, missing_exports, stp_idx, reserved=reserved, get_bbs = get_bbs
)
return bbs
def split_stp(self,fnm, acts, selii, reserved, get_bbs=True, cwd=None):
"""
Windows cannot handle arguments that are too long.
If needed, this can split a stroke-to-path operation into two.
"""
act = acts[selii]
actfn = act.fname
reserved.update({fnm, actfn})
isreserved = True
cnt = 0
while isreserved:
actfna = actfn.strip(".svg") + str(cnt) + ".svg"
isreserved = actfna in reserved
cnt += 1
reserved.update({actfna})
if len(act.els) == 1:
# cannot split, fact that we failed means there is a STP crash
shutil.copy(fnm, actfn)
return self.split_acts(
fnm=fnm,
acts=acts[:selii] + acts[selii + 1 :],
reserved=reserved
)
act1, act2 = act.split(actfna)
acts1 = acts[:selii] + [act1] if len(act1.els) > 0 else acts[:selii]
acts2 = (
[act2] + acts[selii + 1 :] if len(act2.els) > 0 else acts[selii + 1 :]
)
bbs = self.split_acts(
fnm=fnm, acts=acts1, reserved=reserved, get_bbs=get_bbs
)
bbs = self.split_acts(
fnm=actfna, acts=acts2, reserved=reserved, get_bbs=get_bbs
)
return bbs
def export_file(self, fin, fformat):
"""Use the Inkscape binary to export the file"""
myoutput = self.outtemplate[0:-4] + "." + fformat
if self.prints:
fname = os.path.split(self.filein)[1]
try:
offset = round(os.get_terminal_size().columns / 2)
except OSError:
offset = 40
fname = fname + " " * max(0, offset - len(fname))
self.prints(fname + ": Converting to " + fformat, flush=True)
timestart = time.time()
ispsvg = fformat == "psvg"
notpng = not (fformat == "png")
cfile = fin
if self.thinline and notpng:
svg = get_svg(cfile)
if fformat in ["pdf", "eps"]:
Exporter.thinline_dehancement(svg, "bezier")
else:
Exporter.thinline_dehancement(svg, "split")
tmp = self.tempbase + "_tld" + fformat[0] + ".svg"
dh.overwrite_svg(svg, tmp)
cfile = copy.copy(tmp)
if fformat == "psvg":
myoutput = myoutput.replace(".psvg", "_plain.svg")
def overwrite_output(filein, fileout):
if os.path.exists(fileout):
os.remove(fileout)
args = [
self.bfn,
"--export-background",
"#ffffff",
"--export-background-opacity",
"1.0",
"--export-dpi",
str(self.dpi),
"--export-filename",
fileout,
filein,
]
if fileout.endswith(".pdf") and self.latexpdf:
if os.path.exists(fileout + "_tex"):
os.remove(fileout + "_tex")
args = args[0:5] + ["--export-latex"] + args[5:]
if fileout.endswith(".svg"):
args = (
[args[0]]
+ ["--vacuum-defs"]
+ args[1:5]
+ ["--export-plain-svg"]
+ args[5:]
)
self.check(dh.subprocess_repeat,args)
def make_output(filein, fileout):
if fileout.endswith(".svg"):
if not (self.testmode):
overwrite_output(filein, fileout)
else:
shutil.copy(filein, fileout) # skip conversion
self.made_outputs = [fileout]
osvg = get_svg(filein)
pgs = osvg.cdocsize.pgs
haspgs = inkex.installed_haspages
if (haspgs or self.testmode) and len(pgs) > 0:
# bbs = dh.BB2(type("DummyClass", (), {"svg": osvg}))
bbs = dh.BB2(osvg)
dlbl = self.duplicatelabels
outputs = []
pgiis = (
range(len(pgs)) if not (self.testmode) else [self.testpage - 1]
)
for i in pgiis:
# match the viewbox to each page and delete them
psvg = get_svg(fileout)
# plain SVG has no pages
pgs2 = psvg.cdocsize.pgs
Exporter.change_viewbox_to_page(psvg, pgs[i])
# Only need to delete other Pages in testmode since
# plain SVGs have none
if self.testmode:
for j in reversed(range(len(pgs2))):
pgs2[j].delete()
# Delete content not on current page
pgbb = dh.bbox(psvg.cdocsize.effvb)
for k, bbx in bbs.items():
removeme = not (dh.bbox(bbx).intersect(pgbb))
if k in dlbl:
removeme = not (
dlbl[k] in bbs
and dh.bbox(bbs[dlbl[k]]).intersect(pgbb)
)
if removeme and psvg.getElementById(k) is not None:
psvg.getElementById(k).delete()
pname = pgs[i].get("inkscape:label")
pname = str(i + 1) if pname is None else pname
addendum = (
"_page_" + pname
if not (self.testmode or len(pgs) == 1)
else ""
)
outparts = fileout.split(".")
pgout = ".".join(outparts[:-1]) + addendum + "." + outparts[-1]
dh.overwrite_svg(psvg, pgout)
outputs.append(pgout)
self.made_outputs = outputs
else:
svg = get_svg(filein)
pgs = svg.cdocsize.pgs
haspgs = inkex.installed_haspages
if (haspgs or self.testmode) and len(pgs) > 1:
outputs = []
pgiis = (
range(len(pgs)) if not (self.testmode) else [self.testpage - 1]
)
for i in pgiis:
# match the viewbox to each page and delete them
svgpg = get_svg(filein)
pgs2 = svgpg.cdocsize.pgs
Exporter.change_viewbox_to_page(svgpg, pgs[i])
for j in reversed(range(len(pgs2))):
pgs2[j].delete()
pname = pgs[i].get("inkscape:label")
pname = str(i + 1) if pname is None else pname
addendum = "_page_" + pname if not (self.testmode) else ""
svgpgfn = self.tempbase + addendum + ".svg"
dh.overwrite_svg(svgpg, svgpgfn)
outparts = fileout.split(".")
pgout = ".".join(outparts[:-1]) + addendum + "." + outparts[-1]
overwrite_output(svgpgfn, pgout)
outputs.append(pgout)
self.made_outputs = outputs
else:
overwrite_output(filein, fileout)
self.made_outputs = [fileout]
if not (ispsvg):
make_output(cfile, myoutput)
finalnames = self.made_outputs
else:
tmp = self.tempbase + "_tmp_small.svg"
make_output(cfile, tmp)
moutputs = self.made_outputs
finalnames = []
for mout in moutputs:
svg = get_svg(mout)
self.postprocessing(svg)
finalname = myoutput
if len(moutputs) > 1:
pnum, _ = os.path.splitext(mout.split("_page_")[-1])
finalname = myoutput.replace(
"_plain.svg", "_page_" + pnum + "_plain.svg"
)
dh.overwrite_svg(svg, finalname)
finalnames.append(finalname)
# Remove any previous outputs that we did not just make
directory, file_name = os.path.split(myoutput)
base_name, extension = os.path.splitext(file_name)
if extension == ".svg" and base_name.endswith("_plain"):
base_name = base_name[:-6] # Remove "_plain" from the base name
pattern = re.compile(
rf"{re.escape(base_name)}(_page_.*)?_plain{re.escape(extension)}$"
)
else:
pattern = re.compile(
rf"{re.escape(base_name)}(_page_.*)?{re.escape(extension)}$"
)
matching_files = []
for file in os.listdir(directory):
if pattern.match(file):
matching_files.append(os.path.join(directory, file))
for file in matching_files:
if file not in finalnames:
try:
os.remove(file)
except PermissionError:
pass
if self.prints:
toc = time.time() - timestart
self.prints(
fname
+ ": Conversion to "
+ fformat
+ " done ("
+ str(round(1000 * toc) / 1000)
+ " s)"
)
return True, myoutput
def postprocessing(self, svg):
"""Postprocessing of SVGs, mainly for overcoming bugs in Office products"""
vds = dh.visible_descendants(svg)
# Shift viewbox corner to (0,0) by translating top-level elements
evb = svg.cdocsize.effvb
if not (evb[0] == 0 and evb[1] == 0):
newtr = Transform("translate(" + str(-evb[0]) + ", " + str(-evb[1]) + ")")
svg.set_viewbox([0, 0, evb[2], evb[3]])
ndefs = [el for el in list(svg) if not (el.tag in dh.unungroupable)]
for elem in ndefs:
elem.ctransform = newtr @ elem.ctransform
for dsd in vds:
if (
isinstance(dsd, (TextElement))
and dsd.get_id() not in self.excludetxtids
):
scaleto = 100 if not self.testmode else 10
# Make 10 px in test mode so that errors are not unnecessarily large
Exporter.scale_text(dsd, scaleto)
elif isinstance(dsd, (inkex.Image)):
if ih.hasPIL:
Exporter.merge_mask(dsd)
# while dsd.getparent()!=dsd.croot and dsd.getparent() is not None
# and dsd.croot is not None:
# # iteratively remove groups containing images
# # I don't think it's necessary?
# dh.ungroup(dsd.getparent());
elif isinstance(dsd, (inkex.Group)):
if len(dsd) == 0:
dsd.delete(deleteup=True)
if self.backingrect:
r = inkex.Rectangle()
tlvl = [el for el in list(svg) if not (el.tag in dh.unungroupable)]
if len(tlvl) > 0:
svg.insert(svg.index(tlvl[0]), r)
else:
svg.append(r)
r.cstyle["fill"] = "#ffffff"
r.cstyle["fill-opacity"] = ".00001"
r.cstyle["stroke"] = "none"
vbx = svg.cdocsize.effvb
r.set("x", vbx[0])
r.set("y", vbx[1])
r.set("width", vbx[2])
r.set("height", vbx[3])
# if self.thinline:
# for dsd in vds:
# self.Bezier_to_Split(dsd) # moved to deprecated
# dh.idebug(self.svgtopdf_vbs)
# embed_original = False
# if embed_original:
# def main_contents(svg):
# ret = list(svg)
# for k in reversed(ret):
# if k.tag in [inkex.addNS('metadata','svg'),
# inkex.addNS('namedview','sodipodi')]:
# ret.remove(k);
# return ret
# grp = dh.group(main_contents(svg))
# svgo = get_svg(original_file)
# go = dh.group(main_contents(svgo))
# svg.append(go)
# go.set('style','display:none');
# go.set('inkscape:label','SI original');
# go.set('inkscape:groupmode','layer');
# # Conversion to PDF changes the viewbox to pixels. Convert back to the
# # original viewbox by applying a transform
# dds = self.svgtopdf_dss[i]
# tfmt = 'matrix({0},{1},{2},{3},{4},{5})';
# T =inkex.Transform(tfmt.format(dds.uuw,0,0,dds.uuh,
# -dds.effvb[0]*dds.uuw,
# -dds.effvb[1]*dds.uuh))
# grp.set('transform',str(-T))
# svg.set_viewbox(dds.effvb)
tel = None
for elem in list(svg):
if (
isinstance(elem, (inkex.TextElement,))
and elem.text is not None
and ORIG_KEY in elem.text
):
tel = elem
if tel is None:
# tel = svg.new_element(inkex.TextElement, svg)
tel = inkex.TextElement()
svg.append(tel)
tel.text = ORIG_KEY + ": {0}, hash: {1}".format(
self.original_file, hash_file(self.original_file)
)
tel.set("style", "display:none")
dh.clean_up_document(svg) # Clean up
PTH_COMMANDS = list("MLHVCSQTAZmlhvcsqtaz")
@staticmethod
def thinline_dehancement(svg, mode="split"):
"""
Prevents thin-line enhancement in certain bad PDF renderers
'bezier' mode converts h,v,l path commands to trivial Beziers
'split' mode splits h,v,l path commands into two path commands
The Office PDF renderer removes trivial Beziers, as does conversion to EMF
The Inkscape PDF/EPS renderer removes split commands
Split is more general, so apply it to everything but PDFs/EPS
"""
command_chars = {"h", "v", "l", "H", "V", "L"}
split = mode == "split"
for elem in svg.descendants2():
if elem.tag in otp_support_tags and not elem.tag == peltag:
elem.object_to_path()
pthd = elem.get("d")
if pthd and any(char in pthd for char in command_chars):
if any(char in pthd for char in {"H", "V", "L"}):
pthd = str(inkex.Path(pthd).to_relative())
dds = [v for v in pthd.replace(",", " ").split(" ") if v]
current_command = None
i = 0
while i < len(dds):
token = dds[i]
if token in {"v", "h", "l"}:
current_command = token
dds[i] = ""
elif token in Exporter.PTH_COMMANDS:
current_command = None
else:
if current_command == "h":
hval = float(token)
dds[i] = (
f"h {hval / 2} {hval / 2}"
if split
else f"c {hval},0 {hval},0 {hval},0"
)
elif current_command == "v":
vval = float(token)
dds[i] = (
f"v {vval / 2} {vval / 2}"
if split
else f"c 0,{vval} 0,{vval} 0,{vval}"
)
elif current_command == "l":
lxv = float(token)
lyv = float(dds[i + 1])
dds[i] = (
f"l {lxv / 2},{lyv / 2} {lxv / 2},{lyv / 2}"
if split
else f"c {lxv},{lyv} {lxv},{lyv} {lxv},{lyv}"
)
dds[i + 1] = ""
i += 1
i += 1
newd = " ".join(v for v in dds if v).replace(" ", " ")
elem.set("d", newd)
@staticmethod
def marker_fix(elem):
"""Fixes the marker bug that occurs with context-stroke and context-fill"""
mkrs = Exporter.get_markers(elem)
sty = elem.cspecified_style
for mtyp, mkrel in mkrs.items():
dh.get_strokefill(elem)
mkrds = mkrel.descendants2()
anycontext = any(
a in ("stroke", "fill") and "context" in v
for d in mkrds
for a, v in d.cspecified_style.items()
)
if anycontext:
handled = True
dup = mkrel.duplicate()
dupds = dup.descendants2()
for dsd in dupds:
dsty = dsd.cspecified_style
for att, val in dsty.items():
if att in ("stroke", "fill") and "context" in val:
if val == "context-stroke":
dsd.cstyle[att] = sty.get("stroke", "none")
elif val == "context-fill":
dsd.cstyle[att] = sty.get("fill", "none")
else: # I don't know what this is
handled = False
if handled:
elem.cstyle[mtyp] = dup.get_id(as_url=2)
return mkrs
@staticmethod
def opacity_fix(elem):
"""
Fuse opacity onto fill and stroke for path-like elements
Helps prevent rasterization-at-PDF for Office products
"""
sty = elem.cspecified_style
if (
sty.get("opacity") is not None
and float(sty.get("opacity", 1.0)) != 1.0
and elem.tag in otp_support_tags
):
strf = dh.get_strokefill(elem) # fuses opacity and
# stroke-opacity/fill-opacity
if strf.stroke is not None and strf.fill is None:
elem.cstyle["stroke-opacity"] = strf.stroke.alpha
elem.cstyle["opacity"] = 1
elif strf.fill is not None and strf.stroke is None:
elem.cstyle["fill-opacity"] = strf.fill.alpha
elem.cstyle["opacity"] = 1
@staticmethod
def subsuper_fix(elem):
"""
Replace super and sub with numerical values
Collapse Tspans with 0% baseline-shift, which Office displays incorrectly
"""
for dsd in elem.descendants2():
bsh = dsd.ccascaded_style.get("baseline-shift")
if bsh in ["super", "sub"]:
sty = dsd.ccascaded_style
sty["baseline-shift"] = "40%" if bsh == "super" else "-20%"
dsd.cstyle = sty
for dsd in reversed(elem.descendants2()): # all Tspans
bsh = dsd.ccascaded_style.get("baseline-shift")
if bsh is not None and bsh.replace(" ", "") == "0%":
# see if any ancestors with non-zero shift
anysubsuper = False
for anc in dsd.ancestors2(stopafter=elem):
bsa = anc.ccascaded_style.get("baseline-shift")
if bsa is not None and "%" in bsa:
anysubsuper = float(bsa.replace(" ", "").strip("%")) != 0
# split parent
myp = dsd.getparent()
if anysubsuper and myp is not None:
dds = dh.split_text(myp)
for dsd2 in reversed(dds):
if (
len(list(dsd2)) == 1
and list(dsd2)[0].ccascaded_style.get("baseline-shift")
== "0%"
):
sel = list(dsd2)[0]
mys = sel.ccascaded_style
if mys.get("baseline-shift") == "0%":
mys["baseline-shift"] = dsd2.ccascaded_style.get(
"baseline-shift"
)
fsz, scf, _ = dh.composed_width(sel, "font-size")
mys["font-size"] = str(fsz / scf)
dsd2.addprevious(sel)
sel.cstyle = mys
sel.tail = dsd2.tail
dsd2.delete()
Exporter.subsuper_fix(
elem
) # parent is now gone, so start over
return
@staticmethod
def scale_text(elem, scaleto):
"""
Sets all font-sizes to 100 px by moving size into transform
Office rounds every fonts to the nearest px and then transforms it,
so this makes text sizes more accurate
"""
svg = elem.croot
szs = []
for dsd in elem.descendants2(): # all Tspan sizes
fsz = dsd.ccascaded_style.get("font-size")
fsz = {"small": "10px", "medium": "12px", "large": "14px"}.get(fsz, fsz)
if fsz is not None and "%" not in fsz:
szs.append(dh.ipx(fsz))
if len(szs) == 0:
maxsz = default_style_atts["font-size"]
maxsz = {"small": 10, "medium": 12, "large": 14}.get(maxsz, maxsz)
else:
maxsz = max(szs)
scv = 1 / maxsz * scaleto
# Make a dummy group so we can properly compose the transform
grp = dh.group([elem], moveTCM=True)
for dsd in reversed(elem.descendants2()):
x = ParsedText.get_xy(dsd, "x")
y = ParsedText.get_xy(dsd, "y")
dxv = ParsedText.get_xy(dsd, "dx")
dyv = ParsedText.get_xy(dsd, "dy")
if x[0] is not None:
xyset(dsd, "x", [v * scv for v in x])
if y[0] is not None:
xyset(dsd, "y", [v * scv for v in y])
if dxv[0] is not None:
xyset(dsd, "dx", [v * scv for v in dxv])
if dyv[0] is not None:
xyset(dsd, "dy", [v * scv for v in dyv])
fsz, scf, _ = dh.composed_width(dsd, "font-size")
if scf==0:
continue
dsd.cstyle["font-size"] = "{:.3f}".format(fsz / scf * scv)
otherpx = [
"letter-spacing",
"inline-size",
"stroke-width",
"stroke-dasharray",
]
for oth in otherpx:
othv = dsd.ccascaded_style.get(oth)
if (
othv is None
and oth == "stroke-width"
and "stroke" in dsd.ccascaded_style
):
othv = default_style_atts[oth]
if othv is not None:
if "," not in othv:
if 'em' not in othv: # scaling not needed for em sizes
dsd.cstyle[oth] = str((dh.ipx(othv) or 0) * scv)
else:
dsd.cstyle[oth] = ",".join(
[str((dh.ipx(v) or 0) * scv) for v in othv.split(",")]
)
shape = dsd.ccascaded_style.get_link("shape-inside", svg)
if shape is not None:
dup = shape.duplicate()
svg.cdefs.append(dup)
dup.ctransform = (
inkex.Transform((scv, 0, 0, scv, 0, 0)) @ dup.ctransform
)
dsd.cstyle["shape-inside"] = dup.get_id(as_url=2)
elem.ctransform = inkex.Transform((1 / scv, 0, 0, 1 / scv, 0, 0))
dh.ungroup(grp)
@staticmethod
def merge_mask(elem):
"""
Office will poorly rasterize masked elements at PDF-time
Revert alpha masks made by PDFication back to alpha
"""
mymask = elem.get_link("mask")
if mymask is not None and len(list(mymask)) == 1:
# only if one object in mask
mask = list(mymask)[0]
if isinstance(mask, inkex.Image):
if mask.get("height") == "1" and mask.get("width") == "1":
im1 = ih.str_to_ImagePIL(elem.get("xlink:href")).convert("RGBA")
im2 = ih.str_to_ImagePIL(mask.get("xlink:href")).convert("RGBA")
# only if mask the same size
if im1.size == im2.size:
d1a = np.asarray(im1)
d2a = np.asarray(im2)
# only if image is opaque
if np.where(d1a[:, :, 3] == 255, True, False).all():
nda = np.stack(
(
d1a[:, :, 0],
d1a[:, :, 1],
d1a[:, :, 2],
d2a[:, :, 0],
),
2,
)
# pylint: disable=import-outside-toplevel
from PIL import Image as ImagePIL
# pylint: enable=import-outside-toplevel
newstr = ih.ImagePIL_to_str(ImagePIL.fromarray(nda))
elem.set("xlink:href", newstr)
mask.delete()
elem.set("mask", None)
@staticmethod
def standardize_image(elem):
"""Office has difficulty with clipped images with a nonzero x or y."""
xv = dh.ipx(elem.get('x','0'))
yv = dh.ipx(elem.get('y','0'))
if xv!=0 or yv!=0:
grp = dh.group([elem], moveTCM=True)
elem.set('x','0')
elem.set('y','0')
elem.ctransform = inkex.Transform(f'translate({xv},{yv})')
dh.ungroup(grp)
@staticmethod
def replace_with_raster(elem, imgloc, bbx, imgbbox):
"""Replace vector elements with raster images."""
svg = elem.croot
if svg is None: # in case we were already rasterized within ancestor
return
ih.embed_external_image(elem, imgloc)
# The exported image has a different size and shape than the original
# Correct by putting transform/clip/mask on a new parent group, then
# fix location, then ungroup
grp = dh.group([elem], moveTCM=True)
grp.set("clip-path", None)
# conversion to bitmap already includes clips
grp.set("mask", None) # conversion to bitmap already includes masks
# Calculate what transform is needed to preserve the image's location
ctf = elem.ccomposed_transform
pbb = [
Vector2d(bbx[0], bbx[1]),
Vector2d(bbx[0] + bbx[2], bbx[1]),
Vector2d(bbx[0] + bbx[2], bbx[1] + bbx[3]),
Vector2d(bbx[0], bbx[1] + bbx[3]),
]
# top-left,tr,br,bl
put = [
(-ctf).apply_to_point(p) for p in pbb
] # untransformed bbox (where the image needs to go)
newel = inkex.Image()
dh.replace_element(elem, newel)
newel.set("x", 0)
myx = 0
newel.set("y", 0)
myy = 0
newel.set("width", 1)
myw = dh.ipx(newel.get("width"))
newel.set("height", 1)
myh = dh.ipx(newel.get("height"))
newel.set("xlink:href", elem.get("xlink:href"))
# Inkscape inappropriately clips non-'optimizeQuality' images
# when generating PDFs
sty = "image-rendering:optimizeQuality"
elem = newel
pgo = [
Vector2d(myx, myy),
Vector2d(myx + myw, myy),
Vector2d(myx + myw, myy + myh),
Vector2d(myx, myy + myh),
] # where the image is
elem.set("preserveAspectRatio", "none") # prevents aspect ratio snapping
elem.set("style", sty) # override existing styles
# pylint: disable=invalid-name
a = np.array(
[
[pgo[0].x, 0, pgo[0].y, 0, 1, 0],
[0, pgo[0].x, 0, pgo[0].y, 0, 1],
[pgo[1].x, 0, pgo[1].y, 0, 1, 0],
[0, pgo[1].x, 0, pgo[1].y, 0, 1],
[pgo[2].x, 0, pgo[2].y, 0, 1, 0],
[0, pgo[2].x, 0, pgo[2].y, 0, 1],
]
)
b = np.array(
[
[
put[0].x,
put[0].y,
put[1].x,
put[1].y,
put[2].x,
put[2].y,
]
]
).T
T = np.linalg.solve(a, b)
T = "matrix(" + ",".join([str(v[0]) for v in T]) + ")"
elem.set("transform", T)
# pylint: enable=invalid-name
# If we cropped, need to modify location according to bbox
if ih.hasPIL and imgbbox is not None:
elem.set("x", str(myx + imgbbox[0] * myw))
elem.set("y", str(myy + imgbbox[1] * myh))
elem.set("width", str((imgbbox[2] - imgbbox[0]) * myw))
elem.set("height", str((imgbbox[3] - imgbbox[1]) * myh))
dh.ungroup(grp)
@staticmethod
def add_margin(svg, amt_mm, testmode):
"""Add margin to the document."""
mrgn = inkex.units.convert_unit(str(amt_mm) + "mm", "px")
uuw, uuh = svg.cdocsize.uuw, svg.cdocsize.uuh
# pgs = self.Get_Pages(svg)
haspgs = inkex.installed_haspages or testmode
if haspgs and len(svg.cdocsize.pgs) > 0:
# Has Pages
for page in svg.cdocsize.pgs:
newbbuu = svg.cdocsize.pxtouupgs(
[
page.bbpx[0] - mrgn,
page.bbpx[1] - mrgn,
page.bbpx[2] + 2 * mrgn,
page.bbpx[3] + 2 * mrgn,
]
)
page.set("x", str(newbbuu[0]))
page.set("y", str(newbbuu[1]))
page.set("width", str(newbbuu[2]))
page.set("height", str(newbbuu[3]))
svg.cdocsize = None
else:
# If an old version of Inkscape or has no Pages, defined by viewbox
vbx = svg.cdocsize.effvb
nvb = [
vbx[0] - mrgn / uuw,
vbx[1] - mrgn / uuh,
vbx[2] + 2 * mrgn / uuw,
vbx[3] + 2 * mrgn / uuh,
]
svg.set_viewbox(nvb)
@staticmethod
def change_viewbox_to_page(svg, page):
"""Change viewbox to match specified page."""
newvb = page.croot.cdocsize.pxtouu(page.bbpx)
svg.set_viewbox(newvb)
@staticmethod
def get_markers(elem):
"""Returns valid marker keys and corresponding elements"""
mkrd = dict()
for mtyp in ["marker", "marker-start", "marker-mid", "marker-end"]:
mkrel = elem.cstyle.get_link(mtyp, elem.croot)
if mkrel is not None:
mkrd[mtyp] = mkrel
return mkrd
@staticmethod
def stroke_to_path_fixes(els):
"""
Stroke to Path has tons of bugs. Try to preempt them.
1. STP does not properly handle clips, so move clips and
masks to a temp parent group.
2. Densely-spaced nodes can be converted incorrectly, so scale paths up
to be size 1000 and position corner at (0,0)
3. Starting markers can be flipped.
4. Markers on groups cause crashes
5. Markers are given the wrong size
"""
dummy_groups = []
path_els = []
stroked_els = []
if len(els) > 0:
svg = els[0].croot
for elem in els:
mkrs = Exporter.get_markers(elem)
if isinstance(elem, inkex.Group) and len(mkrs) > 0:
dh.ungroup(elem)
elif elem.tag in otp_support_tags:
sty = elem.cspecified_style
if "stroke" in sty and sty["stroke"] != "none":
stroked_els.append((elem, sty, mkrs))
for elem, sty, mkrs in stroked_els:
swd = sty.get("stroke-width")
if dh.ipx(swd) == 0:
# Fix bug on zero-stroke paths
sty["stroke"] = "none"
elem.cstyle = sty
elif swd is None:
sty["stroke-width"] = default_style_atts['stroke-width']
else:
# Clip and mask issues solved by moving to a dummy parent group
gndp = dh.group([elem], moveTCM=True)
# Do it again so we can add transform later without
# messing up clip
grp = dh.group([elem], moveTCM=True)
path_els.append(elem.get_id())
dummy_groups.extend([gndp.get_id(), grp.get_id()])
# Scale up most paths to be size 1000 and position corner
# at (0,0)
if inkex.addNS("type", "sodipodi") not in elem.attrib:
# Object to path doesn't currently support
# Inkscape-specific objects
elem.object_to_path()
bbx = elem.bounding_box2(
dotransform=False, includestroke=False, roughpath=False
)
if len(mkrs) == 0:
# scaleby = 1000
maxsz = max(bbx.w, bbx.h)
scaleby = 1000 / maxsz if maxsz > 0 else 1000
else:
# For paths with markers, scale to make stroke-width=1
# Prevents incorrect marker size
# https://gitlab.com/inkscape/inbox/-/issues/10506#note_1931910230
scaleby = 1 / dh.ipx(swd) if swd is not None else 1
tfm = Transform("scale({0})".format(scaleby)) @ Transform(
"translate({0},{1})".format(-bbx.x1, -bbx.y1)
)
# relative paths seem to have some bugs
pth2 = str(elem.cpath.to_absolute().transform(tfm))
elem.set("d", pth2)
# Put transform on parent group since STP cannot convert
# transformed paths correctly if they have dashes
# See https://gitlab.com/inkscape/inbox/-/issues/7844
grp.ctransform = -tfm
csty = elem.cspecified_style
if "stroke-width" in csty:
swd = dh.ipx(csty["stroke-width"])
elem.cstyle["stroke-width"] = str(swd * scaleby)
if "stroke-dasharray" in csty:
sda = dh.listsplit(csty["stroke-dasharray"])
elem.cstyle["stroke-dasharray"] = (
str([(sdv or 0) * scaleby for sdv in sda])
.strip("[")
.strip("]")
)
# Fix bug on start markers where auto-start-reverse
# oriented markers are inverted by STP
sty = elem.cspecified_style
mstrt = sty.get_link("marker-start", svg)
if mstrt is not None:
if mstrt.get("orient") == "auto-start-reverse":
dup = mstrt.duplicate()
dup.set("orient", "auto")
for dkid in list(dup):
dkid.ctransform = Transform("scale(-1)") @ dkid.ctransform
sty["marker-start"] = dup.get_id(as_url=2)
elem.cstyle = sty
return path_els, dummy_groups
# Convenience functions
def joinmod(dirc, fname):
"""Join directory and file name with absolute path."""
return os.path.join(os.path.abspath(dirc), fname)
def get_svg(fin):
"""Load an SVG file and return the root svg element."""
try:
svg = inkex.load_svg(fin).getroot()
except lxml.etree.XMLSyntaxError:
# Try removing problematic bytes
with open(fin, "rb") as file:
bytes_content = file.read()
cleaned_content = bytes_content.decode("utf-8", errors="ignore")
nfin = dh.shared_temp(filename="cleaned.svg")
with open(nfin, "w", encoding="utf-8") as file:
file.write(cleaned_content)
svg = inkex.load_svg(nfin).getroot()
return svg
ORIG_KEY = "si_ae_original_filename"
DUP_KEY = "si_ae_original_duplicate"
def hash_file(filename):
"""Calculate hash of a file."""
hashv = hashlib.sha256()
with open(filename, "rb") as file:
chunk = 0
while chunk != b"":
chunk = file.read(1024)
hashv.update(chunk)
return hashv.hexdigest()
if __name__ == "__main__":
dh.Run_SI_Extension(AutoExporter(), "Autoexporter")