Collection of mostly command line tools / PHP scripts. Somewhat out of date.

⌈⌋ branch:  scripts + snippets


Check-in [af566d903e]

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Overview
Comment:prepare magick v7 support, minor parameter rework, dissolve duration_arg(), experiment with more convert options
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA1: af566d903e86f9a751621a03b607e842a3a40b3c
User & Date: mario 2022-10-09 16:36:20
Context
2022-10-10
07:45
translatable=no for combo boxes check-in: 33dbf985e5 user: mario tags: trunk
2022-10-09
16:36
prepare magick v7 support, minor parameter rework, dissolve duration_arg(), experiment with more convert options check-in: af566d903e user: mario tags: trunk
2022-10-08
17:41
hex_color int(alpha), -background None fallback (semi-transparency likely requires a `-channel A -ordered-dither checker` pattern) check-in: c3d85c855b user: mario tags: trunk
Changes
Hide Diffs Unified Diffs Ignore Whitespace Patch

Changes to inkscape/export_gif.py.

8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# id: org.include-once.inkscape.export-gif
# license: MITL
# version: 1.1
# state: stable
# depends: bin:inkscape (>= 1.1), bin:convert, python (>= 3.6), python:svgelements
# pylint: disable=line-too-long, missing-module-docstring, bad-whitespace
# config:
#    { name: file, type: file, mode: file, value: "~/anim.gif", description: "Target GIF filename", help: "Output filename" }
#    { name: mode, type: select, select: "PNG→ImageMagick (better quality)|SVG→ImageMagick (simple drawings)|PNG→Pillow (builtin - a bit faster)|JavaScript→SVG (embed anim code)", value: PNG+ImageMagick, description: Conversion mode, help: "There's different backends to assemble the GIF. Pillow also works on Windows without convert.exe installed. The JavaScript mode embeds some code, does not actually generate a GIF output file." }
#    { name: delay, type: float, value: 0.35, min: 0.01, max: 20, precision: 2, description: "Delay between slides (seconds)", "Can be overriden on a per-slide basis with [--delay=2.5]. Note that convert(1) usually uses »ticks« (*10ms), but this export_gif option is really in seconds." }
#    { name: loop, type: int, value: 0, description: "Loop limit (0 for endless)" }
#    [ hidden: 1, disabled: save_as, type: str, value: "0", description: used in saveas_gif.inx, note: "have to use `str` here, cause .inx `false` is not recognized for hidden attributes" ]
#    { xml: <image width="505" height="50">export_gif.svg</image> }
#    { name: notebook, type: notebook, value: system, description: "Additional flags and help" }
#    { category: ImageMagick, name: fuzz, type: select, select: "0%|5%|10%|20%|30%|50%", value: "10%", description: "Fuzzing/dither", help: "Detect similar colors for optimization" }
#    { category: ImageMagick, name: layers, type: select, select: "optimize|coalesce|compare-any|compare-clear|compare-overlay|composite|dispose|flatten|merge|mosaic|optimize-frame|optimize-plus|optimize-transparency|remove-dups|remove-zero|trim-bounds", value: "optimize", description: "Accumulation/combination of -layers", help: "Can influence optimization or layer combination. Specifically coalesce/merge are pre-interpreted by export_gif to produce accumulating slides." }
#    { category: ImageMagick, name: extra, type: select, select: "-quiet|-resize 640x360|-alpha background|-auto-gamma|-auto-level|-coalesce|-colors 64|-dither FloydSteinberg|-limit disk 1MB|-reverse|-monochrome|-transparent white", value: "", description: "Extra args", help: "Some default convert -args." }
#    { category: ImageMagick, name: extra2, type: str, value: "", description: "Custom args", help: "Specify additional convert(1) options." }
#    { category: ImageMagick, name: background, type: color, appearance: colorbutton, value: "0", description: "Background color for transparent layers", help: "Only works for SVG→ImageMagick option." }
#    { category: System, name: preview, type: bool, value: 0, description: "Preview result file", help: "Should bring up default image viewer on resulting GIF (via xdg-open, or start… on Windows)" }
#    { category: System, name: keep_tmp, type: bool, value: 0, description: "Keep temporary files", help: "Will retain the frame PNGs in the fixed directory /tmp/inkscape.export_gif/" }
#    { category: System, name: reload_svg, type: bool, value: 0, description: "Reload SVG in Inkscape", help: "Averts the warning popup of lacking result data. But is quite redundant for this tool. And should only be enabled if it's becoming too obnoxious." }
#    { category: System, name: export_background, type: bool, value: 0, description: "Force background application in PNG export", help: "Uses --export-background for generating PNG slides prior assembly. (See ImageMagick for color tab.)" }
#    { category: Animation, name: subframes, type: int, value: 5, description: "Subframes per ❮animation❯ slide", help: "Can be overridden per [animate=25] or [steps=25] in layer label." }






