From 1b31806423c8fec4040fed6d1009db016860b763 Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 1 May 2018 20:37:51 -0400 Subject: rename inkstitch/ to lib/ You can't have a module and a package named the same thing. PyInstaller wants to import the main script as if it were a module, and this doesn't work unless there's no directory of the same name with a __init__.py in it. --- lib/extensions/__init__.py | 6 + lib/extensions/base.py | 222 +++++++++++++ lib/extensions/embroider.py | 86 +++++ lib/extensions/input.py | 66 ++++ lib/extensions/palettes.py | 111 +++++++ lib/extensions/params.py | 756 ++++++++++++++++++++++++++++++++++++++++++++ lib/extensions/print_pdf.py | 391 +++++++++++++++++++++++ lib/extensions/simulate.py | 27 ++ 8 files changed, 1665 insertions(+) create mode 100644 lib/extensions/__init__.py create mode 100644 lib/extensions/base.py create mode 100644 lib/extensions/embroider.py create mode 100644 lib/extensions/input.py create mode 100644 lib/extensions/palettes.py create mode 100644 lib/extensions/params.py create mode 100644 lib/extensions/print_pdf.py create mode 100644 lib/extensions/simulate.py (limited to 'lib/extensions') diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py new file mode 100644 index 00000000..ebdd2fc9 --- /dev/null +++ b/lib/extensions/__init__.py @@ -0,0 +1,6 @@ +from embroider import Embroider +from palettes import Palettes +from params import Params +from print_pdf import Print +from simulate import Simulate +from input import Input diff --git a/lib/extensions/base.py b/lib/extensions/base.py new file mode 100644 index 00000000..91e050eb --- /dev/null +++ b/lib/extensions/base.py @@ -0,0 +1,222 @@ +import inkex +import re +import json +from copy import deepcopy +from collections import MutableMapping +from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement +from .. import SVG_POLYLINE_TAG, SVG_GROUP_TAG, SVG_DEFS_TAG, INKSCAPE_GROUPMODE, EMBROIDERABLE_TAGS, PIXELS_PER_MM +from ..utils import cache + + +SVG_METADATA_TAG = inkex.addNS("metadata", "svg") + + +def strip_namespace(tag): + """Remove xml namespace from a tag name. + + >>> {http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd}namedview + <<< namedview + """ + + match = re.match('^\{[^}]+\}(.+)$', tag) + + if match: + return match.group(1) + else: + return tag + + +class InkStitchMetadata(MutableMapping): + """Helper class to get and set inkstitch-specific metadata attributes. + + Operates on a document and acts like a dict. Setting an item adds or + updates a metadata element in the document. Getting an item retrieves + a metadata element's text contents or None if an element by that name + doesn't exist. + """ + + def __init__(self, document): + self.document = document + self.metadata = self._get_or_create_metadata() + + def _get_or_create_metadata(self): + metadata = self.document.find(SVG_METADATA_TAG) + + if metadata is None: + metadata = inkex.etree.SubElement(self.document.getroot(), SVG_METADATA_TAG) + + # move it so that it goes right after the first element, sodipodi:namedview + self.document.getroot().remove(metadata) + self.document.getroot().insert(1, metadata) + + return metadata + + # Because this class inherints from MutableMapping, all we have to do is + # implement these five methods and we get a full dict-like interface. + + def __setitem__(self, name, value): + item = self._find_item(name) + + if value: + item.text = json.dumps(value) + else: + item.getparent().remove(item) + + def _find_item(self, name): + tag = inkex.addNS(name, "inkstitch") + item = self.metadata.find(tag) + if item is None: + item = inkex.etree.SubElement(self.metadata, tag) + + return item + + def __getitem__(self, name): + item = self._find_item(name) + + try: + return json.loads(item.text) + except (ValueError, TypeError): + return None + + def __delitem__(self, name): + item = self._find_item(name) + + if item: + self.metadata.remove(item) + + def __iter__(self): + for child in self.metadata: + if child.prefix == "inkstitch": + yield strip_namespace(child.tag) + + def __len__(self): + i = 0 + for i, item in enumerate(self): + pass + + return i + 1 + + +class InkstitchExtension(inkex.Effect): + """Base class for Inkstitch extensions. Not intended for direct use.""" + + def hide_all_layers(self): + for g in self.document.getroot().findall(SVG_GROUP_TAG): + if g.get(INKSCAPE_GROUPMODE) == "layer": + g.set("style", "display:none") + + def no_elements_error(self): + if self.selected: + inkex.errormsg(_("No embroiderable paths selected.")) + else: + inkex.errormsg(_("No embroiderable paths found in document.")) + inkex.errormsg(_("Tip: use Path -> Object to Path to convert non-paths before embroidering.")) + + def descendants(self, node): + nodes = [] + element = EmbroideryElement(node) + + if element.has_style('display') and element.get_style('display') is None: + return [] + + if node.tag == SVG_DEFS_TAG: + return [] + + for child in node: + nodes.extend(self.descendants(child)) + + if node.tag in EMBROIDERABLE_TAGS: + nodes.append(node) + + return nodes + + def get_nodes(self): + """Get all XML nodes, or just those selected + + effect is an instance of a subclass of inkex.Effect. + """ + + if self.selected: + nodes = [] + for node in self.document.getroot().iter(): + if node.get("id") in self.selected: + nodes.extend(self.descendants(node)) + else: + nodes = self.descendants(self.document.getroot()) + + return nodes + + def detect_classes(self, node): + if node.tag == SVG_POLYLINE_TAG: + return [Polyline] + else: + element = EmbroideryElement(node) + + if element.get_boolean_param("satin_column"): + return [SatinColumn] + else: + classes = [] + + if element.get_style("fill"): + if element.get_boolean_param("auto_fill", True): + classes.append(AutoFill) + else: + classes.append(Fill) + + if element.get_style("stroke"): + classes.append(Stroke) + + if element.get_boolean_param("stroke_first", False): + classes.reverse() + + return classes + + + def get_elements(self): + self.elements = [] + for node in self.get_nodes(): + classes = self.detect_classes(node) + self.elements.extend(cls(node) for cls in classes) + + if self.elements: + return True + else: + self.no_elements_error() + return False + + def elements_to_patches(self, elements): + patches = [] + for element in elements: + if patches: + last_patch = patches[-1] + else: + last_patch = None + + patches.extend(element.embroider(last_patch)) + + return patches + + def get_inkstitch_metadata(self): + return InkStitchMetadata(self.document) + + def parse(self): + """Override inkex.Effect to add Ink/Stitch xml namespace""" + + # SVG parsers don't actually look for anything at this URL. They just + # care that it's unique. That defines a "namespace" of element and + # attribute names to disambiguate conflicts with element and + # attribute names other XML namespaces. + # + # Updating inkex.NSS here allows us to pass 'inkstitch' into + # inkex.addNS(). + inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace' + + # call the superclass's method first + inkex.Effect.parse(self) + + # This is the only way I could find to add a namespace to an existing + # element tree at the top without getting ugly prefixes like "ns0". + inkex.etree.cleanup_namespaces(self.document, + top_nsmap=inkex.NSS, + keep_ns_prefixes=inkex.NSS.keys()) + self.original_document = deepcopy(self.document) diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py new file mode 100644 index 00000000..564e96ca --- /dev/null +++ b/lib/extensions/embroider.py @@ -0,0 +1,86 @@ +import sys +import traceback +import os + +import inkex +from .. import _, PIXELS_PER_MM, write_embroidery_file +from .base import InkstitchExtension +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan + + +class Embroider(InkstitchExtension): + def __init__(self, *args, **kwargs): + InkstitchExtension.__init__(self) + self.OptionParser.add_option("-c", "--collapse_len_mm", + action="store", type="float", + dest="collapse_length_mm", default=3.0, + help="max collapse length (mm)") + self.OptionParser.add_option("--hide_layers", + action="store", type="choice", + choices=["true", "false"], + dest="hide_layers", default="true", + help="Hide all other layers when the embroidery layer is generated") + self.OptionParser.add_option("-O", "--output_format", + action="store", type="string", + dest="output_format", default="csv", + help="Output file extenstion (default: csv)") + self.OptionParser.add_option("-P", "--path", + action="store", type="string", + dest="path", default=".", + help="Directory in which to store output file") + self.OptionParser.add_option("-F", "--output-file", + action="store", type="string", + dest="output_file", + help="Output filename.") + self.OptionParser.add_option("-b", "--max-backups", + action="store", type="int", + dest="max_backups", default=5, + help="Max number of backups of output files to keep.") + self.OptionParser.usage += _("\n\nSeeing a 'no such option' message? Please restart Inkscape to fix.") + + def get_output_path(self): + if self.options.output_file: + output_path = os.path.join(self.options.path, self.options.output_file) + else: + svg_filename = self.document.getroot().get(inkex.addNS('docname', 'sodipodi'), "embroidery.svg") + csv_filename = svg_filename.replace('.svg', '.%s' % self.options.output_format) + output_path = os.path.join(self.options.path, csv_filename) + + def add_suffix(path, suffix): + if suffix > 0: + path = "%s.%s" % (path, suffix) + + return path + + def move_if_exists(path, suffix=0): + source = add_suffix(path, suffix) + + if suffix >= self.options.max_backups: + return + + dest = add_suffix(path, suffix + 1) + + if os.path.exists(source): + move_if_exists(path, suffix + 1) + + if os.path.exists(dest): + os.remove(dest) + + os.rename(source, dest) + + move_if_exists(output_path) + + return output_path + + def effect(self): + if not self.get_elements(): + return + + if self.options.hide_layers: + self.hide_all_layers() + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches, self.options.collapse_length_mm * PIXELS_PER_MM) + write_embroidery_file(self.get_output_path(), stitch_plan, self.document.getroot()) + render_stitch_plan(self.document.getroot(), stitch_plan) diff --git a/lib/extensions/input.py b/lib/extensions/input.py new file mode 100644 index 00000000..bd3db0ed --- /dev/null +++ b/lib/extensions/input.py @@ -0,0 +1,66 @@ +import os +from os.path import realpath, dirname, join as path_join +import sys + +# help python find libembroidery when running in a local repo clone +if getattr(sys, 'frozen', None) is None: + sys.path.append(realpath(path_join(dirname(__file__), '..', '..'))) + +from libembroidery import * +from inkex import etree +import inkex +from .. import PIXELS_PER_MM, INKSCAPE_LABEL, _ +from ..stitch_plan import StitchPlan +from ..svg import render_stitch_plan + + +class Input(object): + def pattern_stitches(self, pattern): + stitch_pointer = pattern.stitchList + while stitch_pointer: + yield stitch_pointer.stitch + stitch_pointer = stitch_pointer.next + + + def affect(self, args): + embroidery_file = args[0] + pattern = embPattern_create() + embPattern_read(pattern, embroidery_file) + embPattern_flipVertical(pattern) + + stitch_plan = StitchPlan() + color_block = None + current_color = None + + for stitch in self.pattern_stitches(pattern): + if stitch.color != current_color: + thread = embThreadList_getAt(pattern.threadList, stitch.color) + color = thread.color + color_block = stitch_plan.new_color_block((color.r, color.g, color.b)) + current_color = stitch.color + + if not stitch.flags & END: + color_block.add_stitch(stitch.xx * PIXELS_PER_MM, stitch.yy * PIXELS_PER_MM, + jump=stitch.flags & JUMP, + stop=stitch.flags & STOP, + trim=stitch.flags & TRIM) + + extents = stitch_plan.extents + svg = etree.Element("svg", nsmap=inkex.NSS, attrib= + { + "width": str(extents[0] * 2), + "height": str(extents[1] * 2), + "viewBox": "0 0 %s %s" % (extents[0] * 2, extents[1] * 2), + }) + render_stitch_plan(svg, stitch_plan) + + # 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.attrib.pop('id') + + # Shift the design so that its origin is at the center of the canvas + # Note: this is NOT the same as centering the design in the canvas! + layer.set('transform', 'translate(%s,%s)' % (extents[0], extents[1])) + + print etree.tostring(svg) diff --git a/lib/extensions/palettes.py b/lib/extensions/palettes.py new file mode 100644 index 00000000..269dc6dc --- /dev/null +++ b/lib/extensions/palettes.py @@ -0,0 +1,111 @@ +import sys +import traceback +import os +from os.path import realpath, dirname +from glob import glob +from threading import Thread +import socket +import errno +import time +import logging +import wx +import inkex +from ..utils import guess_inkscape_config_path + + +class InstallPalettesFrame(wx.Frame): + def __init__(self, *args, **kwargs): + wx.Frame.__init__(self, *args, **kwargs) + + default_path = os.path.join(guess_inkscape_config_path(), "palettes") + + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + + text = wx.StaticText(panel, label=_("Directory in which to install palettes:")) + font = wx.Font(12, wx.DEFAULT, wx.NORMAL, wx.NORMAL) + text.SetFont(font) + sizer.Add(text, proportion=0, flag=wx.ALL|wx.EXPAND, border=10) + + path_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.path_input = wx.TextCtrl(panel, wx.ID_ANY, value=default_path) + path_sizer.Add(self.path_input, proportion=3, flag=wx.RIGHT|wx.EXPAND, border=20) + chooser_button = wx.Button(panel, wx.ID_OPEN, _('Choose another directory...')) + path_sizer.Add(chooser_button, proportion=1, flag=wx.EXPAND) + sizer.Add(path_sizer, proportion=0, flag=wx.ALL|wx.EXPAND, border=10) + + buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) + install_button = wx.Button(panel, wx.ID_ANY, _("Install")) + install_button.SetBitmap(wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK)) + buttons_sizer.Add(install_button, proportion=0, flag=wx.ALIGN_RIGHT|wx.ALL, border=5) + cancel_button = wx.Button(panel, wx.ID_CANCEL, _("Cancel")) + buttons_sizer.Add(cancel_button, proportion=0, flag=wx.ALIGN_RIGHT|wx.ALL, border=5) + sizer.Add(buttons_sizer, proportion=0, flag=wx.ALIGN_RIGHT) + + outer_sizer = wx.BoxSizer(wx.HORIZONTAL) + outer_sizer.Add(sizer, proportion=0, flag=wx.ALIGN_CENTER_VERTICAL) + + panel.SetSizer(outer_sizer) + panel.Layout() + + chooser_button.Bind(wx.EVT_BUTTON, self.chooser_button_clicked) + cancel_button.Bind(wx.EVT_BUTTON, self.cancel_button_clicked) + install_button.Bind(wx.EVT_BUTTON, self.install_button_clicked) + + def cancel_button_clicked(self, event): + self.Destroy() + + def chooser_button_clicked(self, event): + dialog = wx.DirDialog(self, _("Choose Inkscape palettes directory")) + if dialog.ShowModal() != wx.ID_CANCEL: + self.path_input.SetValue(dialog.GetPath()) + + def install_button_clicked(self, event): + try: + self.install_palettes() + except Exception, e: + wx.MessageDialog(self, + _('Thread palette installation failed') + ': \n' + traceback.format_exc(), + _('Installation Failed'), + wx.OK).ShowModal() + else: + wx.MessageDialog(self, + _('Thread palette files have been installed. Please restart Inkscape to load the new palettes.'), + _('Installation Completed'), + wx.OK).ShowModal() + + self.Destroy() + + def install_palettes(self): + path = self.path_input.GetValue() + palettes_dir = self.get_bundled_palettes_dir() + self.copy_files(glob(os.path.join(palettes_dir, "*")), path) + + def get_bundled_palettes_dir(self): + if getattr(sys, 'frozen', None) is not None: + return realpath(os.path.join(sys._MEIPASS, '..', 'palettes')) + else: + return os.path.join(dirname(realpath(__file__)), 'palettes') + + if (sys.platform == "win32"): + # If we try to just use shutil.copy it says the operation requires elevation. + def copy_files(self, files, dest): + import winutils + + winutils.copy(files, dest) + else: + def copy_files(self, files, dest): + import shutil + + if not os.path.exists(dest): + os.makedirs(dest) + + for palette_file in files: + shutil.copy(palette_file, dest) + +class Palettes(inkex.Effect): + def effect(self): + app = wx.App() + installer_frame = InstallPalettesFrame(None, title=_("Ink/Stitch Thread Palette Installer"), size=(450, 200)) + installer_frame.Show() + app.MainLoop() diff --git a/lib/extensions/params.py b/lib/extensions/params.py new file mode 100644 index 00000000..881dab49 --- /dev/null +++ b/lib/extensions/params.py @@ -0,0 +1,756 @@ +# -*- coding: UTF-8 -*- + +import os +import sys +import json +import traceback +import time +from threading import Thread, Event +from copy import copy +import wx +from wx.lib.scrolledpanel import ScrolledPanel +from collections import defaultdict +from functools import partial +from itertools import groupby + +from .. import _ +from .base import InkstitchExtension +from ..stitch_plan import patches_to_stitch_plan +from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn +from ..utils import save_stderr, restore_stderr +from ..simulator import EmbroiderySimulator + + +def presets_path(): + try: + import appdirs + config_path = appdirs.user_config_dir('inkstitch') + except ImportError: + config_path = os.path.expanduser('~/.inkstitch') + + if not os.path.exists(config_path): + os.makedirs(config_path) + return os.path.join(config_path, 'presets.json') + + +def load_presets(): + try: + with open(presets_path(), 'r') as presets: + presets = json.load(presets) + return presets + except: + return {} + + +def save_presets(presets): + with open(presets_path(), 'w') as presets_file: + json.dump(presets, presets_file) + + +def load_preset(name): + return load_presets().get(name) + + +def save_preset(name, data): + presets = load_presets() + presets[name] = data + save_presets(presets) + + +def delete_preset(name): + presets = load_presets() + presets.pop(name, None) + save_presets(presets) + + +def confirm_dialog(parent, question, caption = 'ink/stitch'): + dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION) + result = dlg.ShowModal() == wx.ID_YES + dlg.Destroy() + return result + + +def info_dialog(parent, message, caption = 'ink/stitch'): + dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION) + dlg.ShowModal() + dlg.Destroy() + + +class ParamsTab(ScrolledPanel): + def __init__(self, *args, **kwargs): + self.params = kwargs.pop('params', []) + self.name = kwargs.pop('name', None) + self.nodes = kwargs.pop('nodes') + kwargs["style"] = wx.TAB_TRAVERSAL + ScrolledPanel.__init__(self, *args, **kwargs) + self.SetupScrolling() + + self.changed_inputs = set() + self.dependent_tabs = [] + self.parent_tab = None + self.param_inputs = {} + self.paired_tab = None + self.disable_notify_pair = False + + toggles = [param for param in self.params if param.type == 'toggle'] + + if toggles: + self.toggle = toggles[0] + self.params.remove(self.toggle) + self.toggle_checkbox = wx.CheckBox(self, label=self.toggle.description) + + value = any(self.toggle.values) + if self.toggle.inverse: + value = not value + self.toggle_checkbox.SetValue(value) + + self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.update_toggle_state) + self.toggle_checkbox.Bind(wx.EVT_CHECKBOX, self.changed) + + self.param_inputs[self.toggle.name] = self.toggle_checkbox + else: + self.toggle = None + + self.settings_grid = wx.FlexGridSizer(rows=0, cols=3, hgap=10, vgap=10) + self.settings_grid.AddGrowableCol(0, 1) + self.settings_grid.SetFlexibleDirection(wx.HORIZONTAL) + + self.__set_properties() + self.__do_layout() + + if self.toggle: + self.update_toggle_state() + # end wxGlade + + def pair(self, tab): + # print self.name, "paired with", tab.name + self.paired_tab = tab + self.update_description() + + def add_dependent_tab(self, tab): + self.dependent_tabs.append(tab) + self.update_description() + + def set_parent_tab(self, tab): + self.parent_tab = tab + + def is_dependent_tab(self): + return self.parent_tab is not None + + def enabled(self): + if self.toggle_checkbox: + return self.toggle_checkbox.IsChecked() + else: + return True + + def update_toggle_state(self, event=None, notify_pair=True): + enable = self.enabled() + # print self.name, "update_toggle_state", enable + for child in self.settings_grid.GetChildren(): + widget = child.GetWindow() + if widget: + child.GetWindow().Enable(enable) + + if notify_pair and self.paired_tab: + self.paired_tab.pair_changed(enable) + + for tab in self.dependent_tabs: + tab.dependent_enable(enable) + + if event: + event.Skip() + + def pair_changed(self, value): + # print self.name, "pair_changed", value + new_value = not value + + if self.enabled() != new_value: + self.set_toggle_state(not value) + self.update_toggle_state(notify_pair=False) + + if self.on_change_hook: + self.on_change_hook(self) + + def dependent_enable(self, enable): + if enable: + self.toggle_checkbox.Enable() + else: + self.set_toggle_state(False) + self.toggle_checkbox.Disable() + self.update_toggle_state() + + if self.on_change_hook: + self.on_change_hook(self) + + def set_toggle_state(self, value): + if self.toggle_checkbox: + self.toggle_checkbox.SetValue(value) + self.changed_inputs.add(self.toggle_checkbox) + + def get_values(self): + values = {} + + if self.toggle: + checked = self.enabled() + if self.toggle_checkbox in self.changed_inputs and not self.toggle.inverse: + values[self.toggle.name] = checked + + if not checked: + # Ignore params on this tab if the toggle is unchecked, + # because they're grayed out anyway. + return values + + for name, input in self.param_inputs.iteritems(): + if input in self.changed_inputs and input != self.toggle_checkbox: + values[name] = input.GetValue() + + return values + + def apply(self): + values = self.get_values() + for node in self.nodes: + # print >> sys.stderr, "apply: ", self.name, node.id, values + for name, value in values.iteritems(): + node.set_param(name, value) + + def on_change(self, callable): + self.on_change_hook = callable + + def changed(self, event): + self.changed_inputs.add(event.GetEventObject()) + event.Skip() + + if self.on_change_hook: + self.on_change_hook(self) + + def load_preset(self, preset): + preset_data = preset.get(self.name, {}) + + for name, value in preset_data.iteritems(): + if name in self.param_inputs: + self.param_inputs[name].SetValue(value) + self.changed_inputs.add(self.param_inputs[name]) + + self.update_toggle_state() + + def save_preset(self, storage): + preset = storage[self.name] = {} + for name, input in self.param_inputs.iteritems(): + preset[name] = input.GetValue() + + def update_description(self): + if len(self.nodes) == 1: + description = _("These settings will be applied to 1 object.") + else: + description = _("These settings will be applied to %d objects.") % len(self.nodes) + + if any(len(param.values) > 1 for param in self.params): + description += "\n • " + _("Some settings had different values across objects. Select a value from the dropdown or enter a new one.") + + if self.dependent_tabs: + if len(self.dependent_tabs) == 1: + description += "\n • " + _("Disabling this tab will disable the following %d tabs.") % len(self.dependent_tabs) + else: + description += "\n • " + _("Disabling this tab will disable the following tab.") + + if self.paired_tab: + description += "\n • " + _("Enabling this tab will disable %s and vice-versa.") % self.paired_tab.name + + self.description_text = description + + def resized(self, event): + if not hasattr(self, 'rewrap_timer'): + self.rewrap_timer = wx.Timer() + self.rewrap_timer.Bind(wx.EVT_TIMER, self.rewrap) + + # If we try to rewrap every time we get EVT_SIZE then a resize is + # extremely slow. + self.rewrap_timer.Start(50, oneShot=True) + event.Skip() + + def rewrap(self, event=None): + self.description.SetLabel(self.description_text) + self.description.Wrap(self.GetSize().x - 20) + self.description_container.Layout() + if event: + event.Skip() + + def __set_properties(self): + # begin wxGlade: SatinPane.__set_properties + # end wxGlade + pass + + def __do_layout(self): + # just to add space around the settings + box = wx.BoxSizer(wx.VERTICAL) + + summary_box = wx.StaticBox(self, wx.ID_ANY, label=_("Inkscape objects")) + sizer = wx.StaticBoxSizer(summary_box, wx.HORIZONTAL) +# sizer = wx.BoxSizer(wx.HORIZONTAL) + self.description = wx.StaticText(self) + self.update_description() + self.description.SetLabel(self.description_text) + self.description_container = box + self.Bind(wx.EVT_SIZE, self.resized) + sizer.Add(self.description, proportion=0, flag=wx.EXPAND|wx.ALL, border=5) + box.Add(sizer, proportion=0, flag=wx.ALL, border=5) + + if self.toggle: + box.Add(self.toggle_checkbox, proportion=0, flag=wx.BOTTOM, border=10) + + for param in self.params: + description = wx.StaticText(self, label=param.description) + description.SetToolTip(param.tooltip) + + self.settings_grid.Add(description, proportion=1, flag=wx.EXPAND|wx.RIGHT, border=40) + + if param.type == 'boolean': + + if len(param.values) > 1: + input = wx.CheckBox(self, style=wx.CHK_3STATE) + input.Set3StateValue(wx.CHK_UNDETERMINED) + else: + input = wx.CheckBox(self) + if param.values: + input.SetValue(param.values[0]) + + input.Bind(wx.EVT_CHECKBOX, self.changed) + elif len(param.values) > 1: + input = wx.ComboBox(self, wx.ID_ANY, choices=sorted(param.values), style=wx.CB_DROPDOWN) + input.Bind(wx.EVT_COMBOBOX, self.changed) + input.Bind(wx.EVT_TEXT, self.changed) + else: + value = param.values[0] if param.values else "" + input = wx.TextCtrl(self, wx.ID_ANY, value=str(value)) + input.Bind(wx.EVT_TEXT, self.changed) + + self.param_inputs[param.name] = input + + self.settings_grid.Add(input, proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) + self.settings_grid.Add(wx.StaticText(self, label=param.unit or ""), proportion=1, flag=wx.ALIGN_CENTER_VERTICAL) + + box.Add(self.settings_grid, proportion=1, flag=wx.ALL, border=10) + self.SetSizer(box) + + self.Layout() + +# end of class SatinPane + +class SettingsFrame(wx.Frame): + def __init__(self, *args, **kwargs): + # begin wxGlade: MyFrame.__init__ + self.tabs_factory = kwargs.pop('tabs_factory', []) + self.cancel_hook = kwargs.pop('on_cancel', None) + wx.Frame.__init__(self, None, wx.ID_ANY, + _("Embroidery Params") + ) + self.notebook = wx.Notebook(self, wx.ID_ANY) + self.tabs = self.tabs_factory(self.notebook) + + for tab in self.tabs: + tab.on_change(self.update_simulator) + + self.simulate_window = None + self.simulate_thread = None + self.simulate_refresh_needed = Event() + + wx.CallLater(1000, self.update_simulator) + + self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets")) + + self.preset_chooser = wx.ComboBox(self, wx.ID_ANY) + self.update_preset_list() + + self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load")) + self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_preset) + + self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add")) + self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset) + + self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite")) + self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset) + + self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete")) + self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset) + + 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.use_last_button = wx.Button(self, wx.ID_ANY, _("Use Last Settings")) + self.use_last_button.Bind(wx.EVT_BUTTON, self.use_last) + + self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit")) + self.apply_button.Bind(wx.EVT_BUTTON, self.apply) + + self.__set_properties() + self.__do_layout() + # end wxGlade + + def update_simulator(self, tab=None): + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.clear() + + if not self.simulate_thread or not self.simulate_thread.is_alive(): + self.simulate_thread = Thread(target=self.simulate_worker) + self.simulate_thread.daemon = True + self.simulate_thread.start() + + self.simulate_refresh_needed.set() + + def simulate_worker(self): + while True: + self.simulate_refresh_needed.wait() + self.simulate_refresh_needed.clear() + self.update_patches() + + def update_patches(self): + patches = self.generate_patches() + + if patches and not self.simulate_refresh_needed.is_set(): + wx.CallAfter(self.refresh_simulator, patches) + + def refresh_simulator(self, patches): + stitch_plan = patches_to_stitch_plan(patches) + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.load(stitch_plan=stitch_plan) + else: + my_rect = self.GetRect() + simulator_pos = my_rect.GetTopRight() + simulator_pos.x += 5 + + screen_rect = wx.Display(0).ClientArea + max_width = screen_rect.GetWidth() - my_rect.GetWidth() + max_height = screen_rect.GetHeight() + + try: + self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"), + simulator_pos, + size=(300, 300), + stitch_plan=stitch_plan, + on_close=self.simulate_window_closed, + target_duration=5, + max_width=max_width, + max_height=max_height) + except: + error = traceback.format_exc() + + try: + # a window may have been created, so we need to destroy it + # or the app will never exit + wx.Window.FindWindowByName("Preview").Destroy() + except: + pass + + info_dialog(self, error, _("Internal Error")) + + self.simulate_window.Show() + wx.CallLater(10, self.Raise) + + wx.CallAfter(self.simulate_window.go) + + def simulate_window_closed(self): + self.simulate_window = None + + def generate_patches(self): + patches = [] + nodes = [] + + for tab in self.tabs: + tab.apply() + + if tab.enabled() and not tab.is_dependent_tab(): + nodes.extend(tab.nodes) + + # sort nodes into the proper stacking order + nodes.sort(key=lambda node: node.order) + + try: + for node in nodes: + if self.simulate_refresh_needed.is_set(): + # cancel; params were updated and we need to start over + return [] + + # Making a copy of the embroidery element is an easy + # way to drop the cache in the @cache decorators used + # for many params in embroider.py. + + patches.extend(copy(node).embroider(None)) + except SystemExit: + raise + except: + # Ignore errors. This can be things like incorrect paths for + # satins or division by zero caused by incorrect param values. + pass + + return patches + + def update_preset_list(self): + preset_names = load_presets().keys() + preset_names = [preset for preset in preset_names if preset != "__LAST__"] + self.preset_chooser.SetItems(sorted(preset_names)) + + def get_preset_name(self): + preset_name = self.preset_chooser.GetValue().strip() + if preset_name: + return preset_name + else: + info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset')) + return + + def check_and_load_preset(self, preset_name): + preset = load_preset(preset_name) + if not preset: + info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset')) + + return preset + + def get_preset_data(self): + preset = {} + + current_tab = self.tabs[self.notebook.GetSelection()] + while current_tab.parent_tab: + current_tab = current_tab.parent_tab + + tabs = [current_tab] + if current_tab.paired_tab: + tabs.append(current_tab.paired_tab) + tabs.extend(current_tab.paired_tab.dependent_tabs) + tabs.extend(current_tab.dependent_tabs) + + for tab in tabs: + tab.save_preset(preset) + + return preset + + def add_preset(self, event, overwrite=False): + preset_name = self.get_preset_name() + if not preset_name: + return + + if not overwrite and load_preset(preset_name): + info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset')) + + save_preset(preset_name, self.get_preset_data()) + self.update_preset_list() + + event.Skip() + + def overwrite_preset(self, event): + self.add_preset(event, overwrite=True) + + + def _load_preset(self, preset_name): + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + for tab in self.tabs: + tab.load_preset(preset) + + + def load_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + self._load_preset(preset_name) + + event.Skip() + + + def delete_preset(self, event): + preset_name = self.get_preset_name() + if not preset_name: + return + + preset = self.check_and_load_preset(preset_name) + if not preset: + return + + delete_preset(preset_name) + self.update_preset_list() + self.preset_chooser.SetValue("") + + event.Skip() + + def _apply(self): + for tab in self.tabs: + tab.apply() + + def apply(self, event): + self._apply() + save_preset("__LAST__", self.get_preset_data()) + self.close() + + def use_last(self, event): + self._load_preset("__LAST__") + self.apply(event) + + def close(self): + if self.simulate_window: + self.simulate_window.stop() + self.simulate_window.Close() + + self.Destroy() + + def cancel(self, event): + if self.cancel_hook: + self.cancel_hook() + + self.close() + + def __set_properties(self): + # begin wxGlade: MyFrame.__set_properties + self.notebook.SetMinSize((800, 600)) + self.preset_chooser.SetSelection(-1) + # end wxGlade + + def __do_layout(self): + # begin wxGlade: MyFrame.__do_layout + sizer_1 = wx.BoxSizer(wx.VERTICAL) + #self.sizer_3_staticbox.Lower() + sizer_2 = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL) + sizer_3 = wx.BoxSizer(wx.HORIZONTAL) + for tab in self.tabs: + self.notebook.AddPage(tab, tab.name) + sizer_1.Add(self.notebook, 1, wx.EXPAND|wx.LEFT|wx.TOP|wx.RIGHT, 10) + sizer_2.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_2.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL|wx.ALL, 5) + sizer_1.Add(sizer_2, 0, flag=wx.EXPAND|wx.ALL, border=10) + sizer_3.Add(self.cancel_button, 0, wx.ALIGN_RIGHT|wx.RIGHT, 5) + sizer_3.Add(self.use_last_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) + sizer_3.Add(self.apply_button, 0, wx.ALIGN_RIGHT|wx.RIGHT|wx.BOTTOM, 5) + sizer_1.Add(sizer_3, 0, wx.ALIGN_RIGHT, 0) + self.SetSizer(sizer_1) + sizer_1.Fit(self) + self.Layout() + # end wxGlade + +class Params(InkstitchExtension): + def __init__(self, *args, **kwargs): + self.cancelled = False + InkstitchExtension.__init__(self, *args, **kwargs) + + def embroidery_classes(self, node): + element = EmbroideryElement(node) + classes = [] + + if element.get_style("fill"): + classes.append(AutoFill) + classes.append(Fill) + + if element.get_style("stroke"): + classes.append(Stroke) + + if element.get_style("stroke-dasharray") is None: + classes.append(SatinColumn) + + return classes + + def get_nodes_by_class(self): + nodes = self.get_nodes() + nodes_by_class = defaultdict(list) + + for z, node in enumerate(nodes): + for cls in self.embroidery_classes(node): + element = cls(node) + element.order = z + nodes_by_class[cls].append(element) + + return sorted(nodes_by_class.items(), key=lambda (cls, nodes): cls.__name__) + + def get_values(self, param, nodes): + getter = 'get_param' + + if param.type in ('toggle', 'boolean'): + getter = 'get_boolean_param' + else: + getter = 'get_param' + + values = filter(lambda item: item is not None, + (getattr(node, getter)(param.name, str(param.default)) for node in nodes)) + + return values + + def group_params(self, params): + def by_group_and_sort_index(param): + return param.group, param.sort_index + + def by_group(param): + return param.group + + return groupby(sorted(params, key=by_group_and_sort_index), by_group) + + def create_tabs(self, parent): + tabs = [] + for cls, nodes in self.get_nodes_by_class(): + params = cls.get_params() + + for param in params: + param.values = list(set(self.get_values(param, nodes))) + + parent_tab = None + new_tabs = [] + for group, params in self.group_params(params): + tab = ParamsTab(parent, id=wx.ID_ANY, name=group or cls.element_name, params=list(params), nodes=nodes) + new_tabs.append(tab) + + if group is None: + parent_tab = tab + + for tab in new_tabs: + if tab != parent_tab: + parent_tab.add_dependent_tab(tab) + tab.set_parent_tab(parent_tab) + + tabs.extend(new_tabs) + + for tab in tabs: + if tab.toggle and tab.toggle.inverse: + for other_tab in tabs: + if other_tab != tab and other_tab.toggle.name == tab.toggle.name: + tab.pair(other_tab) + other_tab.pair(tab) + + def tab_sort_key(tab): + parent = tab.parent_tab or tab + + sort_key = ( + # For Stroke and SatinColumn, place the one that's + # enabled first. Place dependent tabs first too. + parent.toggle and parent.toggle_checkbox.IsChecked(), + + # If multiple tabs are enabled, make sure dependent + # tabs are grouped with the parent. + parent, + + # Within parent/dependents, put the parent first. + tab == parent + ) + + return sort_key + + tabs.sort(key=tab_sort_key, reverse=True) + + return tabs + + + def cancel(self): + self.cancelled = True + + def effect(self): + app = wx.App() + frame = SettingsFrame(tabs_factory=self.create_tabs, on_cancel=self.cancel) + frame.Show() + app.MainLoop() + + if self.cancelled: + # This prevents the superclass from outputting the SVG, because we + # may have modified the DOM. + sys.exit(0) diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py new file mode 100644 index 00000000..5d462c0f --- /dev/null +++ b/lib/extensions/print_pdf.py @@ -0,0 +1,391 @@ +import sys +import traceback +import os +from threading import Thread +import socket +import errno +import time +import logging +from copy import deepcopy +import wx +import appdirs +import json + +import inkex +from .. import _, PIXELS_PER_MM, SVG_GROUP_TAG, translation as inkstitch_translation +from .base import InkstitchExtension +from ..stitch_plan import patches_to_stitch_plan +from ..svg import render_stitch_plan +from ..threads import ThreadCatalog + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from datetime import date +import base64 + +from flask import Flask, request, Response, send_from_directory, jsonify +import webbrowser +import requests + + +def datetimeformat(value, format='%Y/%m/%d'): + return value.strftime(format) + + +def defaults_path(): + defaults_dir = appdirs.user_config_dir('inkstitch') + + if not os.path.exists(defaults_dir): + os.makedirs(defaults_dir) + + return os.path.join(defaults_dir, 'print_settings.json') + + +def load_defaults(): + try: + with open(defaults_path(), 'r') as defaults_file: + defaults = json.load(defaults_file) + return defaults + except: + return {} + + +def save_defaults(defaults): + with open(defaults_path(), 'w') as defaults_file: + json.dump(defaults, defaults_file) + + +def open_url(url): + # Avoid spurious output from xdg-open. Any output on stdout will crash + # inkscape. + null = open(os.devnull, 'w') + old_stdout = os.dup(sys.stdout.fileno()) + os.dup2(null.fileno(), sys.stdout.fileno()) + + if getattr(sys, 'frozen', False): + + # PyInstaller sets LD_LIBRARY_PATH. We need to temporarily clear it + # to avoid confusing xdg-open, which webbrowser will run. + + # The following code is adapted from PyInstaller's documentation + # http://pyinstaller.readthedocs.io/en/stable/runtime-information.html + + old_environ = dict(os.environ) # make a copy of the environment + lp_key = 'LD_LIBRARY_PATH' # for Linux and *BSD. + lp_orig = os.environ.get(lp_key + '_ORIG') # pyinstaller >= 20160820 has this + if lp_orig is not None: + os.environ[lp_key] = lp_orig # restore the original, unmodified value + else: + os.environ.pop(lp_key, None) # last resort: remove the env var + + webbrowser.open(url) + + # restore the old environ + os.environ.clear() + os.environ.update(old_environ) + else: + webbrowser.open(url) + + # restore file descriptors + os.dup2(old_stdout, sys.stdout.fileno()) + os.close(old_stdout) + + +class PrintPreviewServer(Thread): + def __init__(self, *args, **kwargs): + self.html = kwargs.pop('html') + self.metadata = kwargs.pop('metadata') + self.stitch_plan = kwargs.pop('stitch_plan') + Thread.__init__(self, *args, **kwargs) + self.daemon = True + self.last_request_time = None + self.shutting_down = False + + self.__setup_app() + + def __set_resources_path(self): + if getattr(sys, 'frozen', False): + self.resources_path = os.path.join(sys._MEIPASS, 'print', 'resources') + else: + self.resources_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..', 'print', 'resources')) + + def __setup_app(self): + self.__set_resources_path() + self.app = Flask(__name__) + + @self.app.before_request + def request_started(): + self.last_request_time = time.time() + + @self.app.before_first_request + def start_watcher(): + self.watcher_thread = Thread(target=self.watch) + self.watcher_thread.daemon = True + self.watcher_thread.start() + + @self.app.route('/') + def index(): + return self.html + + @self.app.route('/shutdown', methods=['POST']) + def shutdown(): + self.shutting_down = True + request.environ.get('werkzeug.server.shutdown')() + return _('Closing...') + '

