bundle: update (2026-01-18)

This commit is contained in:
2026-01-18 04:47:51 +00:00
parent 822898e4a3
commit 7597f2f156
223 changed files with 31167 additions and 0 deletions

View 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