|








|







8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# id: org.include-once.inkscape.export-gif
# license: MITL
# version: 1.1
# state: stable
# depends: bin:inkscape (>= 1.1), bin:convert, python (>= 3.6), python:svgelements
# pylint: disable=line-too-long, missing-module-docstring, bad-whitespace
# config:
#    { name: file, type: file, mode: file, value: "~/anim.gif", description: "Target GIF filename", help: "Output filename should end in .gif for actual results. Alternatively can be `APNG:dir/filename.apng` with ImageMagick 7." }
#    { name: mode, type: select, select: "PNG→ImageMagick (better quality)|SVG→ImageMagick (simple drawings)|PNG→Pillow (builtin - a bit faster)|JavaScript→SVG (embed anim code)", value: PNG+ImageMagick, description: Conversion mode, help: "There's different backends to assemble the GIF. Pillow also works on Windows without convert.exe installed. The JavaScript mode embeds some code, does not actually generate a GIF output file." }
#    { name: delay, type: float, value: 0.35, min: 0.01, max: 20, precision: 2, description: "Delay between slides (seconds)", "Can be overriden on a per-slide basis with [--delay=2.5]. Note that convert(1) usually uses »ticks« (*10ms), but this export_gif option is really in seconds." }
#    { name: loop, type: int, value: 0, description: "Loop limit (0 for endless)" }
#    [ hidden: 1, disabled: save_as, type: str, value: "0", description: used in saveas_gif.inx, note: "have to use `str` here, cause .inx `false` is not recognized for hidden attributes" ]
#    { xml: <image width="505" height="50">export_gif.svg</image> }
#    { name: notebook, type: notebook, value: system, description: "Additional flags and help" }
#    { category: ImageMagick, name: fuzz, type: select, select: "0%|5%|10%|20%|30%|50%", value: "10%", description: "Fuzzing/dither", help: "Detect similar colors for optimization" }
#    { category: ImageMagick, name: layers, type: select, select: "optimize|coalesce|compare-any|compare-clear|compare-overlay|composite|dispose|flatten|merge|mosaic|optimize-frame|optimize-plus|optimize-transparency|remove-dups|remove-zero|trim-bounds", value: "optimize", description: "Accumulation/combination of -layers", help: "Can influence optimization or layer combination. Specifically coalesce/merge are pre-interpreted by export_gif to produce accumulating slides." }
#    { category: ImageMagick, name: extra, type: select, select: "-quiet|-optimize|-size 640x360|-alpha background|-auto-gamma|-auto-level|-coalesce|-colors 64|-dither FloydSteinberg|-limit disk 1MB|-reverse|-monochrome|-transparent white", value: "", description: "Extra args", help: "Some default convert -args." }
#    { category: ImageMagick, name: extra2, type: str, value: "", description: "Custom args", help: "Specify additional convert(1) options." }
#    { category: ImageMagick, name: background, type: color, appearance: colorbutton, value: "0", description: "Background color for transparent layers", help: "Only works for SVG→ImageMagick option." }
#    { category: System, name: preview, type: bool, value: 0, description: "Preview result file", help: "Should bring up default image viewer on resulting GIF (via xdg-open, or start… on Windows)" }
#    { category: System, name: keep_tmp, type: bool, value: 0, description: "Keep temporary files", help: "Will retain the frame PNGs in the fixed directory /tmp/inkscape.export_gif/" }
#    { category: System, name: reload_svg, type: bool, value: 0, description: "Reload SVG in Inkscape", help: "Averts the warning popup of lacking result data. But is quite redundant for this tool. And should only be enabled if it's becoming too obnoxious." }
#    { category: System, name: export_background, type: bool, value: 0, description: "Force background application in PNG export", help: "Uses --export-background for generating PNG slides prior assembly. (See ImageMagick for color tab.)" }
#    { category: Animation, name: subframes, type: int, value: 5, description: "Subframes per ❮animation❯ slide", help: "Can be overridden per [animate=25] or [steps=25] in layer label." }
107
108
109
110
111
112
113

