bundle: update (2026-01-18)
This commit is contained in:
567
extensions/botbox3000/deps/inkex/base.py
Normal file
567
extensions/botbox3000/deps/inkex/base.py
Normal file
@@ -0,0 +1,567 @@
|
||||
# coding=utf-8
|
||||
#
|
||||
# Copyright (c) 2018 - Martin Owens <doctormo@gmail.com>
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
"""
|
||||
The ultimate base functionality for every Inkscape extension.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import copy
|
||||
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Tuple,
|
||||
Type,
|
||||
Optional,
|
||||
Callable,
|
||||
Any,
|
||||
Union,
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
cast,
|
||||
)
|
||||
|
||||
from argparse import ArgumentParser, Namespace
|
||||
from lxml import etree
|
||||
|
||||
from .utils import filename_arg, AbortExtension, ABORT_STATUS, errormsg, do_nothing
|
||||
from .elements._parser import load_svg
|
||||
from .elements._utils import NSS
|
||||
from .localization import localize
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .elements._svg import SvgDocumentElement
|
||||
from .elements._base import BaseElement
|
||||
|
||||
|
||||
class InkscapeExtension:
|
||||
"""
|
||||
The base class extension, provides argument parsing and basic
|
||||
variable handling features.
|
||||
"""
|
||||
|
||||
multi_inx = False # Set to true if this class is used by multiple inx files.
|
||||
extra_nss = {} # type: Dict[str, str]
|
||||
|
||||
# Provide a unique value to allow detection of no argument specified
|
||||
# for `output` parameter of `run()`, not even `None`; this has to be an io
|
||||
# type for type checking purposes:
|
||||
output_unspecified = io.StringIO("")
|
||||
|
||||
def __init__(self):
|
||||
# type: () -> None
|
||||
NSS.update(self.extra_nss)
|
||||
self.file_io = None # type: Optional[IO]
|
||||
self.options = Namespace()
|
||||
self.document = None # type: Union[None, bytes, str, etree.element]
|
||||
self.arg_parser = ArgumentParser(description=self.__doc__)
|
||||
|
||||
self.arg_parser.add_argument(
|
||||
"input_file",
|
||||
nargs="?",
|
||||
metavar="INPUT_FILE",
|
||||
type=filename_arg,
|
||||
help="Filename of the input file (default is stdin)",
|
||||
default=None,
|
||||
)
|
||||
|
||||
self.arg_parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional output filename for saving the result (default is stdout).",
|
||||
)
|
||||
|
||||
self.add_arguments(self.arg_parser)
|
||||
|
||||
localize()
|
||||
|
||||
def add_arguments(self, pars):
|
||||
# type: (ArgumentParser) -> None
|
||||
"""Add any extra arguments to your extension handle, use:
|
||||
|
||||
def add_arguments(self, pars):
|
||||
pars.add_argument("--num-cool-things", type=int, default=3)
|
||||
pars.add_argument("--pos-in-doc", type=str, default="doobry")
|
||||
"""
|
||||
# No extra arguments by default so super is not required
|
||||
|
||||
def parse_arguments(self, args):
|
||||
# type: (List[str]) -> None
|
||||
"""Parse the given arguments and set 'self.options'"""
|
||||
self.options = self.arg_parser.parse_args(args)
|
||||
|
||||
def arg_method(self, prefix="method"):
|
||||
# type: (str) -> Callable[[str], Callable[[Any], Any]]
|
||||
"""Used by add_argument to match a tab selection with an object method
|
||||
|
||||
pars.add_argument("--tab", type=self.arg_method(), default="foo")
|
||||
...
|
||||
self.options.tab(arguments)
|
||||
...
|
||||
.. code-block:: python
|
||||
.. def method_foo(self, arguments):
|
||||
.. # do something
|
||||
"""
|
||||
|
||||
def _inner(value):
|
||||
name = f"""{prefix}_{value.strip('"').lower()}""".replace("-", "_")
|
||||
try:
|
||||
return getattr(self, name)
|
||||
except AttributeError as error:
|
||||
if name.startswith("_"):
|
||||
return do_nothing
|
||||
raise AbortExtension(f"Can not find method {name}") from error
|
||||
|
||||
return _inner
|
||||
|
||||
@staticmethod
|
||||
def arg_number_ranges():
|
||||
"""Parses a number descriptor. e.g:
|
||||
``1,2,4-5,7,9-`` is parsed to ``1, 2, 4, 5, 7, 9, 10, ..., lastvalue``
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# in add_arguments()
|
||||
pars.add_argument("--pages", type=self.arg_number_ranges(), default=1-)
|
||||
# later on, pages is then a list of ints
|
||||
pages = self.options.pages(lastvalue)
|
||||
|
||||
"""
|
||||
|
||||
def _inner(value):
|
||||
def method(pages, lastvalue, startvalue=1):
|
||||
# replace ranges, such as -3, 10- with startvalue,2,3,10..lastvalue
|
||||
pages = re.sub(
|
||||
r"(\d+|)\s?-\s?(\d+|)",
|
||||
lambda m: (
|
||||
",".join(
|
||||
map(
|
||||
str,
|
||||
range(
|
||||
int(m.group(1) or startvalue),
|
||||
int(m.group(2) or lastvalue) + 1,
|
||||
),
|
||||
)
|
||||
)
|
||||
if not (m.group(1) or m.group(2)) == ""
|
||||
else ""
|
||||
),
|
||||
pages,
|
||||
)
|
||||
|
||||
pages = map(int, re.findall(r"(\d+)", pages))
|
||||
pages = tuple({i for i in pages if i <= lastvalue})
|
||||
return pages
|
||||
|
||||
return lambda lastvalue, startvalue=1: method(
|
||||
value, lastvalue, startvalue=startvalue
|
||||
)
|
||||
|
||||
return _inner
|
||||
|
||||
@staticmethod
|
||||
def arg_class(options: List[Type]) -> Callable[[str], Any]:
|
||||
"""Used by add_argument to match an option with a class
|
||||
|
||||
Types to choose from are given by the options list
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
Usage:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
pars.add_argument("--class", type=self.arg_class([ClassA, ClassB]),
|
||||
default="ClassA")
|
||||
"""
|
||||
|
||||
def _inner(value: str):
|
||||
name = value.strip('"')
|
||||
for i in options:
|
||||
if name == i.__name__:
|
||||
return i
|
||||
raise AbortExtension(f"Can not find class {name}")
|
||||
|
||||
return _inner
|
||||
|
||||
def debug(self, msg):
|
||||
# type: (str) -> None
|
||||
"""Write a debug message"""
|
||||
errormsg(f"DEBUG<{type(self).__name__}> {msg}\n")
|
||||
|
||||
@staticmethod
|
||||
def msg(msg):
|
||||
# type: (str) -> None
|
||||
"""Write a non-error message"""
|
||||
errormsg(msg)
|
||||
|
||||
def run(self, args=None, output=output_unspecified):
|
||||
# type: (Optional[List[str]], Union[str, IO]) -> None
|
||||
"""Main entrypoint for any Inkscape Extension"""
|
||||
try:
|
||||
if args is None:
|
||||
args = sys.argv[1:]
|
||||
|
||||
self.parse_arguments(args)
|
||||
if self.options.input_file is None:
|
||||
self.options.input_file = sys.stdin
|
||||
elif "DOCUMENT_PATH" not in os.environ:
|
||||
os.environ["DOCUMENT_PATH"] = self.options.input_file
|
||||
|
||||
self.bin_stdout = None
|
||||
if self.options.output is None:
|
||||
# If no output was specified, attempt to extract a binary
|
||||
# output from stdout, and if that doesn't seem possible,
|
||||
# punt and try whatever stream stdout is:
|
||||
if output is InkscapeExtension.output_unspecified:
|
||||
output = sys.stdout
|
||||
if "b" not in getattr(output, "mode", "") and not isinstance(
|
||||
output, (io.RawIOBase, io.BufferedIOBase)
|
||||
):
|
||||
if hasattr(output, "buffer"):
|
||||
output = output.buffer # type: ignore
|
||||
elif hasattr(output, "fileno"):
|
||||
self.bin_stdout = os.fdopen(
|
||||
output.fileno(), "wb", closefd=False
|
||||
)
|
||||
output = self.bin_stdout
|
||||
self.options.output = output
|
||||
|
||||
self.load_raw()
|
||||
self.save_raw(self.effect())
|
||||
except AbortExtension as err:
|
||||
errormsg(str(err))
|
||||
sys.exit(ABORT_STATUS)
|
||||
finally:
|
||||
self.clean_up()
|
||||
|
||||
def load_raw(self):
|
||||
# type: () -> None
|
||||
"""Load the input stream or filename, save everything to self"""
|
||||
if isinstance(self.options.input_file, str):
|
||||
# pylint: disable=consider-using-with
|
||||
self.file_io = open(self.options.input_file, "rb")
|
||||
document = self.load(self.file_io)
|
||||
else:
|
||||
document = self.load(self.options.input_file)
|
||||
self.document = document
|
||||
|
||||
def save_raw(self, ret):
|
||||
# type: (Any) -> None
|
||||
"""Save to the output stream, use everything from self"""
|
||||
if self.has_changed(ret):
|
||||
if isinstance(self.options.output, str):
|
||||
with open(self.options.output, "wb") as stream:
|
||||
self.save(stream)
|
||||
else:
|
||||
if sys.platform == "win32" and not "PYTEST_CURRENT_TEST" in os.environ:
|
||||
# When calling an extension from within Inkscape on Windows,
|
||||
# Python thinks that the output stream is seekable
|
||||
# (https://gitlab.com/inkscape/inkscape/-/issues/3273)
|
||||
self.options.output.seekable = lambda self: False
|
||||
|
||||
def seek_replacement(offset: int, whence: int = 0):
|
||||
raise AttributeError(
|
||||
"We can't seek in the stream passed by Inkscape on Windows"
|
||||
)
|
||||
|
||||
def tell_replacement():
|
||||
raise AttributeError(
|
||||
"We can't tell in the stream passed by Inkscape on Windows"
|
||||
)
|
||||
|
||||
# Some libraries (e.g. ZipFile) don't query seekable, but check for an error
|
||||
# on seek
|
||||
self.options.output.seek = seek_replacement
|
||||
self.options.output.tell = tell_replacement
|
||||
self.save(self.options.output)
|
||||
|
||||
def load(self, stream):
|
||||
# type: (IO) -> str
|
||||
"""Takes the input stream and creates a document for parsing"""
|
||||
raise NotImplementedError(f"No input handle for {self.name}")
|
||||
|
||||
def save(self, stream):
|
||||
# type: (IO) -> None
|
||||
"""Save the given document to the output file"""
|
||||
raise NotImplementedError(f"No output handle for {self.name}")
|
||||
|
||||
def effect(self):
|
||||
# type: () -> Any
|
||||
"""Apply some effects on the document or local context"""
|
||||
raise NotImplementedError(f"No effect handle for {self.name}")
|
||||
|
||||
def has_changed(self, ret): # pylint: disable=no-self-use
|
||||
# type: (Any) -> bool
|
||||
"""Return true if the output should be saved"""
|
||||
return ret is not False
|
||||
|
||||
def clean_up(self):
|
||||
# type: () -> None
|
||||
"""Clean up any open handles and other items"""
|
||||
if hasattr(self, "bin_stdout"):
|
||||
if self.bin_stdout is not None:
|
||||
self.bin_stdout.close()
|
||||
if self.file_io is not None:
|
||||
self.file_io.close()
|
||||
|
||||
@classmethod
|
||||
def svg_path(cls, default=None):
|
||||
# type: (Optional[str]) -> Optional[str]
|
||||
"""
|
||||
Return the folder the svg is contained in.
|
||||
Returns None if there is no file.
|
||||
|
||||
.. versionchanged:: 1.1
|
||||
A default path can be given which is returned in case no path to the
|
||||
SVG file can be determined.
|
||||
"""
|
||||
path = cls.document_path()
|
||||
if path:
|
||||
return os.path.dirname(path)
|
||||
if default:
|
||||
return default
|
||||
return path # Return None or '' for context
|
||||
|
||||
@classmethod
|
||||
def ext_path(cls):
|
||||
# type: () -> str
|
||||
"""Return the folder the extension script is in"""
|
||||
return os.path.dirname(sys.modules[cls.__module__].__file__ or "")
|
||||
|
||||
@classmethod
|
||||
def get_resource(cls, name, abort_on_fail=True):
|
||||
# type: (str, bool) -> str
|
||||
"""Return the full filename of the resource in the extension's dir
|
||||
|
||||
.. versionadded:: 1.1"""
|
||||
filename = cls.absolute_href(name, cwd=cls.ext_path())
|
||||
if abort_on_fail and not os.path.isfile(filename):
|
||||
raise AbortExtension(f"Could not find resource file: {filename}")
|
||||
return filename
|
||||
|
||||
@classmethod
|
||||
def document_path(cls):
|
||||
# type: () -> Optional[str]
|
||||
"""Returns the saved location of the document
|
||||
|
||||
* Normal return is a string containing the saved location
|
||||
* Empty string means the document was never saved
|
||||
* 'None' means this version of Inkscape doesn't support DOCUMENT_PATH
|
||||
|
||||
DO NOT READ OR WRITE TO THE DOCUMENT FILENAME!
|
||||
|
||||
* Inkscape may have not written the latest changes, leaving you reading old
|
||||
data.
|
||||
* Inkscape will not respect anything you write to the file, causing data loss.
|
||||
|
||||
.. versionadded:: 1.1
|
||||
"""
|
||||
return os.environ.get("DOCUMENT_PATH", None)
|
||||
|
||||
@classmethod
|
||||
def absolute_href(cls, filename, default="~/", cwd=None):
|
||||
# type: (str, str, Optional[str]) -> str
|
||||
"""
|
||||
Process the filename such that it's turned into an absolute filename
|
||||
with the working directory being the directory of the loaded svg.
|
||||
|
||||
User's home folder is also resolved. So '~/a.png` will be `/home/bob/a.png`
|
||||
|
||||
Default is a fallback working directory to use if the svg's filename is not
|
||||
available.
|
||||
|
||||
.. versionchanged:: 1.1
|
||||
If you set default to None, then the user will be given errors if
|
||||
there's no working directory available from Inkscape.
|
||||
"""
|
||||
filename = os.path.expanduser(filename)
|
||||
if not os.path.isabs(filename):
|
||||
filename = os.path.expanduser(filename)
|
||||
if not os.path.isabs(filename):
|
||||
if cwd is None:
|
||||
cwd = cls.svg_path(default)
|
||||
if cwd is None:
|
||||
raise AbortExtension(
|
||||
"Can not use relative path, Inkscape isn't telling us the "
|
||||
"current working directory."
|
||||
)
|
||||
if cwd == "":
|
||||
raise AbortExtension(
|
||||
"The SVG must be saved before you can use relative paths."
|
||||
)
|
||||
filename = os.path.join(cwd, filename)
|
||||
return os.path.realpath(os.path.expanduser(filename))
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
# type: () -> str
|
||||
"""Return a fixed name for this extension"""
|
||||
return type(self).__name__
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
_Base = InkscapeExtension
|
||||
else:
|
||||
_Base = object
|
||||
|
||||
|
||||
class TempDirMixin(_Base): # pylint: disable=abstract-method
|
||||
"""
|
||||
Provide a temporary directory for extensions to stash files.
|
||||
"""
|
||||
|
||||
dir_suffix = ""
|
||||
dir_prefix = "inktmp"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.tempdir = None
|
||||
self._tempdir = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def load_raw(self):
|
||||
# type: () -> None
|
||||
"""Create the temporary directory"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
# Need to hold a reference to the Directory object or else it might get GC'd
|
||||
self._tempdir = TemporaryDirectory( # pylint: disable=consider-using-with
|
||||
prefix=self.dir_prefix, suffix=self.dir_suffix
|
||||
)
|
||||
self.tempdir = os.path.realpath(self._tempdir.name)
|
||||
super().load_raw()
|
||||
|
||||
def clean_up(self):
|
||||
# type: () -> None
|
||||
"""Delete the temporary directory"""
|
||||
self.tempdir = None
|
||||
# if the file does not exist, _tempdir is never set.
|
||||
if self._tempdir is not None:
|
||||
self._tempdir.cleanup()
|
||||
super().clean_up()
|
||||
|
||||
|
||||
class SvgInputMixin(_Base): # pylint: disable=too-few-public-methods, abstract-method
|
||||
"""
|
||||
Expects the file input to be an svg document and will parse it.
|
||||
"""
|
||||
|
||||
# Select all objects if none are selected
|
||||
select_all: Tuple[Type["BaseElement"], ...] = ()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
self.arg_parser.add_argument(
|
||||
"--id",
|
||||
action="append",
|
||||
type=str,
|
||||
dest="ids",
|
||||
default=[],
|
||||
help="id attribute of object to manipulate",
|
||||
)
|
||||
|
||||
self.arg_parser.add_argument(
|
||||
"--selected-nodes",
|
||||
action="append",
|
||||
type=str,
|
||||
dest="selected_nodes",
|
||||
default=[],
|
||||
help="id:subpath:position of selected nodes, if any",
|
||||
)
|
||||
|
||||
def load(self, stream):
|
||||
# type: (IO) -> etree
|
||||
"""Load the stream as an svg xml etree and make a backup"""
|
||||
document = load_svg(stream)
|
||||
self.original_document = copy.deepcopy(document)
|
||||
self.svg: SvgDocumentElement = document.getroot()
|
||||
self.svg.selection.set(*self.options.ids)
|
||||
if not self.svg.selection and self.select_all:
|
||||
self.svg.selection = self.svg.descendants().filter(*self.select_all)
|
||||
return document
|
||||
|
||||
|
||||
class SvgOutputMixin(_Base): # pylint: disable=too-few-public-methods, abstract-method
|
||||
"""
|
||||
Expects the output document to be an svg document and will write an etree xml.
|
||||
|
||||
A template can be specified to kick off the svg document building process.
|
||||
"""
|
||||
|
||||
template = """<svg viewBox="0 0 {width} {height}"
|
||||
width="{width}{unit}" height="{height}{unit}"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
|
||||
</svg>"""
|
||||
|
||||
@classmethod
|
||||
def get_template(cls, **kwargs):
|
||||
"""
|
||||
Opens a template svg document for building, the kwargs
|
||||
MUST include all the replacement values in the template, the
|
||||
default template has 'width' and 'height' of the document.
|
||||
"""
|
||||
kwargs.setdefault("unit", "")
|
||||
return load_svg(str(cls.template.format(**kwargs)))
|
||||
|
||||
def save(self, stream):
|
||||
# type: (IO) -> None
|
||||
"""Save the svg document to the given stream"""
|
||||
if isinstance(self.document, (bytes, str)):
|
||||
document = self.document
|
||||
elif "Element" in type(self.document).__name__:
|
||||
# isinstance can't be used here because etree is broken
|
||||
doc = cast(etree, self.document)
|
||||
document = doc.getroot().tostring()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown type of document: {type(self.document).__name__} can not"
|
||||
+ "save."
|
||||
)
|
||||
|
||||
try:
|
||||
stream.write(document)
|
||||
except TypeError:
|
||||
# we hope that this happens only when document needs to be encoded
|
||||
stream.write(document.encode("utf-8")) # type: ignore
|
||||
|
||||
|
||||
class SvgThroughMixin(SvgInputMixin, SvgOutputMixin): # pylint: disable=abstract-method
|
||||
"""
|
||||
Combine the input and output svg document handling (usually for effects).
|
||||
"""
|
||||
|
||||
def has_changed(self, ret): # pylint: disable=unused-argument
|
||||
# type: (Any) -> bool
|
||||
"""Return true if the svg document has changed"""
|
||||
original = etree.tostring(self.original_document)
|
||||
result = etree.tostring(self.document)
|
||||
return original != result
|
||||
Reference in New Issue
Block a user