Add batch lettering extension (#3589)

pull/3600/head
Kaalleen 2025-03-21 19:31:25 +01:00 zatwierdzone przez GitHub
rodzic de7d86e526
commit 9f91470ac7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
8 zmienionych plików z 502 dodań i 137 usunięć

Wyświetl plik

@ -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,

Wyświetl plik

@ -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()

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>