114
115
116
117
118
119
120
import os
import tempfile
from argparse import Namespace
import re
import functools
import textwrap
import shlex

import math
import inkex
from inkex.utils import strargs
from inkex.tween import StyleInterpolator, TransformInterpolator
from inkex.transforms import Transform








>







107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import os
import tempfile
from argparse import Namespace
import re
import functools
import textwrap
import shlex
import shutil
import math
import inkex
from inkex.utils import strargs
from inkex.tween import StyleInterpolator, TransformInterpolator
from inkex.transforms import Transform


169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
        if not self.options.keep_tmp:
            os.unlink(self.options.file)

    def effect(self):
        """ Generate output file when invoked """
        layers = list(self.get_layers())
        fixed_layers = [layer.id for layer in layers if layer.is_fixed]
        temp_files_args = [] # tuples of filename,-args

        # more parameter handling
        if re.search("JavaScript", self.options.mode, re.I):
            return self.inject_javascript()
        self.make_temp()
        up_until_mode = re.search("merge|compo|coal|plus", self.options.layers)
        preconvert_png = re.search("PNG", self.options.mode, re.I)






|







170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
        if not self.options.keep_tmp:
            os.unlink(self.options.file)

    def effect(self):
        """ Generate output file when invoked """
        layers = list(self.get_layers())
        fixed_layers = [layer.id for layer in layers if layer.is_fixed]
        temp_files_args = [] # tuples of filename,{-args}

        # more parameter handling
        if re.search("JavaScript", self.options.mode, re.I):
            return self.inject_javascript()
        self.make_temp()
        up_until_mode = re.search("merge|compo|coal|plus", self.options.layers)
        preconvert_png = re.search("PNG", self.options.mode, re.I)
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
            if layer.animate or self.is_animatable(layer):
                # produces multiple subframes
                anim = AnimationSteps(
                    parent = self, layer = layer,
                    svg = self.export_layers(dest="", show=[layer.id] + fixed_layers + merge_layers)
                )
                temp_files_args.extend(list(anim.steps()))
            else:
                # current frame into .svg/.png
                dest = self.export_layers(
                    dest = f"{self.tempdir}/{self.index}.{layer.label}.{img_ext}",
                    show = [layer.id] + fixed_layers + merge_layers
                )
                temp_files_args.append([dest, layer.args])






|







198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
            if layer.animate or self.is_animatable(layer):
                # produces multiple subframes
                anim = AnimationSteps(
                    parent = self, layer = layer,
                    svg = self.export_layers(dest="", show=[layer.id] + fixed_layers + merge_layers)
                )
                temp_files_args.extend(list(anim.export()))
            else:
                # current frame into .svg/.png
                dest = self.export_layers(
                    dest = f"{self.tempdir}/{self.index}.{layer.label}.{img_ext}",
                    show = [layer.id] + fixed_layers + merge_layers
                )
                temp_files_args.append([dest, layer.args])
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
            os.system(('start %s' if self.win32 else 'xdg-open %r 2>&1 &') % self.options.file)

        if self.options.save_as:
            self.save(self.options.output)
        elif self.options.reload_svg:
            inkex.base.SvgOutputMixin.save(self, self.options.output)










    def convert_png(self, svg_path, png_path):
        """ Use the convenience wrapper (more likely referencing the current inkscape binary) """
        extra = {}
        if self.options.background and self.options.export_background:
            color = inkex.Color(self.options.background)
            extra.update({
                "export_background": self.hex_color(color, with_alpha=False),
                "export_background_opacity": "{:.2f}".format(color.alpha),


            })
        inkex.command.inkscape(
            svg_path, export_area_page=True, export_type="png",
            export_filename=png_path, export_text_to_path=True,
            **extra,
            #export_dpi=96, --export-width  --export-height
        )

    def imagick_gif(self, temp_files_args):
        """ Run `convert` using list of exported slides """
        inkex.command.call(*[
            "convert",
            "-delay", float(self.options.delay) * 100,
            "-loop", self.options.loop,
            "-layers", self.options.layers,
            "-fuzz", self.options.fuzz,   # ToDo: distribute some flags into temp_files_args?
            "-background", self.hex_color(self.options.background) if self.options.background else "None",
            *shlex.split(self.options.extra),
            *shlex.split(self.options.extra2),
            *self.zip_args(temp_files_args),
            *[os.path.expanduser(self.options.file)]
        ])

    @staticmethod
    def hex_color(color, with_alpha=True):
        """ convert ints or inkex.Color tuple to #RRGGBBAA string """
        color = inkex.Color(color)
        formatted = "#{:02X}{:02X}{:02X}".format(*color.to_rgb())
        if with_alpha:
            formatted += "{:02X}".format(int(255 * min(1.0, color.alpha)))
        return formatted

    @staticmethod
    def zip_args(temp_files_args):
        """ unzip files and possible -args (fitted list of dicts) """
        for filename, arg_list in temp_files_args:
            for key, val in arg_list.items():
                if key == "write":
                    continue






