diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/elements/clone.py | 9 | ||||
| -rw-r--r-- | lib/elements/polyline.py | 2 | ||||
| -rw-r--r-- | lib/elements/satin_column.py | 4 | ||||
| -rw-r--r-- | lib/extensions/__init__.py | 2 | ||||
| -rw-r--r-- | lib/extensions/cutwork_segmentation.py | 59 | ||||
| -rw-r--r-- | lib/extensions/display_stacking_order.py | 60 | ||||
| -rw-r--r-- | lib/extensions/element_info.py | 38 | ||||
| -rw-r--r-- | lib/extensions/input.py | 5 | ||||
| -rw-r--r-- | lib/extensions/lettering.py | 487 | ||||
| -rw-r--r-- | lib/extensions/letters_to_font.py | 9 | ||||
| -rw-r--r-- | lib/extensions/troubleshoot.py | 22 | ||||
| -rw-r--r-- | lib/gui/lettering.py | 493 | ||||
| -rw-r--r-- | lib/stitch_plan/generate_stitch_plan.py | 3 | ||||
| -rw-r--r-- | lib/stitches/auto_fill.py | 2 | ||||
| -rw-r--r-- | lib/stitches/auto_run.py | 5 | ||||
| -rw-r--r-- | lib/stitches/contour_fill.py | 15 | ||||
| -rw-r--r-- | lib/stitches/fill.py | 13 | ||||
| -rw-r--r-- | lib/stitches/utils/autoroute.py | 97 | ||||
| -rw-r--r-- | lib/utils/geometry.py | 34 |
19 files changed, 753 insertions, 606 deletions
diff --git a/lib/elements/clone.py b/lib/elements/clone.py index 5100def1..fdcd6835 100644 --- a/lib/elements/clone.py +++ b/lib/elements/clone.py @@ -6,6 +6,7 @@ from math import atan2, degrees, radians from inkex import CubicSuperPath, Path, Transform +from shapely import MultiLineString from ..commands import is_command_symbol from ..i18n import _ @@ -142,6 +143,14 @@ class Clone(EmbroideryElement): center = self.node.bounding_box(transform).center return center + @property + def shape(self): + path = self.node.get_path() + transform = Transform(self.node.composed_transform()) + path = path.transform(transform) + path = path.to_superpath() + return MultiLineString(path) + def validation_warnings(self): source_node = get_clone_source(self.node) if source_node.tag not in EMBROIDERABLE_TAGS: diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index a33b75de..4bc71dc0 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -49,7 +49,7 @@ class Polyline(EmbroideryElement): @property @cache def shape(self): - return shgeo.LineString(self.path) + return shgeo.MultiLineString(self.csp[0]) @property def path(self): diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index 40fc27be..3daf1085 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -684,10 +684,10 @@ class SatinColumn(EmbroideryElement): if len(self.csp) < 2: yield TooFewPathsError((0, 0)) elif len(self.rails) < 2: - yield TooFewPathsError(self.shape.centroid) + yield TooFewPathsError(self.flattened_rails[0].representative_point()) if not self.to_stitch_groups(): - yield NotStitchableError(self.shape.centroid) + yield NotStitchableError(self.flattened_rails[0].representative_point()) def _center_walk_is_odd(self): return self.center_walk_underlay_repeats % 2 == 1 diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index 1b923a44..3012ca5a 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -16,6 +16,7 @@ from .convert_to_stroke import ConvertToStroke from .cut_satin import CutSatin from .cutwork_segmentation import CutworkSegmentation from .density_map import DensityMap +from .display_stacking_order import DisplayStackingOrder from .duplicate_params import DuplicateParams from .element_info import ElementInfo from .fill_to_stroke import FillToStroke @@ -71,6 +72,7 @@ __all__ = extensions = [ApplyThreadlist, CutSatin, CutworkSegmentation, DensityMap, + DisplayStackingOrder, DuplicateParams, ElementInfo, FillToStroke, diff --git a/lib/extensions/cutwork_segmentation.py b/lib/extensions/cutwork_segmentation.py index 537cfadf..6e9e17a3 100644 --- a/lib/extensions/cutwork_segmentation.py +++ b/lib/extensions/cutwork_segmentation.py @@ -5,15 +5,13 @@ from math import atan2, degrees -from lxml import etree -from shapely.geometry import LineString, Point - import inkex +from shapely.geometry import LineString, Point from ..elements import Stroke from ..i18n import _ from ..svg import get_correction_transform -from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS, SVG_PATH_TAG +from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS from .base import InkstitchExtension @@ -27,18 +25,18 @@ class CutworkSegmentation(InkstitchExtension): InkstitchExtension.__init__(self, *args, **kwargs) self.arg_parser.add_argument("-o", "--options", type=str, default=None, dest="page_1") self.arg_parser.add_argument("-i", "--info", type=str, default=None, dest="page_2") - self.arg_parser.add_argument("-as", "--a_start", type=int, default=0, dest="a_start") - self.arg_parser.add_argument("-ae", "--a_end", type=int, default=0, dest="a_end") - self.arg_parser.add_argument("-ac", "--a_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="a_color") - self.arg_parser.add_argument("-bs", "--b_start", type=int, default=0, dest="b_start") - self.arg_parser.add_argument("-be", "--b_end", type=int, default=0, dest="b_end") - self.arg_parser.add_argument("-bc", "--b_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="b_color") - self.arg_parser.add_argument("-cs", "--c_start", type=int, default=0, dest="c_start") - self.arg_parser.add_argument("-ce", "--c_end", type=int, default=0, dest="c_end") - self.arg_parser.add_argument("-cc", "--c_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="c_color") - self.arg_parser.add_argument("-ds", "--d_start", type=int, default=0, dest="d_start") - self.arg_parser.add_argument("-de", "--d_end", type=int, default=0, dest="d_end") - self.arg_parser.add_argument("-dc", "--d_color", type=inkex.Color, default=inkex.Color(0x808080FF), dest="d_color") + self.arg_parser.add_argument("-as", "--a_start", type=int, default=112, dest="a_start") + self.arg_parser.add_argument("-ae", "--a_end", type=int, default=157, dest="a_end") + self.arg_parser.add_argument("-ac", "--a_color", type=inkex.Color, default=inkex.Color(0x990000ff), dest="a_color") + self.arg_parser.add_argument("-bs", "--b_start", type=int, default=158, dest="b_start") + self.arg_parser.add_argument("-be", "--b_end", type=int, default=23, dest="b_end") + self.arg_parser.add_argument("-bc", "--b_color", type=inkex.Color, default=inkex.Color(0xe5a50aff), dest="b_color") + self.arg_parser.add_argument("-cs", "--c_start", type=int, default=22, dest="c_start") + self.arg_parser.add_argument("-ce", "--c_end", type=int, default=68, dest="c_end") + self.arg_parser.add_argument("-cc", "--c_color", type=inkex.Color, default=inkex.Color(0x009900ff), dest="c_color") + self.arg_parser.add_argument("-ds", "--d_start", type=int, default=67, dest="d_start") + self.arg_parser.add_argument("-de", "--d_end", type=int, default=113, dest="d_end") + self.arg_parser.add_argument("-dc", "--d_color", type=inkex.Color, default=inkex.Color(0x000099ff), dest="d_color") self.arg_parser.add_argument("-s", "--sort_by_color", type=inkex.Boolean, default=True, dest="sort_by_color") self.arg_parser.add_argument("-k", "--keep_original", type=inkex.Boolean, default=False, dest="keep_original") @@ -139,17 +137,16 @@ class CutworkSegmentation(InkstitchExtension): d = "M " for point in point_list: - d += "%s,%s " % (point.x, point.y) - - stroke_element = etree.Element(SVG_PATH_TAG, - { - "style": color, - "transform": get_correction_transform(element.node), - INKSTITCH_ATTRIBS["ties"]: "3", - INKSTITCH_ATTRIBS["running_stitch_length_mm"]: "1", - INKSTITCH_ATTRIBS["cutwork_needle"]: str(sector['id']), - "d": d - }) + d += f"{ point.x }, { point.y } " + + stroke_element = inkex.PathElement(attrib={ + "style": color, + "transform": get_correction_transform(element.node), + INKSTITCH_ATTRIBS["ties"]: "3", + INKSTITCH_ATTRIBS["running_stitch_length_mm"]: "1", + INKSTITCH_ATTRIBS["cutwork_needle"]: str(sector['id']), + "d": d + }) self.new_elements.append([stroke_element, sector['id']]) # clear point_list in self.sectors self.sectors[sector['id']].update({'point_list': []}) @@ -174,9 +171,9 @@ class CutworkSegmentation(InkstitchExtension): section_group.insert(0, element) def _insert_group(self, parent, label, group_id, index=0): - group = etree.Element("g", { - INKSCAPE_LABEL: "%s" % label, - "id": self.uniqueId("%s" % group_id) + group = inkex.Group(attrib={ + INKSCAPE_LABEL: label, + "id": self.uniqueId(group_id) }) parent.insert(index, group) return group @@ -192,4 +189,4 @@ class CutworkSegmentation(InkstitchExtension): def path_style(self, element, color): # set stroke color and make it a running stitch - they don't want to cut zigzags - return inkex.Style(element.node.get('style', '')) + inkex.Style('stroke:%s;stroke-dasharray:6,1;' % color) + return inkex.Style(element.node.get('style', '')) + inkex.Style(f'stroke-width:1;stroke:{ color };') diff --git a/lib/extensions/display_stacking_order.py b/lib/extensions/display_stacking_order.py new file mode 100644 index 00000000..4056081d --- /dev/null +++ b/lib/extensions/display_stacking_order.py @@ -0,0 +1,60 @@ +# Authors: see git history +# +# Copyright (c) 2022 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import inkex + +from ..i18n import _ +from ..svg.tags import INKSCAPE_GROUPMODE, INKSCAPE_LABEL +from .base import InkstitchExtension + + +class DisplayStackingOrder(InkstitchExtension): + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self, *args, **kwargs) + self.arg_parser.add_argument("-s", "--font_size", type=int, default=4, dest="font_size") + + def effect(self): + layer = self.create_layer() + + nodes = self.get_nodes() + for i, node in enumerate(nodes): + if node.style['fill'] != 'none': + position = node.bounding_box(node.composed_transform()).minimum + self.insert_stacking_num(layer, i + 1, position) + else: + path = node.get_path().transform(node.composed_transform()) + position = next(path.end_points) + self.insert_stacking_num(layer, i + 1, position) + + # remove layer if empty + if len(layer) == 0: + self.svg.remove(layer) + + def insert_stacking_num(self, layer, num, position): + text = inkex.TextElement(attrib={ + 'x': str(position[0]), + 'y': str(position[1]) + }) + text.style = inkex.Style(f"text-anchor: middle;text-align: center;dominant-baseline: middle;font-size: { self.options.font_size }") + tspan = inkex.Tspan() + tspan.text = str(num) + text.add(tspan) + layer.add(text) + + def create_layer(self): + layer = self.svg.find(".//*[@id='__inkstitch_stacking_order__']") + + # Remove the existing layer + if layer is not None: + layer.getparent().remove(layer) + + layer = inkex.Group(attrib={ + 'id': '__inkstitch_stacking_order__', + INKSCAPE_LABEL: _('Stacking Order'), + INKSCAPE_GROUPMODE: 'layer', + }) + self.svg.append(layer) + + return layer diff --git a/lib/extensions/element_info.py b/lib/extensions/element_info.py index 84730bf7..0e889ddf 100644 --- a/lib/extensions/element_info.py +++ b/lib/extensions/element_info.py @@ -19,12 +19,13 @@ class ElementInfo(InkstitchExtension): self.metadata = self.get_inkstitch_metadata() self.list_items = [] - - self._general_info() + self.max_stitch_lengths = [] + self.min_stitch_lengths = [] previous_stitch_group = None for element in self.elements: previous_stitch_group = self._element_info(element, previous_stitch_group) + self._general_info() app = ElementInfoApp(self.list_items) app.MainLoop() @@ -86,6 +87,8 @@ class ElementInfo(InkstitchExtension): continue stitch_lengths.append(length) previous_stitch = stitch + self.max_stitch_lengths.append(max(stitch_lengths)) + self.min_stitch_lengths.append(min(stitch_lengths)) stitches_per_group = "" if len(stitch_groups) > 1: @@ -115,6 +118,7 @@ class ElementInfo(InkstitchExtension): return stitch_groups[0] def _general_info(self): + general_info_list_items = [] stitch_groups = self.elements_to_stitch_groups(self.elements) stitch_plan = stitch_groups_to_stitch_plan( stitch_groups, @@ -122,43 +126,53 @@ class ElementInfo(InkstitchExtension): min_stitch_len=self.metadata['min_stitch_len'] ) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("All Selected Elements"), headline=True )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Dimensions (mm)"), value="{:.2f} x {:.2f}".format(stitch_plan.dimensions_mm[0], stitch_plan.dimensions_mm[1]) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Colors"), value=str(stitch_plan.num_colors) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Color Changes"), value=str(stitch_plan.num_color_blocks - 1) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Jumps"), value=str(stitch_plan.num_jumps) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Trims"), value=str(stitch_plan.num_trims) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Stops"), value=str(stitch_plan.num_stops) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( name=_("Stitches"), value=str(stitch_plan.num_stitches - stitch_plan.num_jumps) )) - self.list_items.append(ListItem( + general_info_list_items.append(ListItem( + name=_("Min stitch length"), + value="{:.2f}".format(min(self.min_stitch_lengths)) + )) + general_info_list_items.append(ListItem( + name=_("Max stitch length"), + value="{:.2f}".format(max(self.max_stitch_lengths)) + )) + general_info_list_items.append(ListItem( name=_("Filter stitches smaller than (mm)"), value=str(self.metadata['min_stitch_len_mm']) )) - self.list_items.append(ListItem()) + general_info_list_items.append(ListItem()) + + self.list_items = general_info_list_items + self.list_items class ListItem: diff --git a/lib/extensions/input.py b/lib/extensions/input.py index 4240be0e..3bcbb5a2 100644 --- a/lib/extensions/input.py +++ b/lib/extensions/input.py @@ -3,9 +3,10 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -from lxml import etree +from html import unescape from inkex import errormsg +from lxml import etree from ..i18n import _ from ..stitch_plan import generate_stitch_plan @@ -20,4 +21,4 @@ class Input(object): errormsg(msg) exit(0) stitch_plan = generate_stitch_plan(embroidery_file) - print(etree.tostring(stitch_plan).decode('utf-8')) + print(unescape(etree.tostring(stitch_plan).decode('utf-8'))) diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py index 6f79d9a7..43ff424d 100644 --- a/lib/extensions/lettering.py +++ b/lib/extensions/lettering.py @@ -3,499 +3,18 @@ # Copyright (c) 2010 Authors # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. -import json -import os import sys -from base64 import b64decode -import appdirs import inkex import wx import wx.adv -import wx.lib.agw.floatspin as fs -from .commands import CommandsExtension -from .lettering_custom_font_dir import get_custom_font_dir -from ..elements import nodes_to_elements -from ..gui import PresetsPanel, PreviewRenderer, info_dialog +from ..gui.lettering import LetteringPanel from ..gui.simulator import SplitSimulatorWindow from ..i18n import _ -from ..lettering import Font, FontError -from ..lettering.categories import FONT_CATEGORIES, FontCategory -from ..stitch_plan import stitch_groups_to_stitch_plan from ..svg import get_correction_transform -from ..svg.tags import (INKSCAPE_LABEL, INKSTITCH_LETTERING, SVG_GROUP_TAG, - SVG_PATH_TAG) -from ..utils import DotDict, cache, get_bundled_dir -from ..utils.threading import ExitThread, check_stop_flag - - -class LetteringPanel(wx.Panel): - DEFAULT_FONT = "small_font" - - def __init__(self, parent, simulator, group, on_cancel=None, metadata=None): - self.parent = parent - self.simulator = simulator - self.group = group - self.cancel_hook = on_cancel - self.metadata = metadata or dict() - - super().__init__(parent, wx.ID_ANY) - - self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) - - self.preview_renderer = PreviewRenderer(self.render_stitch_plan, self.on_stitch_plan_rendered) - self.presets_panel = PresetsPanel(self) - - # font - self.font_selector_box = wx.StaticBox(self, wx.ID_ANY, label=_("Font")) - - self.font_chooser = wx.adv.BitmapComboBox(self, wx.ID_ANY, style=wx.CB_READONLY | wx.CB_SORT) - self.font_chooser.Bind(wx.EVT_COMBOBOX, self.on_font_changed) - - self.font_size_filter = fs.FloatSpin(self, min_val=0, max_val=None, increment=1, value="0") - self.font_size_filter.SetFormat("%f") - self.font_size_filter.SetDigits(2) - self.font_size_filter.Bind(fs.EVT_FLOATSPIN, self.on_filter_changed) - self.font_size_filter.SetToolTip(_("Font size filter (mm). 0 for all sizes.")) - - self.font_glyph_filter = wx.CheckBox(self, label=_("Glyphs")) - self.font_glyph_filter.Bind(wx.EVT_CHECKBOX, self.on_filter_changed) - self.font_glyph_filter.SetToolTip(_("Filter fonts by available glyphs.")) - - self.font_category_filter = wx.ComboBox(self, wx.ID_ANY, choices=[], style=wx.CB_DROPDOWN | wx.CB_READONLY) - unfiltered = FontCategory('unfiltered', "---") - self.font_category_filter.Append(unfiltered.name, unfiltered) - for category in FONT_CATEGORIES: - self.font_category_filter.Append(category.name, category) - self.font_category_filter.SetToolTip(_("Filter fonts by category.")) - self.font_category_filter.SetSelection(0) - self.font_category_filter.Bind(wx.EVT_COMBOBOX, self.on_filter_changed) - - # font details - self.font_description = wx.StaticText(self, wx.ID_ANY) - self.Bind(wx.EVT_SIZE, self.resize) - - # font filter - self.filter_box = wx.StaticBox(self, wx.ID_ANY, label=_("Font Filter")) - - # options - self.options_box = wx.StaticBox(self, wx.ID_ANY, label=_("Options")) - - self.scale_spinner = wx.SpinCtrl(self, wx.ID_ANY, min=0, max=1000, initial=100) - self.scale_spinner.Bind(wx.EVT_SPINCTRL, lambda event: self.on_change("scale", event)) - - self.back_and_forth_checkbox = wx.CheckBox(self, label=_("Stitch lines of text back and forth")) - self.back_and_forth_checkbox.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("back_and_forth", event)) - - self.trim_option_choice = wx.Choice(self, choices=[_("Never"), _("after each line"), _("after each word"), _("after each letter")], - name=_("Add trim command")) - self.trim_option_choice.Bind(wx.EVT_CHOICE, lambda event: self.on_trim_option_change(event)) - - self.use_trim_symbols = wx.CheckBox(self, label=_("Use command symbols")) - self.use_trim_symbols.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("use_trim_symbols", event)) - self.use_trim_symbols.SetToolTip(_('Uses command symbols if enabled. When disabled inserts trim commands as params.')) - - # text editor - self.text_input_box = wx.StaticBox(self, wx.ID_ANY, label=_("Text")) - - self.text_editor = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_DONTWRAP) - self.text_editor.Bind(wx.EVT_TEXT, lambda event: self.on_change("text", event)) - - self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) - self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) - self.Bind(wx.EVT_CLOSE, self.cancel) - - self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) - self.apply_button.Bind(wx.EVT_BUTTON, self.apply) - - # set font list - self.update_font_list() - self.set_font_list() - - self.__do_layout() - - self.load_settings() - self.apply_settings() - - def load_settings(self): - """Load the settings saved into the SVG group 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.group.attrib: - try: - self.settings.update(json.loads(self.group.get(INKSTITCH_LETTERING))) - except json.decoder.JSONDecodeError: - # legacy base64 encoded (changed in v2.0) - try: - self.settings.update(json.loads(b64decode(self.group.get(INKSTITCH_LETTERING)))) - except (TypeError, ValueError): - pass - except (TypeError, ValueError): - pass - - def apply_settings(self): - """Make the settings in self.settings visible in the UI.""" - self.back_and_forth_checkbox.SetValue(bool(self.settings.back_and_forth)) - self.trim_option_choice.SetSelection(self.settings.trim_option) - self.use_trim_symbols.SetValue(bool(self.settings.use_trim_symbols)) - self.text_editor.SetValue(self.settings.text) - self.scale_spinner.SetValue(self.settings.scale) - self.set_initial_font(self.settings.font) - - def save_settings(self): - """Save the settings into the SVG group element.""" - self.group.set(INKSTITCH_LETTERING, json.dumps(self.settings)) - - @property - @cache - def font_list(self): - fonts = [] - font_paths = { - get_bundled_dir("fonts"), - os.path.expanduser("~/.inkstitch/fonts"), - os.path.join(appdirs.user_config_dir('inkstitch'), 'fonts'), - get_custom_font_dir() - } - - 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.marked_custom_font_name == "" or font.marked_custom_font_id == "": - continue - fonts.append(font) - return fonts - - def update_font_list(self): - self.fonts = {} - self.fonts_by_id = {} - - # font size filter value - filter_size = self.font_size_filter.GetValue() - filter_glyph = self.font_glyph_filter.GetValue() - filter_category = self.font_category_filter.GetSelection() - 1 - - # glyph filter string without spaces - glyphs = [*self.text_editor.GetValue().replace(" ", "").replace("\n", "")] - - for font in self.font_list: - if filter_glyph and glyphs and not set(glyphs).issubset(font.available_glyphs): - continue - - if filter_category != -1: - category = FONT_CATEGORIES[filter_category].id - if category not in font.keywords: - continue - - if filter_size != 0 and (filter_size < font.size * font.min_scale or filter_size > font.size * font.max_scale): - continue - - self.fonts[font.marked_custom_font_name] = font - self.fonts_by_id[font.marked_custom_font_id] = font - - def set_font_list(self): - self.font_chooser.Clear() - for font in self.fonts.values(): - image = font.preview_image - - if image is not None: - image = wx.Image(image) - """ - # I would like to do this but Windows requires all images to be the exact same size - # It might work with an updated wxpython version - so let's keep it here - - # Scale to max 20 height - img_height = 20 - width, height = image.GetSize() - scale_factor = height / img_height - width = int(width / scale_factor) - image.Rescale(width, img_height, quality=wx.IMAGE_QUALITY_HIGH) - """ - # Windows requires all images to have the exact same size - image.Rescale(300, 20, quality=wx.IMAGE_QUALITY_HIGH) - self.font_chooser.Append(font.marked_custom_font_name, wx.Bitmap(image)) - else: - self.font_chooser.Append(font.marked_custom_font_name) - - def get_font_descriptions(self): - return {font.name: font.description for font in self.fonts.values()} - - def set_initial_font(self, font_id): - if font_id: - if font_id not in self.fonts_by_id: - message = '''This text was created using the font "%s", but Ink/Stitch can't find that font. ''' \ - '''A default font will be substituted.''' - info_dialog(self, _(message) % font_id) - try: - font = self.fonts_by_id[font_id].marked_custom_font_name - except KeyError: - font = self.default_font.marked_custom_font_name - self.font_chooser.SetValue(font) - - self.on_font_changed() - - @property - def default_font(self): - try: - return self.fonts_by_id[self.DEFAULT_FONT] - except KeyError: - return list(self.fonts.values())[0] - - def on_change(self, attribute, event): - self.settings[attribute] = event.GetEventObject().GetValue() - if attribute == "text" and self.font_glyph_filter.GetValue() is True: - self.on_filter_changed() - self.preview_renderer.update() - - def on_trim_option_change(self, event=None): - self.settings.trim_option = self.trim_option_choice.GetCurrentSelection() - self.preview_renderer.update() - - def on_font_changed(self, event=None): - font = self.fonts.get(self.font_chooser.GetValue(), self.default_font) - self.settings.font = font.marked_custom_font_id - - filter_size = self.font_size_filter.GetValue() - self.scale_spinner.SetRange(int(font.min_scale * 100), int(font.max_scale * 100)) - if filter_size != 0: - self.scale_spinner.SetValue(int(filter_size / font.size * 100)) - self.settings['scale'] = self.scale_spinner.GetValue() - - font_variants = [] - try: - font_variants = font.has_variants() - except FontError: - pass - - # Update font description - color = wx.NullColour - description = font.description - if len(font_variants) == 0: - color = (255, 0, 0) - description = _('This font has no available font variant. Please update or remove the font.') - self.font_description.SetLabel(description) - self.font_description.SetForegroundColour(color) - self.font_description.Wrap(self.GetSize().width - 35) - - if font.reversible: - self.back_and_forth_checkbox.Enable() - self.back_and_forth_checkbox.SetValue(bool(self.settings.back_and_forth)) - else: - # The creator of the font banned the possibility of writing in reverse with json file: "reversible": false - self.back_and_forth_checkbox.Disable() - self.back_and_forth_checkbox.SetValue(False) - - self.update_preview() - self.Layout() - - def on_filter_changed(self, event=None): - self.update_font_list() - - if not self.fonts: - # No fonts for filtered size - self.font_chooser.Clear() - self.filter_box.SetForegroundColour("red") - return - else: - self.filter_box.SetForegroundColour(wx.NullColour) - - filter_size = self.font_size_filter.GetValue() - previous_font = self.font_chooser.GetValue() - self.set_font_list() - font = self.fonts.get(previous_font, self.default_font) - self.font_chooser.SetValue(font.marked_custom_font_name) - if font.marked_custom_font_name != previous_font: - self.on_font_changed() - elif filter_size != 0: - self.scale_spinner.SetValue(int(filter_size / font.size * 100)) - self.settings['scale'] = self.scale_spinner.GetValue() - - def resize(self, event=None): - description = self.font_description.GetLabel().replace("\n", " ") - self.font_description.SetLabel(description) - self.font_description.Wrap(self.GetSize().width - 35) - self.Layout() - - def update_preview(self, event=None): - self.preview_renderer.update() - - def update_lettering(self, raise_error=False): - # return if there is no font in the font list (possibly due to a font size filter) - if not self.font_chooser.GetValue(): - return - - del self.group[:] - - if self.settings.scale == 100: - destination_group = self.group - else: - destination_group = inkex.Group(attrib={ - # L10N The user has chosen to scale the text by some percentage - # (50%, 200%, etc). If you need to use the percentage symbol, - # make sure to double it (%%). - INKSCAPE_LABEL: _("Text scale %s%%") % self.settings.scale - }) - self.group.append(destination_group) - - font = self.fonts.get(self.font_chooser.GetValue(), self.default_font) - try: - font.render_text(self.settings.text, destination_group, back_and_forth=self.settings.back_and_forth, - trim_option=self.settings.trim_option, use_trim_symbols=self.settings.use_trim_symbols) - - except FontError as e: - if raise_error: - inkex.errormsg(_("Error: Text cannot be applied to the document.\n%s") % e) - return - else: - pass - - # destination_group isn't always the text scaling group (but also the parent group) - # the text scaling group label is dependend on the user language, so it would break in international file exchange if we used it - # scaling (correction transform) on the parent group is already applied, so let's use that for recognition - if self.settings.scale != 100 and not destination_group.get('transform', None): - destination_group.attrib['transform'] = 'scale(%s)' % (self.settings.scale / 100.0) - - def render_stitch_plan(self): - stitch_groups = [] - - try: - self.update_lettering() - elements = nodes_to_elements(self.group.iterdescendants(SVG_PATH_TAG)) - - for element in elements: - check_stop_flag() - - stitch_groups.extend(element.embroider(None)) - - if stitch_groups: - return stitch_groups_to_stitch_plan( - stitch_groups, - collapse_len=self.metadata['collapse_len_mm'], - min_stitch_len=self.metadata['min_stitch_len_mm'] - ) - except SystemExit: - raise - except ExitThread: - raise - except Exception: - raise - # Ignore errors. This can be things like incorrect paths for - # satins or division by zero caused by incorrect param values. - pass - - def on_stitch_plan_rendered(self, stitch_plan): - self.simulator.stop() - self.simulator.load(stitch_plan) - self.simulator.go() - - def get_preset_data(self): - # called by self.presets_panel - settings = dict(self.settings) - del settings["text"] - return settings - - def apply_preset_data(self, preset_data): - settings = DotDict(preset_data) - settings["text"] = self.settings.text - self.settings = settings - self.apply_settings() - - def get_preset_suite_name(self): - # called by self.presets_panel - return "lettering" - - def apply(self, event): - self.update_lettering(True) - self.save_settings() - self.close() - - def close(self): - self.GetTopLevelParent().Close() - - def cancel(self, event): - if self.cancel_hook: - self.cancel_hook() - - self.close() - - def __do_layout(self): - outer_sizer = wx.BoxSizer(wx.VERTICAL) - - # font selection - font_selector_sizer = wx.StaticBoxSizer(self.font_selector_box, wx.VERTICAL) - font_selector_box = wx.BoxSizer(wx.HORIZONTAL) - font_selector_box.Add(self.font_chooser, 4, wx.EXPAND | wx.TOP | wx.BOTTOM | wx.RIGHT, 10) - font_selector_sizer.Add(font_selector_box, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - font_selector_sizer.Add(self.font_description, 1, wx.EXPAND | wx.ALL, 10) - outer_sizer.Add(font_selector_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - - # filter fon list - filter_sizer = wx.StaticBoxSizer(self.filter_box, wx.HORIZONTAL) - filter_size_label = wx.StaticText(self, wx.ID_ANY, _("Size")) - filter_sizer.Add(filter_size_label, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 10) - filter_sizer.AddSpacer(5) - filter_sizer.Add(self.font_size_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) - filter_sizer.AddSpacer(5) - filter_sizer.Add(self.font_glyph_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) - filter_sizer.Add(self.font_category_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) - outer_sizer.Add(filter_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - - # options - left_option_sizer = wx.BoxSizer(wx.VERTICAL) - left_option_sizer.Add(self.back_and_forth_checkbox, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 5) - - trim_option_sizer = wx.BoxSizer(wx.HORIZONTAL) - trim_option_sizer.Add(wx.StaticText(self, wx.ID_ANY, _("Add trims")), 0, wx.LEFT | wx.ALIGN_TOP, 5) - trim_option_sizer.Add(self.trim_option_choice, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 5) - trim_option_sizer.Add(self.use_trim_symbols, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 5) - left_option_sizer.Add(trim_option_sizer, 0, wx.ALIGN_LEFT, 5) - - font_scale_sizer = wx.BoxSizer(wx.HORIZONTAL) - font_scale_sizer.Add(wx.StaticText(self, wx.ID_ANY, _("Scale")), 0, wx.LEFT | wx.ALIGN_CENTRE_VERTICAL, 0) - font_scale_sizer.Add(self.scale_spinner, 0, wx.LEFT, 10) - font_scale_sizer.Add(wx.StaticText(self, wx.ID_ANY, "%"), 0, wx.LEFT | wx.ALIGN_CENTRE_VERTICAL, 3) - - options_sizer = wx.StaticBoxSizer(self.options_box, wx.HORIZONTAL) - options_sizer.Add(left_option_sizer, 1, wx.EXPAND, 10) - options_sizer.Add(font_scale_sizer, 0, wx.RIGHT, 10) - - outer_sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - - # text input - text_input_sizer = wx.StaticBoxSizer(self.text_input_box, wx.VERTICAL) - text_input_sizer.Add(self.text_editor, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) - outer_sizer.Add(text_input_sizer, 2, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) - - # presets - outer_sizer.Add(self.presets_panel, 0, wx.EXPAND | wx.EXPAND | wx.ALL, 10) - buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) - buttons_sizer.Add(self.cancel_button, 0, wx.RIGHT, 10) - buttons_sizer.Add(self.apply_button, 0, wx.RIGHT | wx.BOTTOM, 10) - outer_sizer.Add(buttons_sizer, 0, wx.ALIGN_RIGHT, 10) - - self.SetSizerAndFit(outer_sizer) - self.Layout() - - # SetSizerAndFit determined the minimum size that fits all the controls - # and set the window's minimum size so that the user can't make it - # smaller. It also set the window to that size. We'd like to give the - # user a bit more room for text, so we'll add some height. - size = self.GetSize() - size.height = size.height + 200 - self.SetSize(size) +from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_LETTERING, SVG_GROUP_TAG +from .commands import CommandsExtension class Lettering(CommandsExtension): diff --git a/lib/extensions/letters_to_font.py b/lib/extensions/letters_to_font.py index d4d9e60a..e779b5d1 100644 --- a/lib/extensions/letters_to_font.py +++ b/lib/extensions/letters_to_font.py @@ -4,6 +4,7 @@ # Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. import os +from html import escape, unescape from pathlib import Path import inkex @@ -42,7 +43,8 @@ class LettersToFont(InkstitchExtension): group = None for glyph in glyphs: letter = self.get_glyph_element(glyph) - label = "GlyphLayer-%s" % letter.get(INKSCAPE_LABEL, ' ').split('.')[0][-1] + label = unescape(letter.get(INKSCAPE_LABEL, ' ')).split('.')[0][-1] + label = f"GlyphLayer-{ label }" group = inkex.Group(attrib={ INKSCAPE_LABEL: label, INKSCAPE_GROUPMODE: "layer", @@ -79,8 +81,11 @@ class LettersToFont(InkstitchExtension): stitch_plan = generate_stitch_plan(str(glyph), self.options.import_commands) # we received a stitch plan wrapped in an svg document, we only need the stitch_plan group # this group carries the name of the file, so we can search for it. - stitch_plan = stitch_plan.xpath('.//*[@inkscape:label="%s"]' % os.path.basename(glyph), namespaces=inkex.NSS)[0] + label = os.path.basename(glyph) + search_string = f'.//*[@inkscape:label="{ escape(label) }"]' + stitch_plan = stitch_plan.xpath(search_string, namespaces=inkex.NSS)[0] stitch_plan.attrib.pop(INKSCAPE_GROUPMODE) + stitch_plan.label = label return stitch_plan def insert_baseline(self): diff --git a/lib/extensions/troubleshoot.py b/lib/extensions/troubleshoot.py index fdc7fa9e..01629069 100644 --- a/lib/extensions/troubleshoot.py +++ b/lib/extensions/troubleshoot.py @@ -100,16 +100,18 @@ class Troubleshoot(InkstitchExtension): svg = self.document.getroot() layer = svg.find(".//*[@id='__validation_layer__']") - if layer is None: - layer = inkex.Group(attrib={ - 'id': '__validation_layer__', - INKSCAPE_LABEL: _('Troubleshoot'), - INKSCAPE_GROUPMODE: 'layer', - }) - svg.append(layer) - else: - # Clear out everything from the last run - del layer[:] + if layer is not None: + # Remove the old layer - they may have used tranfsorms + # or moved it into an other group (which could lead to more transforms) + # We don't want to deal with it. + layer.getparent().remove(layer) + + layer = inkex.Group(attrib={ + 'id': '__validation_layer__', + INKSCAPE_LABEL: _('Troubleshoot'), + INKSCAPE_GROUPMODE: 'layer', + }) + svg.append(layer) add_layer_commands(layer, ["ignore_layer"]) diff --git a/lib/gui/lettering.py b/lib/gui/lettering.py new file mode 100644 index 00000000..0d006f9e --- /dev/null +++ b/lib/gui/lettering.py @@ -0,0 +1,493 @@ +# Authors: see git history +# +# Copyright (c) 2010 Authors +# Licensed under the GNU GPL version 3.0 or later. See the file LICENSE for details. + +import json +import os +from base64 import b64decode + +import appdirs +import inkex +import wx +import wx.adv +import wx.lib.agw.floatspin as fs + +from ..elements import nodes_to_elements +from ..extensions.lettering_custom_font_dir import get_custom_font_dir +from ..i18n import _ +from ..lettering import Font, FontError +from ..lettering.categories import FONT_CATEGORIES, FontCategory +from ..stitch_plan import stitch_groups_to_stitch_plan +from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_LETTERING, SVG_PATH_TAG +from ..utils import DotDict, cache, get_bundled_dir +from ..utils.threading import ExitThread, check_stop_flag +from . import PresetsPanel, PreviewRenderer, info_dialog + + +class LetteringPanel(wx.Panel): + DEFAULT_FONT = "small_font" + + def __init__(self, parent, simulator, group, on_cancel=None, metadata=None): + self.parent = parent + self.simulator = simulator + self.group = group + self.cancel_hook = on_cancel + self.metadata = metadata or dict() + + super().__init__(parent, wx.ID_ANY) + + self.SetWindowStyle(wx.FRAME_FLOAT_ON_PARENT | wx.DEFAULT_FRAME_STYLE) + + self.preview_renderer = PreviewRenderer(self.render_stitch_plan, self.on_stitch_plan_rendered) + self.presets_panel = PresetsPanel(self) + + # font + self.font_selector_box = wx.StaticBox(self, wx.ID_ANY, label=_("Font")) + + self.font_chooser = wx.adv.BitmapComboBox(self, wx.ID_ANY, style=wx.CB_READONLY | wx.CB_SORT) + self.font_chooser.Bind(wx.EVT_COMBOBOX, self.on_font_changed) + + self.font_size_filter = fs.FloatSpin(self, min_val=0, max_val=None, increment=1, value="0") + self.font_size_filter.SetFormat("%f") + self.font_size_filter.SetDigits(2) + self.font_size_filter.Bind(fs.EVT_FLOATSPIN, self.on_filter_changed) + self.font_size_filter.SetToolTip(_("Font size filter (mm). 0 for all sizes.")) + + self.font_glyph_filter = wx.CheckBox(self, label=_("Glyphs")) + self.font_glyph_filter.Bind(wx.EVT_CHECKBOX, self.on_filter_changed) + self.font_glyph_filter.SetToolTip(_("Filter fonts by available glyphs.")) + + self.font_category_filter = wx.ComboBox(self, wx.ID_ANY, choices=[], style=wx.CB_DROPDOWN | wx.CB_READONLY) + unfiltered = FontCategory('unfiltered', "---") + self.font_category_filter.Append(unfiltered.name, unfiltered) + for category in FONT_CATEGORIES: + self.font_category_filter.Append(category.name, category) + self.font_category_filter.SetToolTip(_("Filter fonts by category.")) + self.font_category_filter.SetSelection(0) + self.font_category_filter.Bind(wx.EVT_COMBOBOX, self.on_filter_changed) + + # font details + self.font_description = wx.StaticText(self, wx.ID_ANY) + self.Bind(wx.EVT_SIZE, self.resize) + + # font filter + self.filter_box = wx.StaticBox(self, wx.ID_ANY, label=_("Font Filter")) + + # options + self.options_box = wx.StaticBox(self, wx.ID_ANY, label=_("Options")) + + self.scale_spinner = wx.SpinCtrl(self, wx.ID_ANY, min=0, max=1000, initial=100) + self.scale_spinner.Bind(wx.EVT_SPINCTRL, lambda event: self.on_change("scale", event)) + + self.back_and_forth_checkbox = wx.CheckBox(self, label=_("Stitch lines of text back and forth")) + self.back_and_forth_checkbox.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("back_and_forth", event)) + + self.trim_option_choice = wx.Choice(self, choices=[_("Never"), _("after each line"), _("after each word"), _("after each letter")], + name=_("Add trim command")) + self.trim_option_choice.Bind(wx.EVT_CHOICE, lambda event: self.on_trim_option_change(event)) + + self.use_trim_symbols = wx.CheckBox(self, label=_("Use command symbols")) + self.use_trim_symbols.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("use_trim_symbols", event)) + self.use_trim_symbols.SetToolTip(_('Uses command symbols if enabled. When disabled inserts trim commands as params.')) + + # text editor + self.text_input_box = wx.StaticBox(self, wx.ID_ANY, label=_("Text")) + + self.text_editor = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_DONTWRAP) + self.text_editor.Bind(wx.EVT_TEXT, lambda event: self.on_change("text", event)) + + self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel")) + self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel) + self.Bind(wx.EVT_CLOSE, self.cancel) + + self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + + # set font list + self.update_font_list() + self.set_font_list() + + self.__do_layout() + + self.load_settings() + self.apply_settings() + + def load_settings(self): + """Load the settings saved into the SVG group 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.group.attrib: + try: + self.settings.update(json.loads(self.group.get(INKSTITCH_LETTERING))) + except json.decoder.JSONDecodeError: + # legacy base64 encoded (changed in v2.0) + try: + self.settings.update(json.loads(b64decode(self.group.get(INKSTITCH_LETTERING)))) + except (TypeError, ValueError): + pass + except (TypeError, ValueError): + pass + + def apply_settings(self): + """Make the settings in self.settings visible in the UI.""" + self.back_and_forth_checkbox.SetValue(bool(self.settings.back_and_forth)) + self.trim_option_choice.SetSelection(self.settings.trim_option) + self.use_trim_symbols.SetValue(bool(self.settings.use_trim_symbols)) + self.text_editor.SetValue(self.settings.text) + self.scale_spinner.SetValue(self.settings.scale) + self.set_initial_font(self.settings.font) + + def save_settings(self): + """Save the settings into the SVG group element.""" + self.group.set(INKSTITCH_LETTERING, json.dumps(self.settings)) + + @property + @cache + def font_list(self): + fonts = [] + font_paths = { + get_bundled_dir("fonts"), + os.path.expanduser("~/.inkstitch/fonts"), + os.path.join(appdirs.user_config_dir('inkstitch'), 'fonts'), + get_custom_font_dir() + } + + 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.marked_custom_font_name == "" or font.marked_custom_font_id == "": + continue + fonts.append(font) + return fonts + + def update_font_list(self): + self.fonts = {} + self.fonts_by_id = {} + + # font size filter value + filter_size = self.font_size_filter.GetValue() + filter_glyph = self.font_glyph_filter.GetValue() + filter_category = self.font_category_filter.GetSelection() - 1 + + # glyph filter string without spaces + glyphs = [*self.text_editor.GetValue().replace(" ", "").replace("\n", "")] + + for font in self.font_list: + if filter_glyph and glyphs and not set(glyphs).issubset(font.available_glyphs): + continue + + if filter_category != -1: + category = FONT_CATEGORIES[filter_category].id + if category not in font.keywords: + continue + + if filter_size != 0 and (filter_size < font.size * font.min_scale or filter_size > font.size * font.max_scale): + continue + + self.fonts[font.marked_custom_font_name] = font + self.fonts_by_id[font.marked_custom_font_id] = font + + def set_font_list(self): + self.font_chooser.Clear() + for font in self.fonts.values(): + image = font.preview_image + + if image is not None: + image = wx.Image(image) + """ + # I would like to do this but Windows requires all images to be the exact same size + # It might work with an updated wxpython version - so let's keep it here + + # Scale to max 20 height + img_height = 20 + width, height = image.GetSize() + scale_factor = height / img_height + width = int(width / scale_factor) + image.Rescale(width, img_height, quality=wx.IMAGE_QUALITY_HIGH) + """ + # Windows requires all images to have the exact same size + image.Rescale(300, 20, quality=wx.IMAGE_QUALITY_HIGH) + self.font_chooser.Append(font.marked_custom_font_name, wx.Bitmap(image)) + else: + self.font_chooser.Append(font.marked_custom_font_name) + + def get_font_descriptions(self): + return {font.name: font.description for font in self.fonts.values()} + + def set_initial_font(self, font_id): + if font_id: + if font_id not in self.fonts_by_id: + message = '''This text was created using the font "%s", but Ink/Stitch can't find that font. ''' \ + '''A default font will be substituted.''' + info_dialog(self, _(message) % font_id) + try: + font = self.fonts_by_id[font_id].marked_custom_font_name + except KeyError: + font = self.default_font.marked_custom_font_name + self.font_chooser.SetValue(font) + + self.on_font_changed() + + @property + def default_font(self): + try: + return self.fonts_by_id[self.DEFAULT_FONT] + except KeyError: + return list(self.fonts.values())[0] + + def on_change(self, attribute, event): + self.settings[attribute] = event.GetEventObject().GetValue() + if attribute == "text" and self.font_glyph_filter.GetValue() is True: + self.on_filter_changed() + self.preview_renderer.update() + + def on_trim_option_change(self, event=None): + self.settings.trim_option = self.trim_option_choice.GetCurrentSelection() + self.preview_renderer.update() + + def on_font_changed(self, event=None): + font = self.fonts.get(self.font_chooser.GetValue(), self.default_font) + self.settings.font = font.marked_custom_font_id + + filter_size = self.font_size_filter.GetValue() + self.scale_spinner.SetRange(int(font.min_scale * 100), int(font.max_scale * 100)) + if filter_size != 0: + self.scale_spinner.SetValue(int(filter_size / font.size * 100)) + self.settings['scale'] = self.scale_spinner.GetValue() + + font_variants = [] + try: + font_variants = font.has_variants() + except FontError: + pass + + # Update font description + color = wx.NullColour + description = font.description + if len(font_variants) == 0: + color = (255, 0, 0) + description = _('This font has no available font variant. Please update or remove the font.') + self.font_description.SetLabel(description) + self.font_description.SetForegroundColour(color) + self.font_description.Wrap(self.GetSize().width - 35) + + if font.reversible: + self.back_and_forth_checkbox.Enable() + self.back_and_forth_checkbox.SetValue(bool(self.settings.back_and_forth)) + else: + # The creator of the font banned the possibility of writing in reverse with json file: "reversible": false + self.back_and_forth_checkbox.Disable() + self.back_and_forth_checkbox.SetValue(False) + + self.update_preview() + self.Layout() + + def on_filter_changed(self, event=None): + self.update_font_list() + + if not self.fonts: + # No fonts for filtered size + self.font_chooser.Clear() + self.filter_box.SetForegroundColour("red") + return + else: + self.filter_box.SetForegroundColour(wx.NullColour) + + filter_size = self.font_size_filter.GetValue() + previous_font = self.font_chooser.GetValue() + self.set_font_list() + font = self.fonts.get(previous_font, self.default_font) + self.font_chooser.SetValue(font.marked_custom_font_name) + if font.marked_custom_font_name != previous_font: + self.on_font_changed() + elif filter_size != 0: + self.scale_spinner.SetValue(int(filter_size / font.size * 100)) + self.settings['scale'] = self.scale_spinner.GetValue() + + def resize(self, event=None): + description = self.font_description.GetLabel().replace("\n", " ") + self.font_description.SetLabel(description) + self.font_description.Wrap(self.GetSize().width - 35) + self.Layout() + + def update_preview(self, event=None): + self.preview_renderer.update() + + def update_lettering(self, raise_error=False): + # return if there is no font in the font list (possibly due to a font size filter) + if not self.font_chooser.GetValue(): + return + + del self.group[:] + + if self.settings.scale == 100: + destination_group = self.group + else: + destination_group = inkex.Group(attrib={ + # L10N The user has chosen to scale the text by some percentage + # (50%, 200%, etc). If you need to use the percentage symbol, + # make sure to double it (%%). + INKSCAPE_LABEL: _("Text scale %s%%") % self.settings.scale + }) + self.group.append(destination_group) + + font = self.fonts.get(self.font_chooser.GetValue(), self.default_font) + try: + font.render_text(self.settings.text, destination_group, back_and_forth=self.settings.back_and_forth, + trim_option=self.settings.trim_option, use_trim_symbols=self.settings.use_trim_symbols) + + except FontError as e: + if raise_error: + inkex.errormsg(_("Error: Text cannot be applied to the document.\n%s") % e) + return + else: + pass + + # destination_group isn't always the text scaling group (but also the parent group) + # the text scaling group label is dependend on the user language, so it would break in international file exchange if we used it + # scaling (correction transform) on the parent group is already applied, so let's use that for recognition + if self.settings.scale != 100 and not destination_group.get('transform', None): + destination_group.attrib['transform'] = 'scale(%s)' % (self.settings.scale / 100.0) + + def render_stitch_plan(self): + stitch_groups = [] + + try: + self.update_lettering() + elements = nodes_to_elements(self.group.iterdescendants(SVG_PATH_TAG)) + + for element in elements: + check_stop_flag() + + stitch_groups.extend(element.embroider(None)) + + if stitch_groups: + return stitch_groups_to_stitch_plan( + stitch_groups, + collapse_len=self.metadata['collapse_len_mm'], + min_stitch_len=self.metadata['min_stitch_len_mm'] + ) + except SystemExit: + raise + except ExitThread: + raise + except Exception: + raise + # Ignore errors. This can be things like incorrect paths for + # satins or division by zero caused by incorrect param values. + pass + + def on_stitch_plan_rendered(self, stitch_plan): + self.simulator.stop() + self.simulator.load(stitch_plan) + self.simulator.go() + + def get_preset_data(self): + # called by self.presets_panel + settings = dict(self.settings) + del settings["text"] + return settings + + def apply_preset_data(self, preset_data): + settings = DotDict(preset_data) + settings["text"] = self.settings.text + self.settings = settings + self.apply_settings() + + def get_preset_suite_name(self): + # called by self.presets_panel + return "lettering" + + def apply(self, event): + self.update_lettering(True) + self.save_settings() + self.close() + + def close(self): + self.GetTopLevelParent().Close() + + def cancel(self, event): + if self.cancel_hook: + self.cancel_hook() + + self.close() + + def __do_layout(self): + outer_sizer = wx.BoxSizer(wx.VERTICAL) + + # font selection + font_selector_sizer = wx.StaticBoxSizer(self.font_selector_box, wx.VERTICAL) + font_selector_box = wx.BoxSizer(wx.HORIZONTAL) + font_selector_box.Add(self.font_chooser, 4, wx.EXPAND | wx.TOP | wx.BOTTOM | wx.RIGHT, 10) + font_selector_sizer.Add(font_selector_box, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + font_selector_sizer.Add(self.font_description, 1, wx.EXPAND | wx.ALL, 10) + outer_sizer.Add(font_selector_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + # filter fon list + filter_sizer = wx.StaticBoxSizer(self.filter_box, wx.HORIZONTAL) + filter_size_label = wx.StaticText(self, wx.ID_ANY, _("Size")) + filter_sizer.Add(filter_size_label, 0, wx.LEFT | wx.TOP | wx.BOTTOM, 10) + filter_sizer.AddSpacer(5) + filter_sizer.Add(self.font_size_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) + filter_sizer.AddSpacer(5) + filter_sizer.Add(self.font_glyph_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) + filter_sizer.Add(self.font_category_filter, 1, wx.RIGHT | wx.TOP | wx.BOTTOM, 10) + outer_sizer.Add(filter_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + # options + left_option_sizer = wx.BoxSizer(wx.VERTICAL) + left_option_sizer.Add(self.back_and_forth_checkbox, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 5) + + trim_option_sizer = wx.BoxSizer(wx.HORIZONTAL) + trim_option_sizer.Add(wx.StaticText(self, wx.ID_ANY, _("Add trims")), 0, wx.LEFT | wx.ALIGN_TOP, 5) + trim_option_sizer.Add(self.trim_option_choice, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 5) + trim_option_sizer.Add(self.use_trim_symbols, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 5) + left_option_sizer.Add(trim_option_sizer, 0, wx.ALIGN_LEFT, 5) + + font_scale_sizer = wx.BoxSizer(wx.HORIZONTAL) + font_scale_sizer.Add(wx.StaticText(self, wx.ID_ANY, _("Scale")), 0, wx.LEFT | wx.ALIGN_CENTRE_VERTICAL, 0) + font_scale_sizer.Add(self.scale_spinner, 0, wx.LEFT, 10) + font_scale_sizer.Add(wx.StaticText(self, wx.ID_ANY, "%"), 0, wx.LEFT | wx.ALIGN_CENTRE_VERTICAL, 3) + + options_sizer = wx.StaticBoxSizer(self.options_box, wx.HORIZONTAL) + options_sizer.Add(left_option_sizer, 1, wx.EXPAND, 10) + options_sizer.Add(font_scale_sizer, 0, wx.RIGHT, 10) + + outer_sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + # text input + text_input_sizer = wx.StaticBoxSizer(self.text_input_box, wx.VERTICAL) + text_input_sizer.Add(self.text_editor, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) + outer_sizer.Add(text_input_sizer, 2, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10) + + # presets + outer_sizer.Add(self.presets_panel, 0, wx.EXPAND | wx.EXPAND | wx.ALL, 10) + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + buttons_sizer.Add(self.cancel_button, 0, wx.RIGHT, 10) + buttons_sizer.Add(self.apply_button, 0, wx.RIGHT | wx.BOTTOM, 10) + outer_sizer.Add(buttons_sizer, 0, wx.ALIGN_RIGHT, 10) + + self.SetSizerAndFit(outer_sizer) + self.Layout() + + # SetSizerAndFit determined the minimum size that fits all the controls + # and set the window's minimum size so that the user can't make it + # smaller. It also set the window to that size. We'd like to give the + # user a bit more room for text, so we'll add some height. + size = self.GetSize() + size.height = size.height + 200 + self.SetSize(size) diff --git a/lib/stitch_plan/generate_stitch_plan.py b/lib/stitch_plan/generate_stitch_plan.py index 53458815..cdd66d9e 100644 --- a/lib/stitch_plan/generate_stitch_plan.py +++ b/lib/stitch_plan/generate_stitch_plan.py @@ -5,6 +5,7 @@ import os import sys +from html import escape import inkex @@ -59,7 +60,7 @@ def generate_stitch_plan(embroidery_file, import_commands="symbols"): # noqa: C # rename the Stitch Plan layer so that it doesn't get overwritten by Embroider layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - layer.set(INKSCAPE_LABEL, os.path.basename(embroidery_file)) + layer.set(INKSCAPE_LABEL, escape(os.path.basename(embroidery_file))) layer.attrib.pop('id') # Shift the design so that its origin is at the center of the canvas diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 24ce6610..40b74d23 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -195,7 +195,7 @@ def insert_node(graph, shape, point): edges = [] for start, end, key, data in graph.edges(keys=True, data=True): - if key == "outline": + if key == "outline" and data['outline'] == outline: edges.append(((start, end), data)) edge, data = min(edges, key=lambda edge_data: shgeo.LineString(edge_data[0]).distance(projected_point)) diff --git a/lib/stitches/auto_run.py b/lib/stitches/auto_run.py index d833a885..b8ab84c7 100644 --- a/lib/stitches/auto_run.py +++ b/lib/stitches/auto_run.py @@ -5,23 +5,22 @@ from collections import defaultdict +import inkex import networkx as nx from shapely.geometry import LineString, MultiLineString, MultiPoint, Point from shapely.ops import nearest_points, substring, unary_union -import inkex - from ..commands import add_commands from ..elements import Stroke from ..i18n import _ from ..svg import PIXELS_PER_MM, generate_unique_id from ..svg.tags import INKSCAPE_LABEL, INKSTITCH_ATTRIBS +from ..utils.threading import check_stop_flag from .utils.autoroute import (add_elements_to_group, add_jumps, create_new_group, find_path, get_starting_and_ending_nodes, preserve_original_groups, remove_original_elements) -from ..utils.threading import check_stop_flag class LineSegments: diff --git a/lib/stitches/contour_fill.py b/lib/stitches/contour_fill.py index 993fa104..e19e1aad 100644 --- a/lib/stitches/contour_fill.py +++ b/lib/stitches/contour_fill.py @@ -4,6 +4,7 @@ from itertools import chain import networkx as nx import numpy as np import trimesh +from shapely import offset_curve from shapely.geometry import (GeometryCollection, LineString, MultiPolygon, Point, Polygon) from shapely.geometry.polygon import orient @@ -14,7 +15,7 @@ from ..stitch_plan import Stitch from ..utils import DotDict from ..utils.clamp_path import clamp_path_to_polygon from ..utils.geometry import (cut, ensure_geometry_collection, - ensure_multi_polygon, reverse_line_string, + ensure_multi_line_string, reverse_line_string, roll_linear_ring) from ..utils.smoothing import smooth_path from ..utils.threading import check_stop_flag @@ -49,10 +50,10 @@ nearest_neighbor_tuple = namedtuple( def _offset_linear_ring(ring, offset, resolution, join_style, mitre_limit): - result = Polygon(ring).buffer(-offset, resolution, cap_style=2, join_style=join_style, mitre_limit=mitre_limit, single_sided=True) - result = ensure_multi_polygon(result) - rings = GeometryCollection([poly.exterior for poly in result.geoms]) - rings = rings.simplify(0.01, False) + ring = Polygon(ring) + result = offset_curve(ring, -offset, resolution, join_style=join_style, mitre_limit=mitre_limit) + result = ensure_multi_line_string(result) + rings = result.simplify(0.01, False) return _take_only_valid_linear_rings(rings) @@ -200,11 +201,11 @@ def _match_polygons_and_holes(outer, inners): def _convert_polygon_to_nodes(tree, polygon, parent_polygon, child_holes): - polygon = orient(polygon, -1) - if polygon.area < 0.1: return None, None + polygon = orient(polygon, -1) + valid_rings = _take_only_valid_linear_rings(polygon.exterior) try: diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 2c5cdffc..9e9ff790 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -149,14 +149,13 @@ def intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing=Non res = grating_line.intersection(shape) - if (isinstance(res, shapely.geometry.MultiLineString) or isinstance(res, shapely.geometry.GeometryCollection)): - runs = [line_string.coords for line_string in res.geoms if isinstance(line_string, shapely.geometry.LineString)] + if res.geom_type in ["MultiLineString", "GeometryCollection"]: + runs = [line_string.coords for line_string in res.geoms if line_string.geom_type == "LineString"] + elif res.geom_type in ["Point", "MultiPoint"] or res.is_empty: + # ignore if we intersected at a single point or no points + runs = [] else: - if res.is_empty or len(res.coords) == 1: - # ignore if we intersected at a single point or no points - runs = [] - else: - runs = [res.coords] + runs = [res.coords] if runs: runs.sort(key=lambda seg: (InkstitchPoint(*seg[0]) - upper_left).length()) diff --git a/lib/stitches/utils/autoroute.py b/lib/stitches/utils/autoroute.py index 3ada4299..ed07c9a4 100644 --- a/lib/stitches/utils/autoroute.py +++ b/lib/stitches/utils/autoroute.py @@ -5,12 +5,12 @@ from itertools import combinations +import inkex import networkx as nx -from shapely.geometry import Point, MultiPoint +from shapely.geometry import MultiPoint, Point from shapely.ops import nearest_points -import inkex - +from ...elements import SatinColumn from ...svg import get_correction_transform from ...svg.tags import INKSCAPE_LABEL from ...utils.threading import check_stop_flag @@ -83,40 +83,71 @@ def add_jumps(graph, elements, preserve_order): Jump stitches are added to ensure that all elements can be reached. Only the minimal number and length of jumps necessary will be added. """ - if preserve_order: - # For each sequential pair of elements, find the shortest possible jump - # stitch between them and add it. The directions of these new edges - # will enforce stitching the elements in order. - - for element1, element2 in zip(elements[:-1], elements[1:]): - check_stop_flag() - - potential_edges = [] - - nodes1 = get_nodes_on_element(graph, element1) - nodes2 = get_nodes_on_element(graph, element2) + _add_ordered_jumps(graph, elements) + else: + _add_unordered_jumps(graph, elements) + return graph - for node1 in nodes1: - for node2 in nodes2: - point1 = graph.nodes[node1]['point'] - point2 = graph.nodes[node2]['point'] - potential_edges.append((point1, point2)) - if potential_edges: - edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1])) - graph.add_edge(str(edge[0]), str(edge[1]), jump=True) - else: - # networkx makes this super-easy! k_edge_agumentation tells us what edges - # we need to add to ensure that the graph is fully connected. We give it a - # set of possible edges that it can consider adding (avail). Each edge has - # a weight, which we'll set as the length of the jump stitch. The - # algorithm will minimize the total length of jump stitches added. - for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): - check_stop_flag() - graph.add_edge(*jump, jump=True) +def _add_ordered_jumps(graph, elements): + # For each sequential pair of elements, find the shortest possible jump + # stitch between them and add it. The directions of these new edges + # will enforce stitching the elements in order. + for element1, element2 in zip(elements[:-1], elements[1:]): + check_stop_flag() + _insert_smallest_jump(graph, element1, element2) - return graph + # add jumps between subpath too, we do not care about directions here + for element in elements: + if isinstance(element, SatinColumn): + # don't try this for satin columns + continue + check_stop_flag() + geoms = list(element.as_multi_line_string().geoms) + i = 0 + for line1 in geoms: + for line2 in geoms[i+1:]: + if line1.distance(line2) == 0: + continue + node1, node2 = nearest_points(line1, line2) + _insert_jump(graph, node1, node2) + i += 1 + + +def _insert_smallest_jump(graph, element1, element2): + potential_edges = [] + + nodes1 = get_nodes_on_element(graph, element1) + nodes2 = get_nodes_on_element(graph, element2) + + for node1 in nodes1: + for node2 in nodes2: + point1 = graph.nodes[node1]['point'] + point2 = graph.nodes[node2]['point'] + potential_edges.append((point1, point2)) + + if potential_edges: + edge = min(potential_edges, key=lambda p1_p2: p1_p2[0].distance(p1_p2[1])) + graph.add_edge(str(edge[0]), str(edge[1]), jump=True) + + +def _insert_jump(graph, node1, node2): + graph.add_node(str(node1), point=node1) + graph.add_node(str(node2), point=node2) + graph.add_edge(str(node1), str(node2), jump=True) + graph.add_edge(str(node2), str(node1), jump=True) + + +def _add_unordered_jumps(graph, elements): + # networkx makes this super-easy! k_edge_agumentation tells us what edges + # we need to add to ensure that the graph is fully connected. We give it a + # set of possible edges that it can consider adding (avail). Each edge has + # a weight, which we'll set as the length of the jump stitch. The + # algorithm will minimize the total length of jump stitches added. + for jump in nx.k_edge_augmentation(graph, 1, avail=list(possible_jumps(graph))): + check_stop_flag() + graph.add_edge(*jump, jump=True) def possible_jumps(graph): diff --git a/lib/utils/geometry.py b/lib/utils/geometry.py index 7434ae27..6ef0d439 100644 --- a/lib/utils/geometry.py +++ b/lib/utils/geometry.py @@ -7,7 +7,7 @@ import math import typing import numpy -from shapely.geometry import LineString, LinearRing, MultiLineString, Polygon, MultiPolygon, MultiPoint, GeometryCollection +from shapely.geometry import LineString, LinearRing, MultiLineString, MultiPolygon, MultiPoint, GeometryCollection from shapely.geometry import Point as ShapelyPoint @@ -103,12 +103,19 @@ def reverse_line_string(line_string): def ensure_multi_line_string(thing): - """Given either a MultiLineString or a single LineString, return a MultiLineString""" - - if isinstance(thing, LineString): - return MultiLineString([thing]) - else: + """Given either a MultiLineString, a single LineString or GeometryCollection, return a MultiLineString""" + if thing.is_empty: return thing + if thing.geom_type == "LineString": + return MultiLineString([thing]) + if thing.geom_type == "GeometryCollection": + multilinestring = [] + for line in thing.geoms: + if line.geom_type == "LineString": + multilinestring.append(line) + if multilinestring: + return MultiLineString(multilinestring) + return thing def ensure_geometry_collection(thing): @@ -124,11 +131,18 @@ def ensure_geometry_collection(thing): def ensure_multi_polygon(thing): """Given either a MultiPolygon or a single Polygon, return a MultiPolygon""" - - if isinstance(thing, Polygon): - return MultiPolygon([thing]) - else: + if thing.is_empty: return thing + if thing.geom_type == "Polygon": + return MultiPolygon([thing]) + if thing.geom_type == "GeometryCollection": + multipolygon = [] + for polygon in thing.geoms: + if polygon.geom_type == "Polygon": + multipolygon.append(polygon) + if multipolygon: + return MultiPolygon(multipolygon) + return thing def cut_path(points, length): |
