summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/base.py
blob: 63f92187a6a989a651b390f106bd824dffa7e619 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
# 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 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 .interfaces.IElement import IBaseElement, ISVGDocumentElement
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

stdout = sys.stdout.buffer  # type: ignore


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]

    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=stdout):
        # 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

            if self.options.output is None:
                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 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
        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
        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[IBaseElement], ...] = ()

    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: ISVGDocumentElement = 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