>
>
>
>
>
>
>
>
>








>
>


<
|
|






|



|







<
<
<
<
<
<
<
<
<







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
            os.system(('start %s' if self.win32 else 'xdg-open %r 2>&1 &') % self.options.file)

        if self.options.save_as:
            self.save(self.options.output)
        elif self.options.reload_svg:
            inkex.base.SvgOutputMixin.save(self, self.options.output)

    @staticmethod
    def hex_color(color, with_alpha=True):
        """ convert ints or inkex.Color tuple to #RRGGBBAA string """
        color = inkex.Color(color)
        formatted = "#{:02X}{:02X}{:02X}".format(*color.to_rgb())
        if with_alpha:
            formatted += "{:02X}".format(int(255 * min(1.0, color.alpha)))
        return formatted

    def convert_png(self, svg_path, png_path):
        """ Use the convenience wrapper (more likely referencing the current inkscape binary) """
        extra = {}
        if self.options.background and self.options.export_background:
            color = inkex.Color(self.options.background)
            extra.update({
                "export_background": self.hex_color(color, with_alpha=False),
                "export_background_opacity": "{:.2f}".format(color.alpha),
                # 5%/compareOverlay/-coalesce per channel [--alpha=set] [--channel=A] [--ordered-dither=checks] has some effect
                # compareClear/-coalesce [--channel=A --ordered-dither=4x4 --dispose=-1]
            })
        inkex.command.inkscape(

            svg_path, export_filename=png_path, export_type="png",
            export_area_page=True, export_text_to_path=True, **extra,
            #export_dpi=96, --export-width  --export-height
        )

    def imagick_gif(self, temp_files_args):
        """ Run `convert` using list of exported slides """
        inkex.command.call(*[
            shutil.which("magick") or "convert",
            "-delay", float(self.options.delay) * 100,
            "-loop", self.options.loop,
            "-layers", self.options.layers,
            "-fuzz", self.options.fuzz,   # ToDo: distribute some flags into temp_files_args? (-blur -colors -compose -cycle -fx -gamma -grayscale -ordered-dither -resample -resize -transparent )
            "-background", self.hex_color(self.options.background) if self.options.background else "None",
            *shlex.split(self.options.extra),
            *shlex.split(self.options.extra2),
            *self.zip_args(temp_files_args),
            *[os.path.expanduser(self.options.file)]
        ])










    @staticmethod
    def zip_args(temp_files_args):
        """ unzip files and possible -args (fitted list of dicts) """
        for filename, arg_list in temp_files_args:
            for key, val in arg_list.items():
                if key == "write":
                    continue
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
        frames[0].save(
            os.path.expanduser(self.options.file),
            save_all = True,
            append_images = frames[1:],
            version = "GIF89a",
            optimize = True,
            disposal = 1, # no disposal
            duration = list(self.duration_args(temp_files_args, self.options.delay)),
            loop = self.options.loop,
        )

    @staticmethod
    def duration_args(temp_files_args, default):
        """ make a pillow duration= list from optional delay values """
        for filename, args in temp_files_args: # pylint: disable=unused-variable
            yield float(args.get("delay", default)) * 1000

    def clean_up(self):
        """ remove tmp files + dir, automatically invoked by base.run() """
        super().clean_up()
        if self.options.keep_tmp or not self.tempdir or not os.path.exists(self.tempdir):
            return
        for file_name in os.listdir(self.tempdir):
            os.unlink(self.tempdir + "/" + file_name)






|



<
<
<
<
<
<







289
290
291
292
293
294
295
296
297
298
299






