From 9f91470ac779dd13e9bbb2b3357c6d3a89c08ae5 Mon Sep 17 00:00:00 2001
From: Kaalleen <36401965+kaalleen@users.noreply.github.com>
Date: Fri, 21 Mar 2025 19:31:25 +0100
Subject: [PATCH] Add batch lettering extension (#3589)
---
lib/extensions/__init__.py | 2 +
lib/extensions/batch_lettering.py | 231 ++++++++++++++++++++
lib/extensions/lettering_along_path.py | 285 ++++++++++++++-----------
lib/lettering/__init__.py | 3 +-
lib/lettering/utils.py | 14 ++
templates/batch_lettering.xml | 83 +++++++
templates/lettering.xml | 5 +-
templates/lettering_along_path.xml | 16 +-
8 files changed, 502 insertions(+), 137 deletions(-)
create mode 100644 lib/extensions/batch_lettering.py
create mode 100644 templates/batch_lettering.xml
diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py
index 289cac312..ca15d42db 100644
--- a/lib/extensions/__init__.py
+++ b/lib/extensions/__init__.py
@@ -8,6 +8,7 @@ from .apply_palette import ApplyPalette
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 @@ extensions = [
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 000000000..44ae75695
--- /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 608479be7..10e62dd68 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,134 +35,7 @@ class LetteringAlongPath(InkstitchExtension):
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:
- errormsg(_("The text doesn't contain any glyphs."))
- return
-
- self.load_settings(text)
-
- if glyphs[0].get('transform', None) is not None:
- glyphs = self._reset_glyph_transforms(text, glyphs)
-
- 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)
- self.restore_commands(hidden_commands)
-
- def _reset_glyph_transforms(self, text_group, glyphs):
- font = get_font_by_id(self.settings.font)
- if font is not None:
- try:
- text_group = list(text_group.iterchildren(SVG_GROUP_TAG))[0]
- except IndexError:
- pass
- for glyph in text_group.iterchildren():
- text_group.remove(glyph)
- text = font.render_text(
- self.settings.text,
- text_group,
- None, # we don't know the font variant (?)
- self.settings.back_and_forth,
- 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()
- text_baseline = text_bbox.bottom
-
- if self.options.stretch_spaces:
- text_content = self.settings["text"]
- space_indices = [i for i, t in enumerate(text_content) if t == " "]
- text_bbox = text.bounding_box()
- text_width = convert_unit(text_bbox.width, 'px', self.svg.unit)
-
- if len(text_content) - 1 != 0:
- path_length = path.length
- stretch_space = (path_length - text_width) / (len(text_content) - 1)
- else:
- stretch_space = 0
- else:
- stretch_space = 0
- space_indices = []
-
- return space_indices, stretch_space, text_baseline
-
- def hide_commands(self, glyphs):
- # hide commmands for bounding box calculation
- hidden_commands = []
- for glyph in 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)
- group.style['display'] = 'none'
- return hidden_commands
-
- 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):
- text_scale = Transform(f'scale({self.settings.scale / 100})')
- distance = 0
- old_bbox = None
- i = 0
-
- for glyph in glyphs:
- # dimensions
- bbox = glyph.bounding_box()
- transformed_bbox = glyph.bounding_box(glyph.getparent().composed_transform())
- left = bbox.left
- transformed_left = transformed_bbox.left
- width = convert_unit(transformed_bbox.width, 'px', self.svg.unit)
-
- # adjust position
- 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:
- distance += stretch_space
- i += 1
-
- new_distance = distance + width
-
- # calculate and apply transform
- first = path.interpolate(distance)
- last = 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)
-
- transform = Transform(f"rotate({angle}, {first.x}, {first.y}) translate({translate.x} {translate.y})")
- correction_transform = Transform(get_correction_transform(glyph))
- glyph.transform = correction_transform @ transform @ glyph.transform @ text_scale
-
- # set values for next iteration
- distance = new_distance
- old_bbox = transformed_bbox
- i += 1
-
- def load_settings(self, text):
- """Load the settings saved into the text element"""
-
- self.settings = DotDict({
- "text": "",
- "back_and_forth": False,
- "font": None,
- "scale": 100,
- "trim_option": 0,
- "use_trim_symbols": False
- })
-
- if INKSTITCH_LETTERING in text.attrib:
- try:
- self.settings.update(json.loads(text.get(INKSTITCH_LETTERING)))
- except (TypeError, ValueError):
- pass
+ TextAlongPath(self.svg, text, path, self.options.text_position)
def get_selection(self):
groups = list()
@@ -188,3 +61,155 @@ class LetteringAlongPath(InkstitchExtension):
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()
+
+ if self.glyphs[0].get('transform', None) is not None:
+ self._reset_glyph_transforms()
+
+ 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):
+ font = get_font_by_id(self.settings.font)
+ if font is not None:
+ try:
+ text_group = list(self.text.iterchildren(SVG_GROUP_TAG))[0]
+ except IndexError:
+ pass
+ for glyph in text_group.iterchildren():
+ text_group.remove(glyph)
+ rendered_text = font.render_text(
+ self.settings.text,
+ text_group,
+ None, # we don't know the font variant (?)
+ self.settings.back_and_forth,
+ self.settings.trim_option,
+ self.settings.use_trim_symbols
+ )
+ 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.text_position == 'stretch':
+ text_content = self.settings.text
+ space_indices = [i for i, t in enumerate(text_content) if t == " "]
+ 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 = self.path.length
+ stretch_space = (path_length - text_width) / (len(text_content) - 1)
+ else:
+ stretch_space = 0
+ else:
+ stretch_space = 0
+ space_indices = []
+
+ return space_indices, stretch_space, text_baseline
+
+ 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 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)
+ group.style['display'] = 'none'
+ return hidden_commands
+
+ def restore_commands(self, hidden_commands):
+ for command in hidden_commands:
+ command.style['display'] = "inline"
+
+ def transform_glyphs(self, start_position, stretch_space, space_indices, text_baseline):
+ text_scale = Transform(f'scale({self.settings.scale / 100})')
+ distance = start_position
+ old_bbox = None
+ i = 0
+
+ for glyph in self.glyphs:
+ # dimensions
+ bbox = glyph.bounding_box()
+ transformed_bbox = glyph.bounding_box(glyph.getparent().composed_transform())
+ left = bbox.left
+ transformed_left = transformed_bbox.left
+ width = convert_unit(transformed_bbox.width, 'px', self.svg.unit)
+
+ # adjust position
+ if old_bbox:
+ distance += convert_unit(transformed_left - old_bbox.right, 'px', self.svg.unit) + stretch_space
+
+ if self.text_position == 'stretch' and i in space_indices:
+ distance += stretch_space
+ i += 1
+
+ new_distance = distance + width
+
+ # calculate and apply transform
+ 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)
+
+ transform = Transform(f"rotate({angle}, {first.x}, {first.y}) translate({translate.x} {translate.y})")
+ correction_transform = Transform(get_correction_transform(glyph))
+ glyph.transform = correction_transform @ transform @ glyph.transform @ text_scale
+
+ # set values for next iteration
+ distance = new_distance
+ old_bbox = transformed_bbox
+ i += 1
+
+ def load_settings(self):
+ """Load the settings saved into the text element"""
+
+ self.settings = DotDict({
+ "text": "",
+ "back_and_forth": False,
+ "font": None,
+ "scale": 100,
+ "trim_option": 0,
+ "use_trim_symbols": False
+ })
+
+ if INKSTITCH_LETTERING in self.text.attrib:
+ try:
+ self.settings.update(json.loads(self.text.get(INKSTITCH_LETTERING)))
+ except (TypeError, ValueError):
+ pass
diff --git a/lib/lettering/__init__.py b/lib/lettering/__init__.py
index 60d475817..679ce23af 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 999976430..f643dfa70 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 000000000..ff750f7e8
--- /dev/null
+++ b/templates/batch_lettering.xml
@@ -0,0 +1,83 @@
+
+
+ Batch Lettering
+ org.{{ id_inkstitch }}.output.batch_lettering
+ batch_lettering
+
+
+
+
+
+
+
+
+
+
+
+
+ 100
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/lettering.xml b/templates/lettering.xml
index 5eaa7e0af..c15a74880 100644
--- a/templates/lettering.xml
+++ b/templates/lettering.xml
@@ -6,8 +6,11 @@
all
{{ icon_path }}inx/lettering.svg
+ Insert ready-to-embroider text into the document
-
+
+
+