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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + .zip + application/zip + {{ menu_inkstitch }}: batch lettering (.zip) + Create a zip with multiple files including embroidered text using Ink/Stitch + true + + + + 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 - + + +