300
301
302
303
304
305
306
        frames[0].save(
            os.path.expanduser(self.options.file),
            save_all = True,
            append_images = frames[1:],
            version = "GIF89a",
            optimize = True,
            disposal = 1, # no disposal
            duration = [float(args[1].get("delay", self.options.delay)) * 1000 for args in temp_files_args],
            loop = self.options.loop,
        )







    def clean_up(self):
        """ remove tmp files + dir, automatically invoked by base.run() """
        super().clean_up()
        if self.options.keep_tmp or not self.tempdir or not os.path.exists(self.tempdir):
            return
        for file_name in os.listdir(self.tempdir):
            os.unlink(self.tempdir + "/" + file_name)
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
        with open(dest, "wb") as svg_fp:
            svg_fp.write(svg.tostring())
        if dest_png:
            self.convert_png(dest, dest_png)
        return dest_png or dest

    def get_layers(self):
        """ All layers are exported, only [fixed]/[background] layer labels get special treatment """
        for layer in self.svg.xpath('./svg:g[@inkscape:groupmode="layer"][@inkscape:label]'):

            label = layer.attrib["{http://www.inkscape.org/namespaces/inkscape}label"]
            animate = re.findall(r"\[(?:steps|animate)(?:=(\d+))?\]", label)

            yield Namespace(
                id = layer.attrib["id"],
                label = re.sub(r"\W+", "_", label),
                is_fixed = re.search(r"fixed|\[(is_?fixe?d?|foreground)\]", label),
                is_sticky = re.search(r"background|\[(bg)\]", label),
                no_frame = re.search(r"fixed|\[(merge|no_frame|exclude)\]", label),
                do_merge = re.search(r"\[(merge|join|group)\]", label),
                animate = int(([num for num in animate if num] or [self.options.subframes])[0]) if animate else 0,
                tags = re.findall(r"\[(\w+)\]", label),
                args = dict(re.findall(r"--(\w[\w\-]+)=(\w[^\s,;\]\)]*)", label))
            )

    def inject_javascript(self):
        """ insert JavaScript version into SVG document """
        script = inkex.Script()






|








|
|
|
|
|







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
        with open(dest, "wb") as svg_fp:
            svg_fp.write(svg.tostring())
        if dest_png:
            self.convert_png(dest, dest_png)
        return dest_png or dest

    def get_layers(self):
        """ Per default all layers are exported, label tags [bg], [exclude], [merge] yield special treatment """
        for layer in self.svg.xpath('./svg:g[@inkscape:groupmode="layer"][@inkscape:label]'):

            label = layer.attrib["{http://www.inkscape.org/namespaces/inkscape}label"]
            animate = re.findall(r"\[(?:steps|animate)(?:=(\d+))?\]", label)

            yield Namespace(
                id = layer.attrib["id"],
                label = re.sub(r"\W+", "_", label),
                is_fixed = bool(re.search(r"fixed|\[(is_?fixe?d?|foreground)\]", label)),
                is_sticky = bool(re.search(r"background|\[(bg)\]", label)),
                no_frame = bool(re.search(r"fixed|\[(merge|no_frame|exclude)\]", label)),
                do_merge = bool(re.search(r"\[(merge|join|group)\]", label)),
                animate = int(([i for i in animate if i] or [self.options.subframes])[0]) if animate else 0,
                tags = re.findall(r"\[(\w+)\]", label),
                args = dict(re.findall(r"--(\w[\w\-]+)=(\w[^\s,;\]\)]*)", label))
            )

    def inject_javascript(self):
        """ insert JavaScript version into SVG document """
        script = inkex.Script()
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
        self.layer = layer
        self.frames = layer.animate
        self.delay = float(layer.args.get("delay", self.gif.options.delay))
        self.layer.args.update({
            "delay": self.delay / self.frames   # frame time split between animation steps
        })

    def steps(self):
        """ applies transforms in each subframe, yields filename+args list """
        transforms = []
        for anim in self.svg.xpath("//svg:animate"):
            transforms.append([self.animate_style(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateTransform"):
            transforms.append([self.animate_transform(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateMotion"):






|







467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
        self.layer = layer
        self.frames = layer.animate
        self.delay = float(layer.args.get("delay", self.gif.options.delay))
        self.layer.args.update({
            "delay": self.delay / self.frames   # frame time split between animation steps
        })

    def export(self):
        """ applies transforms in each subframe, yields filename+args list """
        transforms = []
        for anim in self.svg.xpath("//svg:animate"):
            transforms.append([self.animate_style(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateTransform"):
            transforms.append([self.animate_transform(anim, **anim.attrib), self.pace(anim)])
        for anim in self.svg.xpath("//svg:animateMotion"):