' + _('It is safe to close this window now.') + + @self.app.route('/resources/', methods=['GET']) + def resources(resource): + return send_from_directory(self.resources_path, resource, cache_timeout=1) + + @self.app.route('/ping') + def ping(): + # Javascript is letting us know it's still there. This resets self.last_request_time. + return "pong" + + @self.app.route('/printing/start') + def printing_start(): + # temporarily turn off the watcher while the print dialog is up, + # because javascript will be frozen + self.last_request_time = None + return "OK" + + @self.app.route('/printing/end') + def printing_end(): + # nothing to do here -- request_started() will restart the watcher + return "OK" + + @self.app.route('/settings/', methods=['POST']) + def set_field(field_name): + self.metadata[field_name] = request.json['value'] + return "OK" + + @self.app.route('/settings/', methods=['GET']) + def get_field(field_name): + return jsonify(self.metadata[field_name]) + + @self.app.route('/settings', methods=['GET']) + def get_settings(): + settings = {} + settings.update(load_defaults()) + settings.update(self.metadata) + return jsonify(settings) + + @self.app.route('/defaults', methods=['POST']) + def set_defaults(): + save_defaults(request.json['value']) + return "OK" + + @self.app.route('/palette', methods=['POST']) + def set_palette(): + name = request.json['name'] + catalog = ThreadCatalog() + palette = catalog.get_palette_by_name(name) + catalog.apply_palette(self.stitch_plan, palette) + + # clear any saved color or thread names + for field in self.metadata: + if field.startswith('color-') or field.startswith('thread-'): + del self.metadata[field] + + self.metadata['thread-palette'] = name + + return "OK" + + @self.app.route('/threads', methods=['GET']) + def get_threads(): + threads = [] + for color_block in self.stitch_plan: + threads.append({ + 'hex': color_block.color.hex_digits, + 'name': color_block.color.name, + 'manufacturer': color_block.color.manufacturer, + 'number': color_block.color.number, + }) + + return jsonify(threads) + + def stop(self): + # for whatever reason, shutting down only seems possible in + # the context of a flask request, so we'll just make one + requests.post("http://%s:%s/shutdown" % (self.host, self.port)) + + def watch(self): + try: + while True: + time.sleep(1) + if self.shutting_down: + break + + if self.last_request_time is not None and \ + (time.time() - self.last_request_time) > 3: + self.stop() + break + except: + # seems like sometimes this thread blows up during shutdown + pass + + def disable_logging(self): + logging.getLogger('werkzeug').setLevel(logging.ERROR) + + def run(self): + self.disable_logging() + + self.host = "127.0.0.1" + self.port = 5000 + + while True: + try: + self.app.run(self.host, self.port, threaded=True) + except socket.error, e: + if e.errno == errno.EADDRINUSE: + self.port += 1 + continue + else: + raise + else: + break + + +class PrintInfoFrame(wx.Frame): + def __init__(self, *args, **kwargs): + self.print_server = kwargs.pop("print_server") + wx.Frame.__init__(self, *args, **kwargs) + + panel = wx.Panel(self) + sizer = wx.BoxSizer(wx.VERTICAL) + + text = wx.StaticText(panel, label=_("A print preview has been opened in your web browser. This window will stay open in order to communicate with the JavaScript code running in your browser.\n\nThis window will close after you close the print preview in your browser, or you can close it manually if necessary.")) + font = wx.Font(14, wx.DEFAULT, wx.NORMAL, wx.NORMAL) + text.SetFont(font) + sizer.Add(text, proportion=1, flag=wx.ALL|wx.EXPAND, border=20) + + stop_button = wx.Button(panel, id=wx.ID_CLOSE) + stop_button.Bind(wx.EVT_BUTTON, self.close_button_clicked) + sizer.Add(stop_button, proportion=0, flag=wx.ALIGN_CENTER|wx.ALL, border=10) + + panel.SetSizer(sizer) + panel.Layout() + + self.timer = wx.PyTimer(self.__watcher) + self.timer.Start(250) + + def close_button_clicked(self, event): + self.print_server.stop() + + def __watcher(self): + if not self.print_server.is_alive(): + self.timer.Stop() + self.timer = None + self.Destroy() + + +class Print(InkstitchExtension): + def build_environment(self): + if getattr( sys, 'frozen', False ) : + template_dir = os.path.join(sys._MEIPASS, "print", "templates") + else: + template_dir = os.path.realpath(os.path.join(os.path.dirname(__file__), "..", "..", "print", "templates")) + + env = Environment( + loader = FileSystemLoader(template_dir), + autoescape=select_autoescape(['html', 'xml']), + extensions=['jinja2.ext.i18n'] + ) + + env.filters['datetimeformat'] = datetimeformat + env.install_gettext_translations(inkstitch_translation) + + return env + + def strip_namespaces(self): + # namespace prefixes seem to trip up HTML, so get rid of them + for element in self.document.iter(): + if element.tag[0]=='{': + element.tag = element.tag[element.tag.index('}',1) + 1:] + + def effect(self): + # It doesn't really make sense to print just a couple of selected + # objects. It's almost certain they meant to print the whole design. + # If they really wanted to print just a few objects, they could set + # the rest invisible temporarily. + self.selected = {} + + if not self.get_elements(): + return + + self.hide_all_layers() + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches) + palette = ThreadCatalog().match_and_apply_palette(stitch_plan, self.get_inkstitch_metadata()['thread-palette']) + render_stitch_plan(self.document.getroot(), stitch_plan) + + self.strip_namespaces() + + # Now the stitch plan layer will contain a set of groups, each + # corresponding to a color block. We'll create a set of SVG files + # corresponding to each individual color block and a final one + # for all color blocks together. + + svg = self.document.getroot() + layers = svg.findall("./g[@{http://www.inkscape.org/namespaces/inkscape}groupmode='layer']") + stitch_plan_layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + + # First, delete all of the other layers. We don't need them and they'll + # just bulk up the SVG. + for layer in layers: + if layer is not stitch_plan_layer: + svg.remove(layer) + + overview_svg = inkex.etree.tostring(self.document) + + color_block_groups = stitch_plan_layer.getchildren() + + for i, group in enumerate(color_block_groups): + # clear the stitch plan layer + del stitch_plan_layer[:] + + # add in just this group + stitch_plan_layer.append(group) + + # save an SVG preview + stitch_plan.color_blocks[i].svg_preview = inkex.etree.tostring(self.document) + + env = self.build_environment() + template = env.get_template('index.html') + + html = template.render( + view = {'client_overview': False, 'client_detailedview': False, 'operator_overview': True, 'operator_detailedview': True}, + logo = {'src' : '', 'title' : 'LOGO'}, + date = date.today(), + client = "", + job = { + 'title': '', + 'num_colors': stitch_plan.num_colors, + 'num_color_blocks': len(stitch_plan), + 'num_stops': stitch_plan.num_stops, + 'num_trims': stitch_plan.num_trims, + 'dimensions': stitch_plan.dimensions_mm, + 'num_stitches': stitch_plan.num_stitches, + 'estimated_time': '', # TODO + 'estimated_thread': '', # TODO + }, + svg_overview = overview_svg, + color_blocks = stitch_plan.color_blocks, + palettes = ThreadCatalog().palette_names(), + selected_palette = palette.name, + ) + + # We've totally mucked with the SVG. Restore it so that we can save + # metadata into it. + self.document = deepcopy(self.original_document) + + print_server = PrintPreviewServer(html=html, metadata=self.get_inkstitch_metadata(), stitch_plan=stitch_plan) + print_server.start() + + time.sleep(1) + open_url("http://%s:%s/" % (print_server.host, print_server.port)) + + app = wx.App() + info_frame = PrintInfoFrame(None, title=_("Ink/Stitch Print"), size=(450, 350), print_server=print_server) + info_frame.Show() + app.MainLoop() diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py new file mode 100644 index 00000000..75bc62c7 --- /dev/null +++ b/lib/extensions/simulate.py @@ -0,0 +1,27 @@ +import wx + +from .base import InkstitchExtension +from ..simulator import EmbroiderySimulator +from ..stitch_plan import patches_to_stitch_plan + + +class Simulate(InkstitchExtension): + def __init__(self): + InkstitchExtension.__init__(self) + self.OptionParser.add_option("-P", "--path", + action="store", type="string", + dest="path", default=".", + help="Directory in which to store output file") + + def effect(self): + if not self.get_elements(): + return + + patches = self.elements_to_patches(self.elements) + stitch_plan = patches_to_stitch_plan(patches) + app = wx.App() + frame = EmbroiderySimulator(None, -1, _("Embroidery Simulation"), wx.DefaultPosition, size=(1000, 1000), stitch_plan=stitch_plan) + app.SetTopWindow(frame) + frame.Show() + wx.CallAfter(frame.go) + app.MainLoop() -- cgit v1.3.1 From 05daffb7e01db55879eb24f3c00532324a5d41af Mon Sep 17 00:00:00 2001 From: Lex Neva Date: Tue, 1 May 2018 21:21:07 -0400 Subject: refactor everything out of lib/__init__.py --- lib/__init__.py | 298 ----------------------------------------- lib/elements/auto_fill.py | 8 +- lib/elements/element.py | 18 ++- lib/elements/fill.py | 9 +- lib/elements/polyline.py | 3 +- lib/elements/satin_column.py | 6 +- lib/elements/stroke.py | 5 +- lib/extensions/base.py | 3 +- lib/extensions/embroider.py | 7 +- lib/extensions/input.py | 10 +- lib/extensions/palettes.py | 1 + lib/extensions/params.py | 2 +- lib/extensions/print_pdf.py | 15 +-- lib/extensions/simulate.py | 1 + lib/i18n.py | 21 +++ lib/output.py | 130 ++++++++++++++++++ lib/simulator.py | 3 +- lib/stitch_plan/__init__.py | 1 + lib/stitch_plan/stitch.py | 15 +++ lib/stitch_plan/stitch_plan.py | 5 +- lib/stitch_plan/ties.py | 6 +- lib/stitches/auto_fill.py | 7 +- lib/stitches/fill.py | 6 +- lib/svg.py | 76 ----------- lib/svg/__init__.py | 2 + lib/svg/svg.py | 81 +++++++++++ lib/svg/tags.py | 12 ++ lib/svg/units.py | 105 +++++++++++++++ lib/threads/catalog.py | 2 + lib/threads/palette.py | 3 +- lib/utils/io.py | 1 + messages.po | 24 ++-- 32 files changed, 454 insertions(+), 432 deletions(-) create mode 100644 lib/i18n.py create mode 100644 lib/output.py create mode 100644 lib/stitch_plan/stitch.py delete mode 100644 lib/svg.py create mode 100644 lib/svg/__init__.py create mode 100644 lib/svg/svg.py create mode 100644 lib/svg/tags.py create mode 100644 lib/svg/units.py (limited to 'lib/extensions') diff --git a/lib/__init__.py b/lib/__init__.py index 2c0ee620..e69de29b 100644 --- a/lib/__init__.py +++ b/lib/__init__.py @@ -1,298 +0,0 @@ -#!/usr/bin/env python -# http://www.achatina.de/sewing/main/TECHNICL.HTM - -import os -import sys -import gettext -from copy import deepcopy -import math -import libembroidery -from .utils import cache -from .utils.geometry import Point - -import inkex -import simplepath -import simplestyle -import simpletransform -from bezmisc import bezierlength, beziertatlength, bezierpointatt -from cspsubdiv import cspsubdiv -import cubicsuperpath -from shapely import geometry as shgeo - - -# modern versions of Inkscape use 96 pixels per inch as per the CSS standard -PIXELS_PER_MM = 96 / 25.4 - -SVG_PATH_TAG = inkex.addNS('path', 'svg') -SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') -SVG_DEFS_TAG = inkex.addNS('defs', 'svg') -SVG_GROUP_TAG = inkex.addNS('g', 'svg') -INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') -INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') - -EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) - -dbg = open(os.devnull, "w") - -translation = None -_ = lambda message: message - - -def localize(): - if getattr(sys, 'frozen', False): - # we are in a pyinstaller installation - locale_dir = sys._MEIPASS - else: - locale_dir = os.path.dirname(__file__) - - locale_dir = os.path.join(locale_dir, 'locales') - - global translation, _ - - translation = gettext.translation("inkstitch", locale_dir, fallback=True) - _ = translation.gettext - -localize() - -# cribbed from inkscape-silhouette -def parse_length_with_units( str ): - - ''' - Parse an SVG value which may or may not have units attached - This version is greatly simplified in that it only allows: no units, - units of px, mm, and %. Everything else, it returns None for. - There is a more general routine to consider in scour.py if more - generality is ever needed. - ''' - - u = 'px' - s = str.strip() - if s[-2:] == 'px': - s = s[:-2] - elif s[-2:] == 'mm': - u = 'mm' - s = s[:-2] - elif s[-2:] == 'pt': - u = 'pt' - s = s[:-2] - elif s[-2:] == 'pc': - u = 'pc' - s = s[:-2] - elif s[-2:] == 'cm': - u = 'cm' - s = s[:-2] - elif s[-2:] == 'in': - u = 'in' - s = s[:-2] - elif s[-1:] == '%': - u = '%' - s = s[:-1] - try: - v = float( s ) - except: - raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) - - return v, u - - -def convert_length(length): - value, units = parse_length_with_units(length) - - if not units or units == "px": - return value - - if units == 'pt': - value /= 72 - units = 'in' - - if units == 'pc': - value /= 6 - units = 'in' - - if units == 'cm': - value *= 10 - units = 'mm' - - if units == 'mm': - value = value / 25.4 - units = 'in' - - if units == 'in': - # modern versions of Inkscape use CSS's 96 pixels per inch. When you - # open an old document, inkscape will add a viewbox for you. - return value * 96 - - raise ValueError(_("Unknown unit: %s") % units) - - -@cache -def get_doc_size(svg): - doc_width = convert_length(svg.get('width')) - doc_height = convert_length(svg.get('height')) - - return doc_width, doc_height - -@cache -def get_viewbox_transform(node): - # somewhat cribbed from inkscape-silhouette - doc_width, doc_height = get_doc_size(node) - - viewbox = node.get('viewBox').strip().replace(',', ' ').split() - - dx = -float(viewbox[0]) - dy = -float(viewbox[1]) - transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) - - try: - sx = doc_width / float(viewbox[2]) - sy = doc_height / float(viewbox[3]) - scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) - transform = simpletransform.composeTransform(transform, scale_transform) - except ZeroDivisionError: - pass - - return transform - -@cache -def get_stroke_scale(node): - doc_width, doc_height = get_doc_size(node) - viewbox = node.get('viewBox').strip().replace(',', ' ').split() - return doc_width / float(viewbox[2]) - - -class Stitch(Point): - def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): - self.x = x - self.y = y - self.color = color - self.jump = jump - self.trim = trim - self.stop = stop - self.no_ties = no_ties - - def __repr__(self): - return "Stitch(%s, %s, %s, %s, %s, %s, %s)" % (self.x, self.y, self.color, "JUMP" if self.jump else " ", "TRIM" if self.trim else " ", "STOP" if self.stop else " ", "NO TIES" if self.no_ties else " ") - - -def make_thread(color): - thread = libembroidery.EmbThread() - thread.color = libembroidery.embColor_make(*color.rgb) - - thread.description = color.name - thread.catalogNumber = "" - - return thread - -def add_thread(pattern, thread): - """Add a thread to a pattern and return the thread's index""" - - libembroidery.embPattern_addThread(pattern, thread) - - return libembroidery.embThreadList_count(pattern.threadList) - 1 - -def get_flags(stitch): - flags = 0 - - if stitch.jump: - flags |= libembroidery.JUMP - - if stitch.trim: - flags |= libembroidery.TRIM - - if stitch.stop: - flags |= libembroidery.STOP - - return flags - - -def _string_to_floats(string): - floats = string.split(',') - return [float(num) for num in floats] - - -def get_origin(svg): - # The user can specify the embroidery origin by defining two guides - # named "embroidery origin" that intersect. - - namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) - all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) - label_attribute = inkex.addNS('label', 'inkscape') - guides = [guide for guide in all_guides - if guide.get(label_attribute, "").startswith("embroidery origin")] - - # document size used below - doc_size = list(get_doc_size(svg)) - - # convert the size from viewbox-relative to real-world pixels - viewbox_transform = get_viewbox_transform(svg) - simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) - - default = [doc_size[0] / 2.0, doc_size[1] / 2.0] - simpletransform.applyTransformToPoint(viewbox_transform, default) - default = Point(*default) - - if len(guides) < 2: - return default - - # Find out where the guides intersect. Only pay attention to the first two. - guides = guides[:2] - - lines = [] - for guide in guides: - # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates - position = Point(*_string_to_floats(guide.get('position'))) - position.y = doc_size[1] - position.y - - - # This one baffles me. I think inkscape might have gotten the order of - # their vector wrong? - parts = _string_to_floats(guide.get('orientation')) - direction = Point(parts[1], parts[0]) - - # We have a theoretically infinite line defined by a point on the line - # and a vector direction. Shapely can only deal in concrete line - # segments, so we'll pick points really far in either direction on the - # line and call it good enough. - lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) - - intersection = lines[0].intersection(lines[1]) - - if isinstance(intersection, shgeo.Point): - origin = [intersection.x, intersection.y] - simpletransform.applyTransformToPoint(viewbox_transform, origin) - return Point(*origin) - else: - # Either the two guides are the same line, or they're parallel. - return default - - -def write_embroidery_file(file_path, stitch_plan, svg): - origin = get_origin(svg) - - pattern = libembroidery.embPattern_create() - - for color_block in stitch_plan: - add_thread(pattern, make_thread(color_block.color)) - - for stitch in color_block: - if stitch.stop and stitch is not color_block.last_stitch: - # A STOP stitch that is not at the end of a color block - # occurs when the user specified "STOP after". "STOP" is the - # same thing as a color change, and the user will assign a - # special color at the machine that tells it to pause after. - # We need to add another copy of the same color here so that - # the stitches after the STOP are still the same color. - add_thread(pattern, make_thread(color_block.color)) - - flags = get_flags(stitch) - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, flags, 1) - - libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, libembroidery.END, 1) - - # convert from pixels to millimeters - libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) - - # SVG and embroidery disagree on the direction of the Y axis - libembroidery.embPattern_flipVertical(pattern) - - libembroidery.embPattern_write(pattern, file_path) diff --git a/lib/elements/auto_fill.py b/lib/elements/auto_fill.py index 6eb1f10c..08ae67f7 100644 --- a/lib/elements/auto_fill.py +++ b/lib/elements/auto_fill.py @@ -1,10 +1,10 @@ import math -from .. import _ -from .element import param, Patch -from ..utils import cache -from .fill import Fill from shapely import geometry as shgeo +from ..i18n import _ +from ..utils import cache from ..stitches import auto_fill +from .element import param, Patch +from .fill import Fill class AutoFill(Fill): diff --git a/lib/elements/element.py b/lib/elements/element.py index cfca3782..300136dd 100644 --- a/lib/elements/element.py +++ b/lib/elements/element.py @@ -1,9 +1,10 @@ import sys from copy import deepcopy +from shapely import geometry as shgeo +from ..i18n import _ from ..utils import cache -from shapely import geometry as shgeo -from .. import _, PIXELS_PER_MM, get_viewbox_transform, get_stroke_scale, convert_length +from ..svg import PIXELS_PER_MM, get_viewbox_transform, convert_length, get_doc_size # inkscape-provided utilities import simpletransform @@ -11,6 +12,7 @@ import simplestyle import cubicsuperpath from cspsubdiv import cspsubdiv + class Patch: """A raw collection of stitches with attached instructions.""" @@ -144,6 +146,15 @@ class EmbroideryElement(object): style = simplestyle.parseStyle(self.node.get("style")) return style_name in style + @property + @cache + def stroke_scale(self): + svg = self.node.getroottree().getroot() + doc_width, doc_height = get_doc_size(svg) + viewbox = svg.get('viewBox', '0 0 %s %s' % (doc_width, doc_height)) + viewbox = viewbox.strip().replace(',', ' ').split() + return doc_width / float(viewbox[2]) + @property @cache def stroke_width(self): @@ -153,8 +164,7 @@ class EmbroideryElement(object): return 1.0 width = convert_length(width) - - return width * get_stroke_scale(self.node.getroottree().getroot()) + return width * self.stroke_scale @property def path(self): diff --git a/lib/elements/fill.py b/lib/elements/fill.py index a74a897d..52a42260 100644 --- a/lib/elements/fill.py +++ b/lib/elements/fill.py @@ -1,10 +1,13 @@ -from .. import _, PIXELS_PER_MM -from .element import param, EmbroideryElement, Patch -from ..utils import cache from shapely import geometry as shgeo import math + +from .element import param, EmbroideryElement, Patch +from ..i18n import _ +from ..svg import PIXELS_PER_MM +from ..utils import cache from ..stitches import running_stitch, auto_fill, legacy_fill + class Fill(EmbroideryElement): element_name = _("Fill") diff --git a/lib/elements/polyline.py b/lib/elements/polyline.py index 6ded9fd1..5c474237 100644 --- a/lib/elements/polyline.py +++ b/lib/elements/polyline.py @@ -1,5 +1,6 @@ -from .. import _, Point from .element import param, EmbroideryElement, Patch +from ..i18n import _ +from ..utils.geometry import Point from ..utils import cache diff --git a/lib/elements/satin_column.py b/lib/elements/satin_column.py index d22f5145..3593db64 100644 --- a/lib/elements/satin_column.py +++ b/lib/elements/satin_column.py @@ -1,9 +1,9 @@ from itertools import chain, izip +from shapely import geometry as shgeo, ops as shops -from .. import _, Point from .element import param, EmbroideryElement, Patch -from ..utils import cache -from shapely import geometry as shgeo, ops as shops +from ..i18n import _ +from ..utils import cache, Point class SatinColumn(EmbroideryElement): diff --git a/lib/elements/stroke.py b/lib/elements/stroke.py index 360e3744..48662b6d 100644 --- a/lib/elements/stroke.py +++ b/lib/elements/stroke.py @@ -1,7 +1,8 @@ import sys -from .. import _, Point + from .element import param, EmbroideryElement, Patch -from ..utils import cache +from ..i18n import _ +from ..utils import cache, Point warned_about_legacy_running_stitch = False diff --git a/lib/extensions/base.py b/lib/extensions/base.py index 91e050eb..ff587ca5 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -3,8 +3,9 @@ import re import json from copy import deepcopy from collections import MutableMapping + +from ..svg.tags import * from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement -from .. import SVG_POLYLINE_TAG, SVG_GROUP_TAG, SVG_DEFS_TAG, INKSCAPE_GROUPMODE, EMBROIDERABLE_TAGS, PIXELS_PER_MM from ..utils import cache diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py index 564e96ca..a213be64 100644 --- a/lib/extensions/embroider.py +++ b/lib/extensions/embroider.py @@ -1,12 +1,13 @@ import sys import traceback import os - import inkex -from .. import _, PIXELS_PER_MM, write_embroidery_file + from .base import InkstitchExtension +from ..i18n import _ +from ..output import write_embroidery_file from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan +from ..svg import render_stitch_plan, PIXELS_PER_MM class Embroider(InkstitchExtension): diff --git a/lib/extensions/input.py b/lib/extensions/input.py index bd3db0ed..f8bf5a5d 100644 --- a/lib/extensions/input.py +++ b/lib/extensions/input.py @@ -1,17 +1,19 @@ import os from os.path import realpath, dirname, join as path_join import sys +from inkex import etree +import inkex # help python find libembroidery when running in a local repo clone if getattr(sys, 'frozen', None) is None: sys.path.append(realpath(path_join(dirname(__file__), '..', '..'))) from libembroidery import * -from inkex import etree -import inkex -from .. import PIXELS_PER_MM, INKSCAPE_LABEL, _ + +from ..svg import PIXELS_PER_MM, render_stitch_plan +from ..svg.tags import INKSCAPE_LABEL +from ..i18n import _ from ..stitch_plan import StitchPlan -from ..svg import render_stitch_plan class Input(object): diff --git a/lib/extensions/palettes.py b/lib/extensions/palettes.py index 269dc6dc..f7a6c7a5 100644 --- a/lib/extensions/palettes.py +++ b/lib/extensions/palettes.py @@ -10,6 +10,7 @@ import time import logging import wx import inkex + from ..utils import guess_inkscape_config_path diff --git a/lib/extensions/params.py b/lib/extensions/params.py index 881dab49..03a6f3cc 100644 --- a/lib/extensions/params.py +++ b/lib/extensions/params.py @@ -13,8 +13,8 @@ from collections import defaultdict from functools import partial from itertools import groupby -from .. import _ from .base import InkstitchExtension +from ..i18n import _ from ..stitch_plan import patches_to_stitch_plan from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn from ..utils import save_stderr, restore_stderr diff --git a/lib/extensions/print_pdf.py b/lib/extensions/print_pdf.py index 5d462c0f..6450ee7c 100644 --- a/lib/extensions/print_pdf.py +++ b/lib/extensions/print_pdf.py @@ -10,22 +10,21 @@ from copy import deepcopy import wx import appdirs import json - import inkex -from .. import _, PIXELS_PER_MM, SVG_GROUP_TAG, translation as inkstitch_translation -from .base import InkstitchExtension -from ..stitch_plan import patches_to_stitch_plan -from ..svg import render_stitch_plan -from ..threads import ThreadCatalog - from jinja2 import Environment, FileSystemLoader, select_autoescape from datetime import date import base64 - from flask import Flask, request, Response, send_from_directory, jsonify import webbrowser import requests +from .base import InkstitchExtension +from ..i18n import _, translation as inkstitch_translation +from ..svg import PIXELS_PER_MM, render_stitch_plan +from ..svg.tags import SVG_GROUP_TAG +from ..stitch_plan import patches_to_stitch_plan +from ..threads import ThreadCatalog + def datetimeformat(value, format='%Y/%m/%d'): return value.strftime(format) diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py index 75bc62c7..0c372d4d 100644 --- a/lib/extensions/simulate.py +++ b/lib/extensions/simulate.py @@ -1,6 +1,7 @@ import wx from .base import InkstitchExtension +from ..i18n import _ from ..simulator import EmbroiderySimulator from ..stitch_plan import patches_to_stitch_plan diff --git a/lib/i18n.py b/lib/i18n.py new file mode 100644 index 00000000..d20f5d2f --- /dev/null +++ b/lib/i18n.py @@ -0,0 +1,21 @@ +import sys +import os +import gettext + +_ = translation = None + +def localize(): + if getattr(sys, 'frozen', False): + # we are in a pyinstaller installation + locale_dir = sys._MEIPASS + else: + locale_dir = os.path.dirname(__file__) + + locale_dir = os.path.join(locale_dir, 'locales') + + global translation, _ + + translation = gettext.translation("inkstitch", locale_dir, fallback=True) + _ = translation.gettext + +localize() diff --git a/lib/output.py b/lib/output.py new file mode 100644 index 00000000..f1651357 --- /dev/null +++ b/lib/output.py @@ -0,0 +1,130 @@ +import libembroidery +import inkex +import simpletransform + +from .utils import Point +from .svg import PIXELS_PER_MM, get_doc_size, get_viewbox_transform + + +def make_thread(color): + thread = libembroidery.EmbThread() + thread.color = libembroidery.embColor_make(*color.rgb) + + thread.description = color.name + thread.catalogNumber = "" + + return thread + +def add_thread(pattern, thread): + """Add a thread to a pattern and return the thread's index""" + + libembroidery.embPattern_addThread(pattern, thread) + + return libembroidery.embThreadList_count(pattern.threadList) - 1 + +def get_flags(stitch): + flags = 0 + + if stitch.jump: + flags |= libembroidery.JUMP + + if stitch.trim: + flags |= libembroidery.TRIM + + if stitch.stop: + flags |= libembroidery.STOP + + return flags + + +def _string_to_floats(string): + floats = string.split(',') + return [float(num) for num in floats] + + +def get_origin(svg): + # The user can specify the embroidery origin by defining two guides + # named "embroidery origin" that intersect. + + namedview = svg.find(inkex.addNS('namedview', 'sodipodi')) + all_guides = namedview.findall(inkex.addNS('guide', 'sodipodi')) + label_attribute = inkex.addNS('label', 'inkscape') + guides = [guide for guide in all_guides + if guide.get(label_attribute, "").startswith("embroidery origin")] + + # document size used below + doc_size = list(get_doc_size(svg)) + + # convert the size from viewbox-relative to real-world pixels + viewbox_transform = get_viewbox_transform(svg) + simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) + + default = [doc_size[0] / 2.0, doc_size[1] / 2.0] + simpletransform.applyTransformToPoint(viewbox_transform, default) + default = Point(*default) + + if len(guides) < 2: + return default + + # Find out where the guides intersect. Only pay attention to the first two. + guides = guides[:2] + + lines = [] + for guide in guides: + # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates + position = Point(*_string_to_floats(guide.get('position'))) + position.y = doc_size[1] - position.y + + + # This one baffles me. I think inkscape might have gotten the order of + # their vector wrong? + parts = _string_to_floats(guide.get('orientation')) + direction = Point(parts[1], parts[0]) + + # We have a theoretically infinite line defined by a point on the line + # and a vector direction. Shapely can only deal in concrete line + # segments, so we'll pick points really far in either direction on the + # line and call it good enough. + lines.append(shgeo.LineString((position + 100000 * direction, position - 100000 * direction))) + + intersection = lines[0].intersection(lines[1]) + + if isinstance(intersection, shgeo.Point): + origin = [intersection.x, intersection.y] + simpletransform.applyTransformToPoint(viewbox_transform, origin) + return Point(*origin) + else: + # Either the two guides are the same line, or they're parallel. + return default + + +def write_embroidery_file(file_path, stitch_plan, svg): + origin = get_origin(svg) + + pattern = libembroidery.embPattern_create() + + for color_block in stitch_plan: + add_thread(pattern, make_thread(color_block.color)) + + for stitch in color_block: + if stitch.stop and stitch is not color_block.last_stitch: + # A STOP stitch that is not at the end of a color block + # occurs when the user specified "STOP after". "STOP" is the + # same thing as a color change, and the user will assign a + # special color at the machine that tells it to pause after. + # We need to add another copy of the same color here so that + # the stitches after the STOP are still the same color. + add_thread(pattern, make_thread(color_block.color)) + + flags = get_flags(stitch) + libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, flags, 1) + + libembroidery.embPattern_addStitchAbs(pattern, stitch.x - origin.x, stitch.y - origin.y, libembroidery.END, 1) + + # convert from pixels to millimeters + libembroidery.embPattern_scale(pattern, 1/PIXELS_PER_MM) + + # SVG and embroidery disagree on the direction of the Y axis + libembroidery.embPattern_flipVertical(pattern) + + libembroidery.embPattern_write(pattern, file_path) diff --git a/lib/simulator.py b/lib/simulator.py index cc9442ea..c7e74353 100644 --- a/lib/simulator.py +++ b/lib/simulator.py @@ -4,8 +4,7 @@ import wx import colorsys from itertools import izip -from . import PIXELS_PER_MM -from .svg import color_block_to_point_lists +from .svg import PIXELS_PER_MM, color_block_to_point_lists class EmbroiderySimulator(wx.Frame): diff --git a/lib/stitch_plan/__init__.py b/lib/stitch_plan/__init__.py index 6c1f418a..791a5f20 100644 --- a/lib/stitch_plan/__init__.py +++ b/lib/stitch_plan/__init__.py @@ -1 +1,2 @@ from stitch_plan import patches_to_stitch_plan, StitchPlan, ColorBlock +from .stitch import Stitch diff --git a/lib/stitch_plan/stitch.py b/lib/stitch_plan/stitch.py new file mode 100644 index 00000000..6a8579c2 --- /dev/null +++ b/lib/stitch_plan/stitch.py @@ -0,0 +1,15 @@ +from ..utils.geometry import Point + + +class Stitch(Point): + def __init__(self, x, y, color=None, jump=False, stop=False, trim=False, no_ties=False): + self.x = x + self.y = y + self.color = color + self.jump = jump + self.trim = trim + self.stop = stop + self.no_ties = no_ties + + def __repr__(self): + return "Stitch(%s, %s, %s, %s, %s, %s, %s)" % (self.x, self.y, self.color, "JUMP" if self.jump else " ", "TRIM" if self.trim else " ", "STOP" if self.stop else " ", "NO TIES" if self.no_ties else " ") diff --git a/lib/stitch_plan/stitch_plan.py b/lib/stitch_plan/stitch_plan.py index fab87876..570a7645 100644 --- a/lib/stitch_plan/stitch_plan.py +++ b/lib/stitch_plan/stitch_plan.py @@ -1,8 +1,9 @@ -from .. import Stitch, PIXELS_PER_MM -from ..utils.geometry import Point +from .stitch import Stitch from .stop import process_stop from .trim import process_trim from .ties import add_ties +from ..svg import PIXELS_PER_MM +from ..utils.geometry import Point from ..threads import ThreadColor diff --git a/lib/stitch_plan/ties.py b/lib/stitch_plan/ties.py index 1207ea51..f9c5b721 100644 --- a/lib/stitch_plan/ties.py +++ b/lib/stitch_plan/ties.py @@ -1,7 +1,9 @@ +from copy import deepcopy + +from .stitch import Stitch from ..utils import cut_path from ..stitches import running_stitch -from .. import Stitch -from copy import deepcopy + def add_tie(stitches, tie_path): if stitches[-1].no_ties: diff --git a/lib/stitches/auto_fill.py b/lib/stitches/auto_fill.py index 7f265909..518a2812 100644 --- a/lib/stitches/auto_fill.py +++ b/lib/stitches/auto_fill.py @@ -1,5 +1,3 @@ -from fill import intersect_region_with_grating, row_num, stitch_row -from .. import _, PIXELS_PER_MM, Point as InkstitchPoint import sys import shapely import networkx @@ -7,6 +5,11 @@ import math from itertools import groupby from collections import deque +from .fill import intersect_region_with_grating, row_num, stitch_row +from ..i18n import _ +from ..svg import PIXELS_PER_MM +from ..utils.geometry import Point as InkstitchPoint + class MaxQueueLengthExceeded(Exception): pass diff --git a/lib/stitches/fill.py b/lib/stitches/fill.py index 1b7377b0..14971cb4 100644 --- a/lib/stitches/fill.py +++ b/lib/stitches/fill.py @@ -1,9 +1,10 @@ -from .. import PIXELS_PER_MM -from ..utils import cache, Point as InkstitchPoint import shapely import math import sys +from ..svg import PIXELS_PER_MM +from ..utils import cache, Point as InkstitchPoint + def legacy_fill(shape, angle, row_spacing, end_row_spacing, max_stitch_length, flip, staggers): rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing, flip) @@ -242,4 +243,3 @@ def pull_runs(rows, shape, row_spacing): count += 1 return runs - diff --git a/lib/svg.py b/lib/svg.py deleted file mode 100644 index 0728309b..00000000 --- a/lib/svg.py +++ /dev/null @@ -1,76 +0,0 @@ -import simpletransform, simplestyle, inkex -from . import _, get_viewbox_transform, cache, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG - -def color_block_to_point_lists(color_block): - point_lists = [[]] - - for stitch in color_block: - if stitch.trim: - if point_lists[-1]: - point_lists.append([]) - continue - - if not stitch.jump and not stitch.stop: - point_lists[-1].append(stitch.as_tuple()) - - return point_lists - - -@cache -def get_correction_transform(svg): - transform = get_viewbox_transform(svg) - - # we need to correct for the viewbox - transform = simpletransform.invertTransform(transform) - transform = simpletransform.formatTransform(transform) - - return transform - - -def color_block_to_paths(color_block, svg): - paths = [] - # We could emit just a single path with one subpath per point list, but - # emitting multiple paths makes it easier for the user to manipulate them. - for point_list in color_block_to_point_lists(color_block): - color = color_block.color.visible_on_white.to_hex_str() - paths.append(inkex.etree.Element( - SVG_PATH_TAG, - {'style': simplestyle.formatStyle( - {'stroke': color, - 'stroke-width': "0.4", - 'fill': 'none'}), - 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), - 'transform': get_correction_transform(svg), - 'embroider_manual_stitch': 'true', - 'embroider_trim_after': 'true', - })) - - # no need to trim at the end of a thread color - if paths: - paths[-1].attrib.pop('embroider_trim_after') - - return paths - - -def render_stitch_plan(svg, stitch_plan): - layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") - if layer is None: - layer = inkex.etree.Element(SVG_GROUP_TAG, - {'id': '__inkstitch_stitch_plan__', - INKSCAPE_LABEL: _('Stitch Plan'), - INKSCAPE_GROUPMODE: 'layer'}) - else: - # delete old stitch plan - del layer[:] - - # make sure the layer is visible - layer.set('style', 'display:inline') - - for i, color_block in enumerate(stitch_plan): - group = inkex.etree.SubElement(layer, - SVG_GROUP_TAG, - {'id': '__color_block_%d__' % i, - INKSCAPE_LABEL: "color block %d" % (i + 1)}) - group.extend(color_block_to_paths(color_block, svg)) - - svg.append(layer) diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py new file mode 100644 index 00000000..1895bba4 --- /dev/null +++ b/lib/svg/__init__.py @@ -0,0 +1,2 @@ +from .svg import color_block_to_point_lists, render_stitch_plan +from .units import * diff --git a/lib/svg/svg.py b/lib/svg/svg.py new file mode 100644 index 00000000..3bc546e7 --- /dev/null +++ b/lib/svg/svg.py @@ -0,0 +1,81 @@ +import simpletransform, simplestyle, inkex + +from .units import get_viewbox_transform +from .tags import SVG_GROUP_TAG, INKSCAPE_LABEL, INKSCAPE_GROUPMODE, SVG_PATH_TAG +from ..i18n import _ +from ..utils import cache + + +def color_block_to_point_lists(color_block): + point_lists = [[]] + + for stitch in color_block: + if stitch.trim: + if point_lists[-1]: + point_lists.append([]) + continue + + if not stitch.jump and not stitch.stop: + point_lists[-1].append(stitch.as_tuple()) + + return point_lists + + +@cache +def get_correction_transform(svg): + transform = get_viewbox_transform(svg) + + # we need to correct for the viewbox + transform = simpletransform.invertTransform(transform) + transform = simpletransform.formatTransform(transform) + + return transform + + +def color_block_to_paths(color_block, svg): + paths = [] + # We could emit just a single path with one subpath per point list, but + # emitting multiple paths makes it easier for the user to manipulate them. + for point_list in color_block_to_point_lists(color_block): + color = color_block.color.visible_on_white.to_hex_str() + paths.append(inkex.etree.Element( + SVG_PATH_TAG, + {'style': simplestyle.formatStyle( + {'stroke': color, + 'stroke-width': "0.4", + 'fill': 'none'}), + 'd': "M" + " ".join(" ".join(str(coord) for coord in point) for point in point_list), + 'transform': get_correction_transform(svg), + 'embroider_manual_stitch': 'true', + 'embroider_trim_after': 'true', + })) + + # no need to trim at the end of a thread color + if paths: + paths[-1].attrib.pop('embroider_trim_after') + + return paths + + +def render_stitch_plan(svg, stitch_plan): + layer = svg.find(".//*[@id='__inkstitch_stitch_plan__']") + if layer is None: + layer = inkex.etree.Element(SVG_GROUP_TAG, + {'id': '__inkstitch_stitch_plan__', + INKSCAPE_LABEL: _('Stitch Plan'), + INKSCAPE_GROUPMODE: 'layer'}) + else: + # delete old stitch plan + del layer[:] + + # make sure the layer is visible + layer.set('style', 'display:inline') + + for i, color_block in enumerate(stitch_plan): + group = inkex.etree.SubElement(layer, + SVG_GROUP_TAG, + {'id': '__color_block_%d__' % i, + INKSCAPE_LABEL: "color block %d" % (i + 1)}) + group.extend(color_block_to_paths(color_block, svg)) + + svg.append(layer) diff --git a/lib/svg/tags.py b/lib/svg/tags.py new file mode 100644 index 00000000..fee59957 --- /dev/null +++ b/lib/svg/tags.py @@ -0,0 +1,12 @@ +import inkex + + +SVG_PATH_TAG = inkex.addNS('path', 'svg') +SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg') +SVG_DEFS_TAG = inkex.addNS('defs', 'svg') +SVG_GROUP_TAG = inkex.addNS('g', 'svg') + +INKSCAPE_LABEL = inkex.addNS('label', 'inkscape') +INKSCAPE_GROUPMODE = inkex.addNS('groupmode', 'inkscape') + +EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/svg/units.py b/lib/svg/units.py new file mode 100644 index 00000000..015da60e --- /dev/null +++ b/lib/svg/units.py @@ -0,0 +1,105 @@ +import simpletransform + +from ..utils import cache + +# modern versions of Inkscape use 96 pixels per inch as per the CSS standard +PIXELS_PER_MM = 96 / 25.4 + +# cribbed from inkscape-silhouette +def parse_length_with_units( str ): + + ''' + Parse an SVG value which may or may not have units attached + This version is greatly simplified in that it only allows: no units, + units of px, mm, and %. Everything else, it returns None for. + There is a more general routine to consider in scour.py if more + generality is ever needed. + ''' + + u = 'px' + s = str.strip() + if s[-2:] == 'px': + s = s[:-2] + elif s[-2:] == 'mm': + u = 'mm' + s = s[:-2] + elif s[-2:] == 'pt': + u = 'pt' + s = s[:-2] + elif s[-2:] == 'pc': + u = 'pc' + s = s[:-2] + elif s[-2:] == 'cm': + u = 'cm' + s = s[:-2] + elif s[-2:] == 'in': + u = 'in' + s = s[:-2] + elif s[-1:] == '%': + u = '%' + s = s[:-1] + try: + v = float( s ) + except: + raise ValueError(_("parseLengthWithUnits: unknown unit %s") % s) + + return v, u + + +def convert_length(length): + value, units = parse_length_with_units(length) + + if not units or units == "px": + return value + + if units == 'pt': + value /= 72 + units = 'in' + + if units == 'pc': + value /= 6 + units = 'in' + + if units == 'cm': + value *= 10 + units = 'mm' + + if units == 'mm': + value = value / 25.4 + units = 'in' + + if units == 'in': + # modern versions of Inkscape use CSS's 96 pixels per inch. When you + # open an old document, inkscape will add a viewbox for you. + return value * 96 + + raise ValueError(_("Unknown unit: %s") % units) + + +@cache +def get_doc_size(svg): + doc_width = convert_length(svg.get('width')) + doc_height = convert_length(svg.get('height')) + + return doc_width, doc_height + +@cache +def get_viewbox_transform(node): + # somewhat cribbed from inkscape-silhouette + doc_width, doc_height = get_doc_size(node) + + viewbox = node.get('viewBox').strip().replace(',', ' ').split() + + dx = -float(viewbox[0]) + dy = -float(viewbox[1]) + transform = simpletransform.parseTransform("translate(%f, %f)" % (dx, dy)) + + try: + sx = doc_width / float(viewbox[2]) + sy = doc_height / float(viewbox[3]) + scale_transform = simpletransform.parseTransform("scale(%f, %f)" % (sx, sy)) + transform = simpletransform.composeTransform(transform, scale_transform) + except ZeroDivisionError: + pass + + return transform diff --git a/lib/threads/catalog.py b/lib/threads/catalog.py index cebae4ff..d9981dc6 100644 --- a/lib/threads/catalog.py +++ b/lib/threads/catalog.py @@ -3,8 +3,10 @@ from os.path import dirname, realpath import sys from glob import glob from collections import Sequence + from .palette import ThreadPalette + class _ThreadCatalog(Sequence): """Holds a set of ThreadPalettes.""" diff --git a/lib/threads/palette.py b/lib/threads/palette.py index e1f47c7f..785fb082 100644 --- a/lib/threads/palette.py +++ b/lib/threads/palette.py @@ -1,9 +1,10 @@ from collections import Set -from .color import ThreadColor from colormath.color_objects import sRGBColor, LabColor from colormath.color_conversions import convert_color from colormath.color_diff import delta_e_cie1994 +from .color import ThreadColor + def compare_thread_colors(color1, color2): # K_L=2 indicates textiles diff --git a/lib/utils/io.py b/lib/utils/io.py index e87b9881..be1fdf24 100644 --- a/lib/utils/io.py +++ b/lib/utils/io.py @@ -2,6 +2,7 @@ import os import sys from cStringIO import StringIO + def save_stderr(): # GTK likes to spam stderr, which inkscape will show in a dialog. null = open(os.devnull, 'w') diff --git a/messages.po b/messages.po index a9fae6ff..7b2ca815 100644 --- a/messages.po +++ b/messages.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2018-04-29 21:45-0400\n" +"POT-Creation-Date: 2018-05-01 21:21-0400\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,17 +17,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.5.3\n" -#, python-format -msgid "parseLengthWithUnits: unknown unit %s" -msgstr "" - -#, python-format -msgid "Unknown unit: %s" -msgstr "" - -msgid "Stitch Plan" -msgstr "" - msgid "Auto-Fill" msgstr "" @@ -333,6 +322,17 @@ msgid "" "file to lexelby@github." msgstr "" +msgid "Stitch Plan" +msgstr "" + +#, python-format +msgid "parseLengthWithUnits: unknown unit %s" +msgstr "" + +#, python-format +msgid "Unknown unit: %s" +msgstr "" + msgid "Color" msgstr "" -- cgit v1.3.1