Repository: inkstitch/inkstitch
Pull request #3589: Batch lettering
Author: login: kaalleen
Created: 2025-03-17T22:06:41Z | Merged: 2025-03-21T18:31:25Z

Adds a batch lettering extension.

It includes most lettering features.

To enforce text position, create a path and set the label to `batch lettering`. It then acts the same way as lettering along path.

This extension is an output extension: use `Save a copy` and chose the batch lettering zip file.
It can also be run from the command line (resolves #552)

Linked issue #552: (Command Line) Extension for Batch Lettering
what automations are possible with this plug-in and Inkscape on a Mac? I'd love to write a python or AppleScript to write a script that creates a number of text-based DSTs based on an input file.
Linked issue comments: user: login: lexelby
created_at: 2019-10-10T00:23:47Z
body: We love questions -- no apology necessary at all :)

Some of the Ink/Stitch extensions can be used on the command-line as is.  Ink/Stitch is actually pretty well suited to your use case, because of how Inkscape extensions work.  When Inkscape runs an extension, it first saves the SVG to a temporary file, then passes the temporary SVG to the extension as an argument, and finally receives a new SVG from the extension's standard output.

The part that will trip you up is that some of the extensions have GUI components.  The Lettering extension is one of these, unfortunately.  The main entry point for the lettering extension is [here](https://github.com/inkstitch/inkstitch/blob/master/lib/extensions/lettering.py#L331).  There's a ton of GUI stuff that you don't need if you just want to do batch processing.

The actual lettering is all done by a separate library, though: https://github.com/inkstitch/inkstitch/blob/master/lib/extensions/lettering.py#L195

If you're willing to write a little code for Ink/Stitch itself, you can add a new extension that does lettering without a GUI.  A good example of a simple extension that doesn't have a GUI is [this one](https://github.com/inkstitch/inkstitch/blob/master/lib/extensions/flip.py#L19).

The superclass (`InkstitchExtension`) takes care of reading the SVG provided by Ink/Stitch and parsing it into an XML tree.  Actually, that's all handled by `inkex.Effect`, a class provided as part of Inkscape.  When your `effect()` function returns, `inkex.Effect` automatically converts the XML tree back into text and prints it to standard output for Inkscape to use.

So, here's what I suggest you do:

1. Make a new extension, BatchLettering.
2. It parse command-line options like [this](https://github.com/inkstitch/inkstitch/blob/master/lib/extensions/embroider.py#L13) to accept things such as font, text, etc.
3. In `effect()`, it:
  a. creates an `svg:group` tag
  b. creates a `Font` instance
  c. calls the `Font`'s `render_text` to add the embroidered text to the SVG
  d. returns

Great!  Now you can call `inkstitch --extension=batch_lettering <your arguments> /tmp/template.svg`, and it will print out a new SVG with Ink/Stitch compatible embroidered text in it.  Now what?

Now you can use the [`output` extension](https://github.com/inkstitch/inkstitch/blob/master/lib/extensions/output.py) to get a DST.  This extension is called when the user does `File -> Save As` and selects "Ink/Stitch: Tajima format" as the output format.  Inkscape saves the SVG to a temporary file, passes that into the extension, and expects a DST to be printed out.  Great!

So now you can do: `inkstitch --extension=output --format=dst the_svg_with_embroidered_text.svg > result.dst`

And that'll let you automate text-based DSTs :)

Caveat: Ink/Stitch's lettering support is still pretty rudimentary.  One significant thing that's missing is proper kerning.  There's support for kerning, but I haven't had a chance to set the kerning for each letter pair.  [Here](https://github.com/inkstitch/inkstitch/blob/master/fonts/small_font/font.json#L12)'s where kerning is set.  I used "wo" as my test case for kerning, but you can just add more letter pairs alongside it.

I hope that helps.  I know this was a really long answer to your question, and this is very much a DIY kind of situation. :)  I really like your idea though, and I think other people will be interested in it as well, if you decide to give it a shot.
author_association: MEMBER
user: login: ZoidbergPi
created_at: 2025-03-11T01:42:13Z
body: i use ai to generate a text included svg file, and use Inkscape&inktitch command line(actions-file) to generate the dst file , i modified the inktitch param function to apply the params and close itself after command line opened it
but it's very slow, it take about 1 min per simple text svg file, i can't process multi file in same time. that's sadly
author_association: NONE

Diff:
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index 289cac3126..ca15d42dbc 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -8,6 +8,7 @@
 from .apply_threadlist import ApplyThreadlist
 from .auto_run import AutoRun
 from .auto_satin import AutoSatin
+from .batch_lettering import BatchLettering
 from .break_apart import BreakApart
 from .cleanup import Cleanup
 from .commands_scale_symbols import CommandsScaleSymbols
@@ -83,6 +84,7 @@
     ApplyThreadlist,
     AutoRun,
     AutoSatin,
+    BatchLettering,
     BreakApart,
     Cleanup,
     CommandsScaleSymbols,
diff --git a/lib/extensions/batch_lettering.py b/lib/extensions/batch_lettering.py
new file mode 100644
index 0000000000..44ae75695a
--- /dev/null
+++ b/lib/extensions/batch_lettering.py
@@ -0,0 +1,231 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.replace('\\n', '\n').split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.scale < self.font.min_scale:
+            self.scale = self.font.min_scale
+        elif self.scale > self.font.max_scale:
+            self.scale = self.font.max_scale
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a path which can be use for the text along path method.
+        # The path should be labeled as "batch lettering"
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            stitch_plan, lettering_group = self.generate_stitch_plan(text, text_positioning_path)
+            for file_format in file_formats:
+                files.append(self.generate_output_file(file_format, path, text, stitch_plan))
+
+            self.reset_document(lettering_group, text_positioning_path)
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
+            for output in files:
+                zip_file.write(output, os.path.basename(output))
+
+        # inkscape will read the file contents from stdout and copy
+        # to the destination file that the user chose
+        with open(temp_file.name, 'rb') as output_file:
+            sys.stdout.buffer.write(output_file.read())
+
+        os.remove(temp_file.name)
+        for output in files:
+            os.remove(output)
+        os.rmdir(path)
+
+    def reset_document(self, lettering_group, text_positioning_path):
+        # reset document for the next iteration
+        parent = lettering_group.getparent()
+        index = parent.index(lettering_group)
+        if text_positioning_path is not None:
+            parent.insert(index, text_positioning_path)
+        parent.remove(lettering_group)
+
+    def generate_output_file(self, file_format, path, text, stitch_plan):
+        text = text.replace('\n', '')
+        output_file = os.path.join(path, f"{text}.{file_format}")
+
+        if file_format == 'svg':
+            document = deepcopy(self.document.getroot())
+            with open(output_file, 'w', encoding='utf-8') as svg:
+                svg.write(etree.tostring(document).decode('utf-8'))
+        else:
+            write_embroidery_file(output_file, stitch_plan, self.document.getroot())
+
+        return output_file
+
+    def generate_stitch_plan(self, text, text_positioning_path):
+
+        self.settings = DotDict({
+            "text": text,
+            "text_align": self.text_align,
+            "back_and_forth": True,
+            "font": self.font.marked_custom_font_id,
+            "scale": self.scale * 100,
+            "trim_option": self.trim,
+            "use_trim_symbols": self.options.command_symbols,
+            "color_sort": self.color_sort
+        })
+
+        lettering_group = Group()
+        lettering_group.label = _("Ink/Stitch Lettering")
+        lettering_group.set('inkstitch:lettering', json.dumps(self.settings))
+        self.svg.append(lettering_group)
+        lettering_group.set("transform", get_correction_transform(lettering_group, child=True))
+
+        destination_group = Group()
+        destination_group.label = f"{self.font.name} {_('scale')} {self.scale * 100}%"
+        lettering_group.append(destination_group)
+
+        text = self.font.render_text(
+            text,
+            destination_group,
+            trim_option=self.trim,
+            use_trim_symbols=self.options.command_symbols,
+            color_sort=self.color_sort,
+            text_align=self.text_align
+        )
+
+        destination_group.attrib['transform'] = f'scale({self.scale})'
+
+        if text_positioning_path is not None:
+            parent = text_positioning_path.getparent()
+            index = parent.index(text_positioning_path)
+            parent.insert(index, lettering_group)
+            TextAlongPath(self.svg, lettering_group, text_positioning_path, self.options.text_position)
+            parent.remove(text_positioning_path)
+
+        self.get_elements()
+        stitch_groups = self.elements_to_stitch_groups(self.elements)
+        stitch_plan = stitch_groups_to_stitch_plan(stitch_groups, collapse_len=self.collapse_len, min_stitch_len=self.min_stitch_len)
+        ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette'])
+
+        return stitch_plan, lettering_group
+
+
+if __name__ == '__main__':
+    BatchLettering().run()
diff --git a/lib/extensions/lettering_along_path.py b/lib/extensions/lettering_along_path.py
index 608479be7e..10e62dd68c 100644
--- a/lib/extensions/lettering_along_path.py
+++ b/lib/extensions/lettering_along_path.py
@@ -6,7 +6,7 @@
 import json
 from math import atan2, degrees
 
-from inkex import Boolean, Transform, errormsg
+from inkex import Transform, errormsg
 from inkex.units import convert_unit
 
 from ..elements import Stroke
@@ -26,7 +26,7 @@ class LetteringAlongPath(InkstitchExtension):
     def __init__(self, *args, **kwargs):
         InkstitchExtension.__init__(self, *args, **kwargs)
         self.arg_parser.add_argument("--notebook")
-        self.arg_parser.add_argument("-s", "--stretch-spaces", type=Boolean, default=False, dest="stretch_spaces")
+        self.arg_parser.add_argument("-p", "--text-position", type=str, default='left', dest="text_position")
 
     def effect(self):
         # we ignore everything but the first path/text
@@ -35,32 +35,70 @@ def effect(self):
             errormsg(_("Please select one path and one Ink/Stitch lettering group."))
             return
 
-        glyphs = [glyph for glyph in text.iterdescendants(SVG_GROUP_TAG) if glyph.label and len(glyph.label) == 1]
-        if not glyphs:
+        TextAlongPath(self.svg, text, path, self.options.text_position)
+
+    def get_selection(self):
+        groups = list()
+        paths = list()
+
+        for node in self.svg.selection:
+            lettering = False
+            if node.tag == SVG_GROUP_TAG and INKSTITCH_LETTERING in node.attrib:
+                groups.append(node)
+                lettering = True
+                continue
+
+            for group in node.iterancestors(SVG_GROUP_TAG):
+                if INKSTITCH_LETTERING in group.attrib:
+                    groups.append(group)
+                    lettering = True
+                    break
+
+            if not lettering and node.tag in EMBROIDERABLE_TAGS:
+                paths.append(node)
+
+        if not groups or not paths:
+            return [None, None]
+
+        return [groups[0], paths[0]]
+
+
+class TextAlongPath:
+    '''
+    Aligns an Ink/Stitch Lettering group along a path
+    '''
+    def __init__(self, svg, text, path, text_position):
+        self.svg = svg
+        self.text = text
+        self.path = Stroke(path).as_multi_line_string().geoms[0]
+        self.text_position = text_position
+
+        self.glyphs = [glyph for glyph in self.text.iterdescendants(SVG_GROUP_TAG) if glyph.label and len(glyph.label) == 1]
+        if not self.glyphs:
             errormsg(_("The text doesn't contain any glyphs."))
             return
 
-        self.load_settings(text)
+        self.load_settings()
 
-        if glyphs[0].get('transform', None) is not None:
-            glyphs = self._reset_glyph_transforms(text, glyphs)
+        if self.glyphs[0].get('transform', None) is not None:
+            self._reset_glyph_transforms()
 
-        path = Stroke(path).as_multi_line_string().geoms[0]
-        hidden_commands = self.hide_commands(glyphs)
-        space_indices, stretch_space, text_baseline = self.get_position_and_stretch_values(path, text, glyphs)
-        self.transform_glyphs(glyphs, path, stretch_space, space_indices, text_baseline)
+        hidden_commands = self.hide_commands()
+        space_indices, stretch_space, text_baseline = self.get_position_and_stretch_values()
+        start_position = self.get_start_position()
+        self.transform_glyphs(start_position, stretch_space, space_indices, text_baseline)
         self.restore_commands(hidden_commands)
 
-    def _reset_glyph_transforms(self, text_group, glyphs):
+    def _reset_glyph_transforms(self):
         font = get_font_by_id(self.settings.font)
         if font is not None:
             try:
-                text_group = list(text_group.iterchildren(SVG_GROUP_TAG))[0]
+                text_group = list(self.text.iterchildren(SVG_GROUP_TAG))[0]
             except IndexError:
                 pass
             for glyph in text_group.iterchildren():
                 text_group.remove(glyph)
-            text = font.render_text(
+            rendered_text = font.render_text(
                 self.settings.text,
                 text_group,
                 None,  # we don't know the font variant (?)
@@ -68,21 +106,30 @@ def _reset_glyph_transforms(self, text_group, glyphs):
                 self.settings.trim_option,
                 self.settings.use_trim_symbols
             )
-            return [glyph for glyph in text.iterdescendants(SVG_GROUP_TAG) if glyph.label and len(glyph.label) == 1]
-        return glyphs
-
-    def get_position_and_stretch_values(self, path, text, glyphs):
-        text_bbox = glyphs[0].getparent().bounding_box()
+            self.glyphs = [glyph for glyph in rendered_text.iterdescendants(SVG_GROUP_TAG) if glyph.label and len(glyph.label) == 1]
+
+    def get_start_position(self):
+        start_position = 0
+        text_length = self.text_length()
+        path_length = self.path.length
+        if self.text_position == 'center':
+            start_position = (path_length - text_length) / 2
+        if self.text_position == 'right':
+            start_position = path_length - text_length
+        return start_position
+
+    def get_position_and_stretch_values(self):
+        text_bbox = self.glyphs[0].getparent().bounding_box()
         text_baseline = text_bbox.bottom
 
-        if self.options.stretch_spaces:
-            text_content = self.settings["text"]
+        if self.text_position == 'stretch':
+            text_content = self.settings.text
             space_indices = [i for i, t in enumerate(text_content) if t == " "]
-            text_bbox = text.bounding_box()
+            text_bbox = self.text.bounding_box()
             text_width = convert_unit(text_bbox.width, 'px', self.svg.unit)
 
             if len(text_content) - 1 != 0:
-                path_length = path.length
+                path_length = self.path.length
                 stretch_space = (path_length - text_width) / (len(text_content) - 1)
             else:
                 stretch_space = 0
@@ -92,10 +139,13 @@ def get_position_and_stretch_values(self, path, text, glyphs):
 
         return space_indices, stretch_space, text_baseline
 
-    def hide_commands(self, glyphs):
+    def text_length(self):
+        return convert_unit(self.text.bounding_box().width, 'px', self.svg.unit)
+
+    def hide_commands(self):
         # hide commmands for bounding box calculation
         hidden_commands = []
-        for glyph in glyphs:
+        for glyph in self.glyphs:
             for group in glyph.iterdescendants(SVG_GROUP_TAG):
                 if group.get_id().startswith("command_group") and group.style('display', 'inline') != 'none':
                     hidden_commands.append(group)
@@ -106,13 +156,13 @@ def restore_commands(self, hidden_commands):
         for command in hidden_commands:
             command.style['display'] = "inline"
 
-    def transform_glyphs(self, glyphs, path, stretch_space, space_indices, text_baseline):
+    def transform_glyphs(self, start_position, stretch_space, space_indices, text_baseline):
         text_scale = Transform(f'scale({self.settings.scale / 100})')
-        distance = 0
+        distance = start_position
         old_bbox = None
         i = 0
 
-        for glyph in glyphs:
+        for glyph in self.glyphs:
             # dimensions
             bbox = glyph.bounding_box()
             transformed_bbox = glyph.bounding_box(glyph.getparent().composed_transform())
@@ -124,15 +174,15 @@ def transform_glyphs(self, glyphs, path, stretch_space, space_indices, text_base
             if old_bbox:
                 distance += convert_unit(transformed_left - old_bbox.right, 'px', self.svg.unit) + stretch_space
 
-            if self.options.stretch_spaces and i in space_indices:
+            if self.text_position == 'stretch' and i in space_indices:
                 distance += stretch_space
                 i += 1
 
             new_distance = distance + width
 
             # calculate and apply transform
-            first = path.interpolate(distance)
-            last = path.interpolate(new_distance)
+            first = self.path.interpolate(distance)
+            last = self.path.interpolate(new_distance)
 
             angle = degrees(atan2(last.y - first.y, last.x - first.x)) % 360
             translate = InkstitchPoint(first.x, first.y) - InkstitchPoint(left, text_baseline)
@@ -146,7 +196,7 @@ def transform_glyphs(self, glyphs, path, stretch_space, space_indices, text_base
             old_bbox = transformed_bbox
             i += 1
 
-    def load_settings(self, text):
+    def load_settings(self):
         """Load the settings saved into the text element"""
 
         self.settings = DotDict({
@@ -158,33 +208,8 @@ def load_settings(self, text):
             "use_trim_symbols": False
         })
 
-        if INKSTITCH_LETTERING in text.attrib:
+        if INKSTITCH_LETTERING in self.text.attrib:
             try:
-                self.settings.update(json.loads(text.get(INKSTITCH_LETTERING)))
+                self.settings.update(json.loads(self.text.get(INKSTITCH_LETTERING)))
             except (TypeError, ValueError):
                 pass
-
-    def get_selection(self):
-        groups = list()
-        paths = list()
-
-        for node in self.svg.selection:
-            lettering = False
-            if node.tag == SVG_GROUP_TAG and INKSTITCH_LETTERING in node.attrib:
-                groups.append(node)
-                lettering = True
-                continue
-
-            for group in node.iterancestors(SVG_GROUP_TAG):
-                if INKSTITCH_LETTERING in group.attrib:
-                    groups.append(group)
-                    lettering = True
-                    break
-
-            if not lettering and node.tag in EMBROIDERABLE_TAGS:
-                paths.append(node)
-
-        if not groups or not paths:
-            return [None, None]
-
-        return [groups[0], paths[0]]
diff --git a/lib/lettering/__init__.py b/lib/lettering/__init__.py
index 60d475817a..679ce23af9 100644
--- a/lib/lettering/__init__.py
+++ b/lib/lettering/__init__.py
@@ -4,5 +4,4 @@
 # Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
 
 from .font import Font, FontError
-from .utils import get_font_list
-from .utils import get_font_by_id
+from .utils import get_font_by_id, get_font_by_name, get_font_list
diff --git a/lib/lettering/utils.py b/lib/lettering/utils.py
index 9999764303..f643dfa70d 100644
--- a/lib/lettering/utils.py
+++ b/lib/lettering/utils.py
@@ -52,3 +52,17 @@ def get_font_by_id(font_id):
             if font.id == font_id:
                 return font
     return None
+
+
+def get_font_by_name(font_name):
+    font_paths = get_font_paths()
+    for font_path in font_paths:
+        try:
+            font_dirs = os.listdir(font_path)
+        except OSError:
+            continue
+        for font_dir in font_dirs:
+            font = Font(os.path.join(font_path, font_dir))
+            if font.name == font_name:
+                return font
+    return None
diff --git a/templates/batch_lettering.xml b/templates/batch_lettering.xml
new file mode 100644
index 0000000000..ff750f7e8e
--- /dev/null
+++ b/templates/batch_lettering.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<inkscape-extension translationdomain="inkstitch" xmlns="http://www.inkscape.org/namespace/inkscape/extension">
+    <name>Batch Lettering</name>
+    <id>org.{{ id_inkstitch }}.output.batch_lettering</id>
+    <param name="extension" type="string" gui-hidden="true">batch_lettering</param>
+
+    <param name="notebook" type="notebook">
+
+        <page name="options" gui-text="Options">
+          <hbox>
+            <vbox>
+                <param name="text" type="string" gui-text="Text" appearance="multiline"
+                       gui-description="Enter the text. Each line of text will be exported to a separate file." />
+                <param name="separator" type="string" gui-text="Custom separator"
+                       gui-description="Set a custom separator for multiline text export. Leave empty for line break." />
+                <spacer />
+                <separator />
+                <spacer />
+                <param name="font" type="string" gui-text="Font name"></param>
+                <param name="scale" type="int" gui-text="Scale (%)" min="1" max="800"
+                       gui-text="The scale value must be within the scale range of the specified font.">100</param>
+                <param name="color-sort" type="optiongroup" appearance="combo" gui-text="Color sort">
+                   <option value="off">Off</option>
+                   <option value="all">Whole text</option>
+                   <option value="line" >Line</option>
+                   <option value="word" >Word</option>
+                </param>
+                <param name="trim" type="optiongroup" appearance="combo" gui-text="Add trims">
+                   <option value="off">Never</option>
+                   <option value="line">after each line</option>
+                   <option value="word">after each word</option>
+                   <option value="glyph">after each letter</option>
+                </param>
+                <param name="use-command-symbols" type="boolean" gui-text="Use command symbols">false</param>
+                <param name="text-align" type="optiongroup" appearance="combo" gui-text="Align Multiline Text">
+                   <option value="left">Left</option>
+                   <option value="center">Center</option>
+                   <option value="right">Right</option>
+                   <option value="block">Block (default)</option>
+                   <option value="letterspacing">Block (letterpacing)</option>
+                </param>
+              </vbox>
+              <spacer />
+              <separator />
+              <spacer />
+              <vbox>
+                <param name="text-position" type="optiongroup" appearance="combo" gui-text="Lettering along path: text position"
+                       gui-description="Uses this text position when using lettering along path">
+                   <option value="left">Left</option>
+                   <option value="center">Center</option>
+                   <option value="right">Right</option>
+                   <option value="stretch">Stretch</option>
+                </param>
+                <spacer />
+                <separator />
+                <spacer />
+                <param name="file-formats" type="string" gui-text="File formats" gui-description="Comma separated list of file formats" />
+              </vbox>
+            </hbox>
+        </page>
+
+        <page name="info" gui-text="Help">
+            <label>Use this extension to save multiple files with the given text.</label>
+            <spacer />
+            <label>When the document contains a path element labeled as "batch lettering" it will be used to place the text along this path. The path itself will be removed and won't be rendered.</label>
+            <spacer />
+            <label>More information on our website</label>
+            <label appearance="url">https://inkstitch.org/docs/lettering/#batch-export</label>
+        </page>
+    </param>
+
+    <output>
+        <extension>.zip</extension>
+        <mimetype>application/zip</mimetype>
+        <filetypename>{{ menu_inkstitch }}: batch lettering (.zip)</filetypename>
+        <filetypetooltip>Create a zip with multiple files including embroidered text using Ink/Stitch</filetypetooltip>
+        <dataloss>true</dataloss>
+    </output>
+
+    <script>
+        {{ command_tag | safe }}
+    </script>
+</inkscape-extension>
diff --git a/templates/lettering.xml b/templates/lettering.xml
index 5eaa7e0aff..c15a74880a 100644
--- a/templates/lettering.xml
+++ b/templates/lettering.xml
@@ -6,8 +6,11 @@
     <effect implements-custom-gui="true" show-stderr="true">
         <object-type>all</object-type>
         <icon>{{ icon_path }}inx/lettering.svg</icon>
+        <menu-tip>Insert ready-to-embroider text into the document</menu-tip>
         <effects-menu>
-            <submenu name="{{ menu_inkstitch }}" translatable="no" />
+            <submenu name="{{ menu_inkstitch }}" translatable="no">
+                <submenu name="Lettering" />
+            </submenu>
         </effects-menu>
     </effect>
     <script>
diff --git a/templates/lettering_along_path.xml b/templates/lettering_along_path.xml
index f914c6a33e..98f12c3869 100644
--- a/templates/lettering_along_path.xml
+++ b/templates/lettering_along_path.xml
@@ -8,13 +8,19 @@
         <icon>{{ icon_path }}inx/lettering_along_path.svg</icon>
         <menu-tip>Shapes a line of text onto a path</menu-tip>
         <effects-menu>
-            <submenu name="{{ menu_inkstitch }}" translatable="no" />
+            <submenu name="{{ menu_inkstitch }}" translatable="no">
+                <submenu name="Lettering" />
+            </submenu>
         </effects-menu>
     </effect>
     <param name="notebook" type="notebook">
       <page name="options" gui-text="Options">
-        <param name="stretch-spaces" type="bool" gui-text="Stretch"
-               gui-description="Expand glyph and word spacing to stretch lettering over the entire path">false</param>
+        <param name="text-position" type="optiongroup" appearance="combo" gui-text="Text position">
+           <option value="left">Left</option>
+           <option value="center">Center</option>
+           <option value="right">Right</option>
+           <option value="stretch">Stretch</option>
+        </param>
       </page>
       <page name="info" gui-text="Help">
         <label appearance="header">This extension bends an Ink/Stitch text to a path.</label>
@@ -24,7 +30,9 @@
         <label indent="1">* The text consists of only one line of text</label>
         <label indent="1">* The text should not be too large for the given path</label>
         <spacer />
-        <label>The stretch option defines whether the spaces between glyphs should be expanded so that the text stretches over the entire path.</label>
+        <label>In the text position dropdown menu, you can decide how the text will be placed on the path.
+               When stretch is selected, the spaces between the glyphs will be expanded, so that the text stretches over the entire path.
+        </label>
         <spacer />
         <label>More information on our website</label>
         <label appearance="url">https://inkstitch.org/docs/lettering/#lettering-along-path</label>


Reviews:
user: login: capellancitizen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T02:27:17Z
user: login: capellancitizen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T04:06:27Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:07:15Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:08:28Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:09:04Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:09:34Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:09:59Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:11:01Z
user: login: kaalleen
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-20T06:11:14Z
user: login: capellancitizen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T21:57:02Z
user: login: capellancitizen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-20T21:57:53Z
user: login: capellancitizen
body: You may wish to review my changes before merging, but I'm liking how this is looking, and it seems to run nicely.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-20T22:27:25Z
user: login: capellancitizen
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-21T18:05:32Z

Review comments:
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
+            for output in files:
+                zip_file.write(output, os.path.basename(output))
+
+        # inkscape will read the file contents from stdout and copy
+        # to the destination file that the user chose
+        with open(temp_file.name, 'rb') as output_file:
+            sys.stdout.buffer.write(output_file.read())
+
+        os.remove(temp_file.name)
+        for output in files:
+            os.remove(output)
+        os.rmdir(path)
+
+    def generate_text_file(self, path, text, text_positioning_path, file_format):
+        output_file = os.path.join(path, f"{text}.{file_format}")
+
+        self.settings = DotDict({
+            "text": text,
+            "text_align": self.text_align,
+            "back_and_forth": True,
+            "font": self.font.marked_custom_font_id,
+            "scale": self.scale * 100,
+            "trim_option": self.trim,
+            "use_trim_symbols": self.options.command_symbols,
+            "color_sort": self.color_sort
+        })
+
+        lettering_group = Group()
+        lettering_group.label = _("Ink/Stitch Lettering")
+        lettering_group.set('inkstitch:lettering', json.dumps(self.settings))
+        self.svg.append(lettering_group)
+        lettering_group.set("transform", get_correction_transform(lettering_group, child=True))
+
+        destination_group = Group()
+        destination_group.label = f"{self.font.name} {_('scale')} {self.scale * 100}%"
+        lettering_group.append(destination_group)
+
+        text = self.font.render_text(
+            text,
+            destination_group,
+            trim_option=self.trim,
+            use_trim_symbols=self.options.command_symbols,
+            color_sort=self.color_sort,
+            text_align=self.text_align
+        )
+
+        destination_group.attrib['transform'] = f'scale({self.scale})'
+
+        if text_positioning_path is not None:
+            parent = text_positioning_path.getparent()
+            index = parent.index(text_positioning_path)
+            parent.insert(index, lettering_group)
+            TextAlongPath(self.svg, lettering_group, text_positioning_path, self.options.text_position)
+            parent.remove(text_positioning_path)
+
+        if file_format == 'svg':
+            document = deepcopy(self.document.getroot())
+            with open(output_file, 'w', encoding='utf-8') as svg:
+                svg.write(etree.tostring(document).decode('utf-8'))
+        else:
+            self.get_elements()
+            stitch_groups = self.elements_to_stitch_groups(self.elements)
+            stitch_plan = stitch_groups_to_stitch_plan(stitch_groups, collapse_len=self.collapse_len, min_stitch_len=self.min_stitch_len)
+            ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette'])
+            write_embroidery_file(output_file, stitch_plan, self.document.getroot())
+
+        # reset document for the next iteration
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: Is there a reason why we're resetting this for each format? I would have thought that we could generate the text once (and save that as the .svg output), generate a stitch plan once, and then use that stitch plan to generate each embroidery file. I guess, is it because of the `match_and_apply_palette` step? That would make sense, stitch plans might not be really `copy`able...
created_at: 2025-03-20T01:58:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: IMO instead it would be better to either clamp the value between min_scale and max_scale (i.e. setting it to `font.min_scale` if the value is less than `font.min_scale`, or `font.max_scale` if the value is greater than `font.max_scale`) rather than silently setting it to 1, or to error and tell the user they picked a value outside the bound s (and tell them what the bounds are so they can fix it).
created_at: 2025-03-20T02:02:05Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: it's unfortunate that the render_text function takes these magic numbers and they aren't e.g. given constant names to use, but changing that is probably outside the scope of this work.
created_at: 2025-03-20T02:04:09Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: Is this comment still correct? from the code above it seems like if scale was 0, it'd be clamped to 1, and there isn't any flag value set either.
created_at: 2025-03-20T02:06:38Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: I bet we could write the zip file directly to stdout without the temporary files with something like:
```
with ZipFile(sys.stdout, "w") as zip_file:
    for text in texts:
        for file_format in file_formats:
            with zip_file.open(f"{text}.{file_format}", "w") as out:
                self.generate_text_file(outfile, path, text, text_positioning_path, file_format) # Obviously, refactored to take the file as a parameter here.
```
If you want I could try that out and push it here if I get it working, or it could be left as a future improvement/followup.
created_at: 2025-03-20T02:19:04Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<inkscape-extension translationdomain="inkstitch" xmlns="http://www.inkscape.org/namespace/inkscape/extension">
+    <name>Batch Lettering</name>
+    <id>org.{{ id_inkstitch }}.output.batch_lettering</id>
+    <param name="extension" type="string" gui-hidden="true">batch_lettering</param>
+
+    <param name="notebook" type="notebook">
+
+        <page name="options" gui-text="Options">
+          <hbox>
+            <vbox>
+                <param name="text" type="string" gui-text="Text" appearance="multiline"
+                       gui-description="Enter the text. Each line of text will be exported to a separate file." />
+                <param name="separator" type="string" gui-text="Custom separator"
+                       gui-description="Set a custom separator for multiline text export. Leave empty for line break." />
+                <spacer />
+                <separator />
+                <spacer />
+                <param name="font" type="string" gui-text="Font name" gui-text="Font folder name"></param>
path: templates/batch_lettering.xml
user: login: capellancitizen
body: Per previous comment, should this just be changed to "Font Name"?
created_at: 2025-03-20T02:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: If separator isn't specified, should we also replace the `\\n`s in the text with real newlines? See the test file in my comment from earlier for how it looks right now.
created_at: 2025-03-20T02:23:00Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: It wasn't too hard to make this change, actually, though it meant a little bit of refactoring in `output.py`. I'll clean it up and commit it to this branch tomorrow. Didn't profile it against this version yet but skipping extra reads/writes from the temporary directory probably can't hurt.
created_at: 2025-03-20T04:06:27Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
+            for output in files:
+                zip_file.write(output, os.path.basename(output))
+
+        # inkscape will read the file contents from stdout and copy
+        # to the destination file that the user chose
+        with open(temp_file.name, 'rb') as output_file:
+            sys.stdout.buffer.write(output_file.read())
+
+        os.remove(temp_file.name)
+        for output in files:
+            os.remove(output)
+        os.rmdir(path)
+
+    def generate_text_file(self, path, text, text_positioning_path, file_format):
+        output_file = os.path.join(path, f"{text}.{file_format}")
+
+        self.settings = DotDict({
+            "text": text,
+            "text_align": self.text_align,
+            "back_and_forth": True,
+            "font": self.font.marked_custom_font_id,
+            "scale": self.scale * 100,
+            "trim_option": self.trim,
+            "use_trim_symbols": self.options.command_symbols,
+            "color_sort": self.color_sort
+        })
+
+        lettering_group = Group()
+        lettering_group.label = _("Ink/Stitch Lettering")
+        lettering_group.set('inkstitch:lettering', json.dumps(self.settings))
+        self.svg.append(lettering_group)
+        lettering_group.set("transform", get_correction_transform(lettering_group, child=True))
+
+        destination_group = Group()
+        destination_group.label = f"{self.font.name} {_('scale')} {self.scale * 100}%"
+        lettering_group.append(destination_group)
+
+        text = self.font.render_text(
+            text,
+            destination_group,
+            trim_option=self.trim,
+            use_trim_symbols=self.options.command_symbols,
+            color_sort=self.color_sort,
+            text_align=self.text_align
+        )
+
+        destination_group.attrib['transform'] = f'scale({self.scale})'
+
+        if text_positioning_path is not None:
+            parent = text_positioning_path.getparent()
+            index = parent.index(text_positioning_path)
+            parent.insert(index, lettering_group)
+            TextAlongPath(self.svg, lettering_group, text_positioning_path, self.options.text_position)
+            parent.remove(text_positioning_path)
+
+        if file_format == 'svg':
+            document = deepcopy(self.document.getroot())
+            with open(output_file, 'w', encoding='utf-8') as svg:
+                svg.write(etree.tostring(document).decode('utf-8'))
+        else:
+            self.get_elements()
+            stitch_groups = self.elements_to_stitch_groups(self.elements)
+            stitch_plan = stitch_groups_to_stitch_plan(stitch_groups, collapse_len=self.collapse_len, min_stitch_len=self.min_stitch_len)
+            ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette'])
+            write_embroidery_file(output_file, stitch_plan, self.document.getroot())
+
+        # reset document for the next iteration
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: Actually you can reuse a stitch plan for the various file formats. I updated it accordingly. Thanks for the hint.
created_at: 2025-03-20T06:07:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: Good point.
created_at: 2025-03-20T06:08:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: Yes, it's not nice to have to use the numbers. I didn't want to mess with this in here.
created_at: 2025-03-20T06:09:04Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: No, this was just the initial idea. Never put into code. I updated the comment.
created_at: 2025-03-20T06:09:33Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: Sounds great. Thank you.
created_at: 2025-03-20T06:09:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<inkscape-extension translationdomain="inkstitch" xmlns="http://www.inkscape.org/namespace/inkscape/extension">
+    <name>Batch Lettering</name>
+    <id>org.{{ id_inkstitch }}.output.batch_lettering</id>
+    <param name="extension" type="string" gui-hidden="true">batch_lettering</param>
+
+    <param name="notebook" type="notebook">
+
+        <page name="options" gui-text="Options">
+          <hbox>
+            <vbox>
+                <param name="text" type="string" gui-text="Text" appearance="multiline"
+                       gui-description="Enter the text. Each line of text will be exported to a separate file." />
+                <param name="separator" type="string" gui-text="Custom separator"
+                       gui-description="Set a custom separator for multiline text export. Leave empty for line break." />
+                <spacer />
+                <separator />
+                <spacer />
+                <param name="font" type="string" gui-text="Font name" gui-text="Font folder name"></param>
path: templates/batch_lettering.xml
user: login: kaalleen
body: Missed this during the update. It was first the folder name. But the folder name is somewhat inaccessible for the regular user. So i figured it would be better use the actual font name.
created_at: 2025-03-20T06:11:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
path: lib/extensions/batch_lettering.py
user: login: kaalleen
body: Right :D
created_at: 2025-03-20T06:11:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
+            for output in files:
+                zip_file.write(output, os.path.basename(output))
+
+        # inkscape will read the file contents from stdout and copy
+        # to the destination file that the user chose
+        with open(temp_file.name, 'rb') as output_file:
+            sys.stdout.buffer.write(output_file.read())
+
+        os.remove(temp_file.name)
+        for output in files:
+            os.remove(output)
+        os.rmdir(path)
+
+    def generate_text_file(self, path, text, text_positioning_path, file_format):
+        output_file = os.path.join(path, f"{text}.{file_format}")
+
+        self.settings = DotDict({
+            "text": text,
+            "text_align": self.text_align,
+            "back_and_forth": True,
+            "font": self.font.marked_custom_font_id,
+            "scale": self.scale * 100,
+            "trim_option": self.trim,
+            "use_trim_symbols": self.options.command_symbols,
+            "color_sort": self.color_sort
+        })
+
+        lettering_group = Group()
+        lettering_group.label = _("Ink/Stitch Lettering")
+        lettering_group.set('inkstitch:lettering', json.dumps(self.settings))
+        self.svg.append(lettering_group)
+        lettering_group.set("transform", get_correction_transform(lettering_group, child=True))
+
+        destination_group = Group()
+        destination_group.label = f"{self.font.name} {_('scale')} {self.scale * 100}%"
+        lettering_group.append(destination_group)
+
+        text = self.font.render_text(
+            text,
+            destination_group,
+            trim_option=self.trim,
+            use_trim_symbols=self.options.command_symbols,
+            color_sort=self.color_sort,
+            text_align=self.text_align
+        )
+
+        destination_group.attrib['transform'] = f'scale({self.scale})'
+
+        if text_positioning_path is not None:
+            parent = text_positioning_path.getparent()
+            index = parent.index(text_positioning_path)
+            parent.insert(index, lettering_group)
+            TextAlongPath(self.svg, lettering_group, text_positioning_path, self.options.text_position)
+            parent.remove(text_positioning_path)
+
+        if file_format == 'svg':
+            document = deepcopy(self.document.getroot())
+            with open(output_file, 'w', encoding='utf-8') as svg:
+                svg.write(etree.tostring(document).decode('utf-8'))
+        else:
+            self.get_elements()
+            stitch_groups = self.elements_to_stitch_groups(self.elements)
+            stitch_plan = stitch_groups_to_stitch_plan(stitch_groups, collapse_len=self.collapse_len, min_stitch_len=self.min_stitch_len)
+            ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette'])
+            write_embroidery_file(output_file, stitch_plan, self.document.getroot())
+
+        # reset document for the next iteration
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: It seems to run much faster now :)
created_at: 2025-03-20T21:57:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,218 @@
+# Authors: see git history
+#
+# Copyright (c) 2025 Authors
+# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.
+
+import json
+import os
+import sys
+import tempfile
+from copy import deepcopy
+from zipfile import ZipFile
+
+from inkex import Boolean, Group, errormsg
+from lxml import etree
+
+import pyembroidery
+
+from ..extensions.lettering_along_path import TextAlongPath
+from ..i18n import _
+from ..lettering import get_font_by_name
+from ..output import write_embroidery_file
+from ..stitch_plan import stitch_groups_to_stitch_plan
+from ..svg import get_correction_transform
+from ..threads import ThreadCatalog
+from ..utils import DotDict
+from .base import InkstitchExtension
+
+
+class BatchLettering(InkstitchExtension):
+    def __init__(self, *args, **kwargs):
+        InkstitchExtension.__init__(self)
+
+        self.arg_parser.add_argument('--notebook')
+
+        self.arg_parser.add_argument('--text', type=str, default='', dest='text')
+        self.arg_parser.add_argument('--separator', type=str, default='', dest='separator')
+
+        self.arg_parser.add_argument('--font', type=str, default='', dest='font')
+        self.arg_parser.add_argument('--scale', type=int, default=100, dest='scale')
+        self.arg_parser.add_argument('--color-sort', type=str, default='off', dest='color_sort')
+        self.arg_parser.add_argument('--trim', type=str, default='off', dest='trim')
+        self.arg_parser.add_argument('--use-command-symbols', type=Boolean, default=False, dest='command_symbols')
+        self.arg_parser.add_argument('--text-align', type=str, default='left', dest='text_align')
+
+        self.arg_parser.add_argument('--text-position', type=str, default='left', dest='text_position')
+
+        self.arg_parser.add_argument('--file-formats', type=str, default='', dest='formats')
+
+    def effect(self):
+        separator = self.options.separator
+        if not separator:
+            separator = '\\n'
+        text_input = self.options.text
+        if not text_input:
+            errormsg(_("Please specify a text"))
+            return
+        texts = text_input.split(separator)
+
+        if not self.options.font:
+            errormsg(_("Please specify a font"))
+            return
+        self.font = get_font_by_name(self.options.font)
+        if self.font is None:
+            errormsg(_("Please specify a valid font name"))
+            return
+
+        if not self.options.formats:
+            errormsg(_("Please specify at least one output file format"))
+            return
+        available_formats = [file_format['extension'] for file_format in pyembroidery.supported_formats()] + ['svg']
+        file_formats = self.options.formats.split(',')
+        file_formats = [file_format.strip().lower() for file_format in file_formats if file_format.strip().lower() in available_formats]
+        if not file_formats:
+            errormsg(_("Please specify at least one file format supported by pyembroidery"))
+            return
+
+        self.setup_trim()
+        self.setup_text_align()
+        self.setup_color_sort()
+        self.setup_scale()
+
+        self.generate_output_files(texts, file_formats)
+
+        # don't let inkex output the SVG!
+        sys.exit(0)
+
+    def setup_trim(self):
+        self.trim = 0
+        if self.options.trim == "line":
+            self.trim = 1
+        elif self.options.trim == "word":
+            self.trim = 2
+        elif self.options.trim == "glyph":
+            self.trim = 3
+
+    def setup_text_align(self):
+        self.text_align = 0
+        if self.options.text_align == "center":
+            self.text_align = 1
+        elif self.options.text_align == "right":
+            self.text_align = 2
+        elif self.options.text_align == "block":
+            self.text_align = 3
+        elif self.options.text_align == "letterspacing":
+            self.text_align = 4
+
+    def setup_color_sort(self):
+        self.color_sort = 0
+        if self.options.color_sort == "all":
+            self.color_sort = 1
+        elif self.options.color_sort == "line":
+            self.color_sort = 2
+        elif self.options.color_sort == "word":
+            self.color_sort = 3
+
+    def setup_scale(self):
+        self.scale = self.options.scale / 100
+        if self.font.min_scale > self.scale or self.scale > self.font.max_scale:
+            self.scale = 1
+
+    def generate_output_files(self, texts, file_formats):
+        self.metadata = self.get_inkstitch_metadata()
+        self.collapse_len = self.metadata['collapse_len_mm']
+        self.min_stitch_len = self.metadata['min_stitch_len_mm']
+
+        # The user can specify a frame to adapt the position of the text or influence it's scaling
+        # if the path is a closed path it will try to adapt the width of the text (if the scaling input was 0) or simply use it for text positioning
+        # if it is an open path, it will use the text along path method
+        text_positioning_path = self.svg.findone(".//*[@inkscape:label='batch lettering']")
+
+        path = tempfile.mkdtemp()
+        files = []
+        for text in texts:
+            for file_format in file_formats:
+                files.append(self.generate_text_file(path, text, text_positioning_path, file_format))
+
+        temp_file = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
+
+        # in windows, failure to close here will keep the file locked
+        temp_file.close()
+
+        with ZipFile(temp_file.name, "w") as zip_file:
path: lib/extensions/batch_lettering.py
user: login: capellancitizen
body: Done. Take a look and let me know if you have any review comments for me in turn :)
created_at: 2025-03-20T21:57:53Z
author_association: COLLABORATOR

PR comments:
user: login: capellancitizen
created_at: 2025-03-18T19:49:15Z
body: (I'll try to give this a test and review in the next day or two)
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-19T18:09:26Z
body: That's great. Thank you.
I wouldn't mind if you push changes to the code if you see necessary.
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-19T22:39:48Z
body: Looking pretty good at first couple of runs. Possibly I'm misunderstanding something, but using text:
```
Test Alpha
Bravo:Charlie
``` 
and line separator `:` it produced a zip with files with the literal text `Test Alpha\nBravo` (though I think this font lacks the backslash so it doesn't show), which probably isn't desired behaviour.

![Test Alpha nBravo](https://github.com/user-attachments/assets/d1d81a99-0d2b-4598-b064-cf88b1ac3db8)

Also IMO given it searches for the Font Name, the `Font folder name` parameter should probably just be renamed `Font Name`. As it was I tried to put in the name of the folder for the font, and was surprised it didn't work. 


author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-20T06:11:36Z
body: Thank you a lot for the review. This is very helpful.
author_association: MEMBER
user: login: kaalleen
created_at: 2025-03-21T07:32:12Z
body: Not sure if I am too happy with it as is :D
All I get is `Error writing: None` which means we run into an IOError.
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-21T16:57:19Z
body: That's interesting. I can't reproduce that on my machine (otherwise I wouldn't have pushed it!), can you provide any more details? Changing `        msg = _("Error writing: %(error)s") % dict(error=e.strerror)` to `        msg = _("Error writing: %(error)s") % dict(error=e)` might help provide more information. Alternatively if you don't want to debug this now I can just revert this change and re-introduce it as another MR for later.
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-21T17:27:00Z
body: It's `Error writing: seek` now. I suppose this is an issue with pyembroidery then, when we use it like that?
Maybe out of scope for this feature? If that's ok for you, I think re-introducing sounds like a good idea.
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-21T17:39:43Z
body: Yeah, it seems broken on Windows on my laptop, as well. Were you running on Windows? Regardless, I'll just revert my change and I'll MR it again later after debugging it.
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-21T17:41:43Z
body: No, I'm on Linux.
Sounds like a good plan :)
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-22T04:29:58Z
body: Oh, duh, I see the issue. ZipFile may be tolerant to writing to stdout (the issue I had with Windows aside - I'll debug it more later), but indeed, pyembroidery isn't always. I was testing with .jef, which can be written to sys.stdout, but several writers (.inf, .pec, .pes, .vp3, .xxx) attempt to `seek`, and some (.dst, .inf, .pec, .pes, .u01, .vp3, .xxx) attempt to `tell`, and neither of those operations are supported on stdout for obvious reasons. To make it work, we'd need to buffer the embroidery file in-memory with [io.BytesIO](https://docs.python.org/3/library/io.html#io.BytesIO) and then write the result out to the zip stream. That, or modify pyembroidery's writing methods to remove uses of seek and tell, but that's much more involved. I'll tinker with it, profile it to make sure it actually has tangible performance benefits, and MR it at some point.
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-22T05:48:06Z
body: I think it would be preferable to update pyembroidery. So other projects which want to use it can also have an advantage. 
This would make it take much longer though, as we are waiting at the moment to see if upstream pyembroidery is still active.
author_association: MEMBER
user: login: kaalleen
created_at: 2025-03-22T10:03:39Z
body: I would like to put more effort into sanitizing the file names. They should be able to put in any text, but the file name should also remove special chars as many embroidery machines would have problems with those. I'm not sure though, if this will be part of your pull request. So just let me know.
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-22T15:38:49Z
body: I can take a look for sanitization. Technically if we want to do it "right" besides sanitizing flashes, we probably also need to make sure it's not one of Windows' special file names. Might be worth pulling in a library for it, I'm sure someone's had this same problem before.
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-22T16:52:25Z
body: I'd like to avoid pulling in new libraries as much as possible. Maybe we just name them with a number :D

You often see something like this: `unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()` and then remove dots and slashes and alike. We may also restrict the strings length.
author_association: MEMBER
user: login: capellancitizen
created_at: 2025-03-25T02:23:11Z
body: [There's a funny quirk of Windows where certain filenames, including some that a user might type such as AUX, are reserved, and that includes those filenames with arbitrary extensions like aux.svg or aux.jef, for example](https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file).  There's a library called [sanitize_filename](https://pypi.org/project/sanitize-filename/) that already checks for all of those, in addition to stripping blacklisted special characters. It's a single file so it's very small, and I think that implementing the logic ourselves would eventually end up just recreating the logic it implements. It's MIT licensed FWIW.

As for implementing the in-memory streaming, worked on Linux, but on Windows it produces zip files that Windows explorer doesn't like, somehow. I'll tinker with it some more. But it doesn't realize any majorly appreciable performance gains on my SSD-equipped laptop, anyway, consistently shaving off about two seconds of a run with 8 output formats times three or four texts, so it's not like we're missing out on much if I take my time.
author_association: COLLABORATOR
user: login: capellancitizen
created_at: 2025-03-25T02:24:24Z
body: I guess then there's also the question of what happens if the user specifies multiple strings that differ only by punctuation that can't be output in the file name. If we even want to bother handling that, at that point I guess we could fall back to numbers.
author_association: COLLABORATOR
user: login: kaalleen
created_at: 2025-03-25T06:13:29Z
body: If there is not a real performance gain out of it, maybe it's better to save your time.

I'm a bit torn about sanitizing (and also handling the case of the same output for two very similar strings). Maybe you are right and numbers are a good choice after all. Text would be more convenient for a user, but it seems to cause too many problems for maybe a little benefit.
author_association: MEMBER