diff --git a/Makefile b/Makefile index 11765025f..ed86a36ce 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ dist: distclean locales inx cp -a images/examples dist/inkstitch cp -a palettes dist/inkstitch cp -a symbols dist/inkstitch + cp -a fonts dist/inkstitch cp -a icons dist/inkstitch/bin cp -a locales dist/inkstitch/bin cp -a print dist/inkstitch/bin diff --git a/fonts/small_font/LICENSE b/fonts/small_font/LICENSE new file mode 100644 index 000000000..10ea73a81 --- /dev/null +++ b/fonts/small_font/LICENSE @@ -0,0 +1,94 @@ +Copyright (c) 2017, Lex Neva. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + diff --git a/fonts/small_font/README_en.md b/fonts/small_font/README_en.md new file mode 100644 index 000000000..129d3fb86 --- /dev/null +++ b/fonts/small_font/README_en.md @@ -0,0 +1,24 @@ +Small Font +========== + +This is a small font for use in machine embroidery. It includes capital and lowercase letters, numbers, and the following punctuation: `< > = - + , : ( ) @ . _ ' " “ ”` + +Currently one size is available, 0.2 inches, as measured by the width of the capital letter "M". + +You may use this font for free in any embroidery project, including in designs that you intend to sell or sew out and sell. There is no limitation on the number of designs or items you may sell. For full details, check the LICENSE file in the top directory. + +I've taken pains to make this font sew out nicely even though it's tiny. Every character is properly underlayed using a center-walk stitch. Even still, your choice of fabric, needle, and stabilizer will definitely affect your end results! Make sure you test it out before sewing onto a garment or anything else that you really care about. + +If you can see areas for improvement, I'd like to know. Just open a github issue in this repo and we can talk through your ideas. + +File Structure +-------------- + +This font is designed for Ink/Stitch and uses its standard font folder layout. The folder contains four files: + +* `→.svg` contains glyphs designed for words laid out horizontally and stitched left-to-right +* `←.svg` contains glyphs designed for words laid out horizontally and stitched right-to-left +* `↓.svg` contains glyphs designed for words laid out vertically and stitched top-to-bottom +* `↑.svg` contains glyphs designed for words laid out vertically and stitched bottom-to-top + +The files are named as they are so as to (hopefully) be recognizable to native speakers of multiple languages. diff --git a/fonts/small_font/font.json b/fonts/small_font/font.json new file mode 100644 index 000000000..a89925e74 --- /dev/null +++ b/fonts/small_font/font.json @@ -0,0 +1,12 @@ +{ + "name": "Ink/Stitch Small Font", + "description": "A font suited for small characters. The capital em is 0.2 inches wide.", + "leading": 8, + "letter_spacing": 1.5, + "word_spacing": 4.5, + "auto_satin": true, + "default_glyph": "�", + "kerning_pairs": { + "wo": -0.3 + } +} diff --git a/fonts/small_font/←.svg b/fonts/small_font/←.svg new file mode 100644 index 000000000..1875cce32 --- /dev/null +++ b/fonts/small_font/←.svg @@ -0,0 +1,2989 @@ + + + + + + + + + + + + + + Satin Column cut point + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fonts/small_font/↑.svg b/fonts/small_font/↑.svg new file mode 100644 index 000000000..e304a711e --- /dev/null +++ b/fonts/small_font/↑.svg @@ -0,0 +1,2948 @@ + + + + + + + + + + + + + + Satin Column cut point + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fonts/small_font/→.svg b/fonts/small_font/→.svg new file mode 100644 index 000000000..d53f4c6e8 --- /dev/null +++ b/fonts/small_font/→.svg @@ -0,0 +1,2978 @@ + + + + + + + + + + + + + + Satin Column cut point + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fonts/small_font/↓.svg b/fonts/small_font/↓.svg new file mode 100644 index 000000000..484937b48 --- /dev/null +++ b/fonts/small_font/↓.svg @@ -0,0 +1,2948 @@ + + + + + + + + + + + + + + Satin Column cut point + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inkstitch.py b/inkstitch.py index 1a7b14683..9b040265b 100644 --- a/inkstitch.py +++ b/inkstitch.py @@ -19,6 +19,16 @@ ch.setFormatter(formatter) logger.addHandler(ch) +logger = logging.getLogger('shapely.geos') +logger.setLevel(logging.DEBUG) +shapely_errors = StringIO() +ch = logging.StreamHandler(shapely_errors) +ch.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s') +ch.setFormatter(formatter) +logger.addHandler(ch) + + parser = ArgumentParser() parser.add_argument("--extension") my_args, remaining_args = parser.parse_known_args() diff --git a/lib/elements/__init__.py b/lib/elements/__init__.py index 22603217a..5413ba04d 100644 --- a/lib/elements/__init__.py +++ b/lib/elements/__init__.py @@ -1,6 +1,7 @@ +from auto_fill import AutoFill from element import EmbroideryElement +from fill import Fill +from polyline import Polyline from satin_column import SatinColumn from stroke import Stroke -from polyline import Polyline -from fill import Fill -from auto_fill import AutoFill +from utils import node_to_elements, nodes_to_elements diff --git a/lib/elements/utils.py b/lib/elements/utils.py new file mode 100644 index 000000000..87dfa877b --- /dev/null +++ b/lib/elements/utils.py @@ -0,0 +1,47 @@ + +from ..commands import is_command +from ..svg.tags import SVG_POLYLINE_TAG, SVG_PATH_TAG + +from .auto_fill import AutoFill +from .element import EmbroideryElement +from .fill import Fill +from .polyline import Polyline +from .satin_column import SatinColumn +from .stroke import Stroke + + +def node_to_elements(node): + if node.tag == SVG_POLYLINE_TAG: + return [Polyline(node)] + elif node.tag == SVG_PATH_TAG: + element = EmbroideryElement(node) + + if element.get_boolean_param("satin_column"): + return [SatinColumn(node)] + else: + elements = [] + + if element.get_style("fill", "black"): + if element.get_boolean_param("auto_fill", True): + elements.append(AutoFill(node)) + else: + elements.append(Fill(node)) + + if element.get_style("stroke"): + if not is_command(element.node): + elements.append(Stroke(node)) + + if element.get_boolean_param("stroke_first", False): + elements.reverse() + + return elements + else: + return [] + + +def nodes_to_elements(nodes): + elements = [] + for node in nodes: + elements.extend(node_to_elements(node)) + + return elements diff --git a/lib/extensions/__init__.py b/lib/extensions/__init__.py index f70c01351..741973ab2 100644 --- a/lib/extensions/__init__.py +++ b/lib/extensions/__init__.py @@ -1,18 +1,20 @@ +from auto_satin import AutoSatin +from convert_to_satin import ConvertToSatin +from cut_satin import CutSatin from embroider import Embroider +from flip import Flip +from global_commands import GlobalCommands +from input import Input from install import Install +from layer_commands import LayerCommands +from lettering import Lettering +from object_commands import ObjectCommands +from output import Output from params import Params from print_pdf import Print from simulate import Simulate -from input import Input -from output import Output from zip import Zip -from flip import Flip -from object_commands import ObjectCommands -from layer_commands import LayerCommands -from global_commands import GlobalCommands -from convert_to_satin import ConvertToSatin -from cut_satin import CutSatin -from auto_satin import AutoSatin + __all__ = extensions = [Embroider, Install, @@ -28,4 +30,5 @@ __all__ = extensions = [Embroider, GlobalCommands, ConvertToSatin, CutSatin, - AutoSatin] + AutoSatin, + Lettering] diff --git a/lib/extensions/auto_satin.py b/lib/extensions/auto_satin.py index e5e9c40bd..f846ac6b9 100644 --- a/lib/extensions/auto_satin.py +++ b/lib/extensions/auto_satin.py @@ -41,14 +41,15 @@ class AutoSatin(CommandsExtension): if not self.check_selection(): return - group = self.create_group() - new_elements, trim_indices = self.auto_satin() - - # The ordering is careful here. Some of the original satins may have - # been used unmodified. That's why we remove all of the original - # satins _first_ before adding new_elements back into the SVG. - self.remove_original_satins() - self.add_elements(group, new_elements) + if self.options.preserve_order: + # when preservering order, auto_satin() takes care of putting the + # newly-created elements into the existing group nodes in the SVG + # DOM + new_elements, trim_indices = self.auto_satin() + else: + group = self.create_group() + new_elements, trim_indices = self.auto_satin() + self.add_elements(group, new_elements) self.add_trims(new_elements, trim_indices) @@ -79,13 +80,6 @@ class AutoSatin(CommandsExtension): ending_point = self.get_ending_point() return auto_satin(self.elements, self.options.preserve_order, starting_point, ending_point) - def remove_original_satins(self): - for element in self.elements: - for command in element.commands: - command.connector.getparent().remove(command.connector) - command.use.getparent().remove(command.use) - element.node.getparent().remove(element.node) - def add_elements(self, group, new_elements): for i, element in enumerate(new_elements): if isinstance(element, SatinColumn): diff --git a/lib/extensions/base.py b/lib/extensions/base.py index b9bba617a..279ca396d 100644 --- a/lib/extensions/base.py +++ b/lib/extensions/base.py @@ -6,10 +6,10 @@ import re import inkex from stringcase import snakecase -from ..commands import is_command, layer_commands -from ..elements import AutoFill, Fill, Stroke, SatinColumn, Polyline, EmbroideryElement +from ..commands import layer_commands +from ..elements import EmbroideryElement, nodes_to_elements from ..i18n import _ -from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_GROUPMODE, SVG_DEFS_TAG, EMBROIDERABLE_TAGS, SVG_POLYLINE_TAG +from ..svg.tags import SVG_GROUP_TAG, INKSCAPE_GROUPMODE, SVG_DEFS_TAG, EMBROIDERABLE_TAGS SVG_METADATA_TAG = inkex.addNS("metadata", "svg") @@ -109,6 +109,16 @@ class InkstitchExtension(inkex.Effect): if g.get(INKSCAPE_GROUPMODE) == "layer": g.set("style", "display:none") + def ensure_current_layer(self): + # if no layer is selected, inkex defaults to the root, which isn't + # particularly useful + if self.current_layer is self.document.getroot(): + try: + self.current_layer = self.document.xpath(".//svg:g[@inkscape:groupmode='layer']", namespaces=inkex.NSS)[0] + except IndexError: + # No layers at all?? Fine, we'll stick with the default. + pass + def no_elements_error(self): if self.selected: inkex.errormsg(_("No embroiderable paths selected.")) @@ -151,38 +161,8 @@ class InkstitchExtension(inkex.Effect): def get_nodes(self): return self.descendants(self.document.getroot()) - 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", "black"): - if element.get_boolean_param("auto_fill", True): - classes.append(AutoFill) - else: - classes.append(Fill) - - if element.get_style("stroke"): - if not is_command(element.node): - 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) - + self.elements = nodes_to_elements(self.get_nodes()) if self.elements: return True else: diff --git a/lib/extensions/embroider.py b/lib/extensions/embroider.py index 7c8adfc9d..1a5780315 100644 --- a/lib/extensions/embroider.py +++ b/lib/extensions/embroider.py @@ -1,15 +1,15 @@ import os -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, PIXELS_PER_MM +from .base import InkstitchExtension class Embroider(InkstitchExtension): def __init__(self, *args, **kwargs): - InkstitchExtension.__init__(self) + InkstitchExtension.__init__(self, *args, **kwargs) self.OptionParser.add_option("-c", "--collapse_len_mm", action="store", type="float", dest="collapse_length_mm", default=3.0, diff --git a/lib/extensions/layer_commands.py b/lib/extensions/layer_commands.py index 60a5fab21..3a746fcfb 100644 --- a/lib/extensions/layer_commands.py +++ b/lib/extensions/layer_commands.py @@ -1,25 +1,15 @@ import inkex -from .commands import CommandsExtension from ..commands import LAYER_COMMANDS, get_command_description from ..i18n import _ -from ..svg.tags import SVG_USE_TAG, INKSCAPE_LABEL, XLINK_HREF from ..svg import get_correction_transform +from ..svg.tags import SVG_USE_TAG, INKSCAPE_LABEL, XLINK_HREF +from .commands import CommandsExtension class LayerCommands(CommandsExtension): COMMANDS = LAYER_COMMANDS - def ensure_current_layer(self): - # if no layer is selected, inkex defaults to the root, which isn't - # particularly useful - if self.current_layer is self.document.getroot(): - try: - self.current_layer = self.document.xpath(".//svg:g[@inkscape:groupmode='layer']", namespaces=inkex.NSS)[0] - except IndexError: - # No layers at all?? Fine, we'll stick with the default. - pass - def effect(self): commands = [command for command in self.COMMANDS if getattr(self.options, command)] diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py new file mode 100644 index 000000000..0d6629f8a --- /dev/null +++ b/lib/extensions/lettering.py @@ -0,0 +1,39 @@ +import os + +from ..i18n import _ +from ..lettering import Font +from ..svg.tags import SVG_PATH_TAG, SVG_GROUP_TAG, INKSCAPE_LABEL +from ..utils import get_bundled_dir +from .commands import CommandsExtension + + +class Lettering(CommandsExtension): + COMMANDS = ["trim"] + + def __init__(self, *args, **kwargs): + CommandsExtension.__init__(self, *args, **kwargs) + + self.OptionParser.add_option("-t", "--text") + + def effect(self): + font_path = os.path.join(get_bundled_dir("fonts"), "small_font") + font = Font(font_path) + self.ensure_current_layer() + + lines = font.render_text(self.options.text.decode('utf-8')) + self.set_labels(lines) + self.current_layer.append(lines) + + def set_labels(self, lines): + path = 1 + for node in lines.iterdescendants(): + if node.tag == SVG_PATH_TAG: + node.set("id", self.uniqueId("lettering")) + + # L10N Label for an object created by the Lettering extension + node.set(INKSCAPE_LABEL, _("Lettering %d") % path) + path += 1 + elif node.tag == SVG_GROUP_TAG: + node.set("id", self.uniqueId("letteringline")) + + # lettering extension already set the label diff --git a/lib/lettering/__init__.py b/lib/lettering/__init__.py new file mode 100644 index 000000000..c62012236 --- /dev/null +++ b/lib/lettering/__init__.py @@ -0,0 +1 @@ +from font import Font \ No newline at end of file diff --git a/lib/lettering/font.py b/lib/lettering/font.py new file mode 100644 index 000000000..4a89df479 --- /dev/null +++ b/lib/lettering/font.py @@ -0,0 +1,185 @@ +# -*- coding: UTF-8 -*- + +from copy import deepcopy +import json +import os + +import inkex + +from ..elements import nodes_to_elements +from ..i18n import _ +from ..stitches.auto_satin import auto_satin +from ..svg import PIXELS_PER_MM +from ..svg.tags import SVG_GROUP_TAG, SVG_PATH_TAG, INKSCAPE_LABEL +from ..utils import Point +from .font_variant import FontVariant + + +def font_metadata(name, default=None, multiplier=None): + def getter(self): + value = self.metadata.get(name, default) + + if multiplier is not None: + value *= multiplier + + return value + + return property(getter) + + +class Font(object): + """Represents a font with multiple variants. + + Each font may have multiple FontVariants for left-to-right, right-to-left, + etc. Each variant has a set of Glyphs, one per character. + + Properties: + path -- the path to the directory containing this font + metadata -- A dict of information about the font. + name -- Shortcut property for metadata["name"] + license -- contents of the font's LICENSE file, or None if no LICENSE file exists. + variants -- A dict of FontVariants, with keys in FontVariant.VARIANT_TYPES. + """ + + def __init__(self, font_path): + self.path = font_path + self._load_metadata() + self._load_license() + self._load_variants() + + def _load_metadata(self): + try: + with open(os.path.join(self.path, "font.json")) as metadata_file: + self.metadata = json.load(metadata_file) + except IOError: + self.metadata = {} + + def _load_license(self): + try: + with open(os.path.join(self.path, "LICENSE")) as license_file: + self.license = license_file.read() + except IOError: + self.license = None + + def _load_variants(self): + self.variants = {} + + for variant in FontVariant.VARIANT_TYPES: + try: + self.variants[variant] = FontVariant(self.path, variant, self.default_glyph) + except IOError: + # we'll deal with missing variants when we apply lettering + pass + + name = font_metadata('name', '') + description = font_metadata('description', '') + default_variant = font_metadata('default_variant', FontVariant.LEFT_TO_RIGHT) + default_glyph = font_metadata('defalt_glyph', u"�") + letter_spacing = font_metadata('letter_spacing', 1.5, multiplier=PIXELS_PER_MM) + leading = font_metadata('leading', 5, multiplier=PIXELS_PER_MM) + word_spacing = font_metadata('word_spacing', 3, multiplier=PIXELS_PER_MM) + kerning_pairs = font_metadata('kerning_pairs', {}) + auto_satin = font_metadata('auto_satin', True) + + def render_text(self, text, variant=None, back_and_forth=True): + if variant is None: + variant = self.default_variant + + if back_and_forth: + glyph_sets = [self.get_variant(variant), self.get_variant(FontVariant.reversed_variant(variant))] + else: + glyph_sets = [self.get_variant(variant)] * 2 + + line_group = inkex.etree.Element(SVG_GROUP_TAG, { + INKSCAPE_LABEL: _("Ink/Stitch Text") + }) + position = Point(0, 0) + for i, line in enumerate(text.splitlines()): + glyph_set = glyph_sets[i % 2] + line = line.strip() + + letter_group = self._render_line(line, position, glyph_set) + if glyph_set.variant == FontVariant.RIGHT_TO_LEFT: + letter_group[:] = reversed(letter_group) + line_group.append(letter_group) + + position.x = 0 + position.y += self.leading + + if self.auto_satin: + self._apply_auto_satin(line_group) + + return line_group + + def get_variant(self, variant): + return self.variants.get(variant, self.variants[self.default_variant]) + + def _render_line(self, line, position, glyph_set): + """Render a line of text. + + An SVG XML node tree will be returned, with an svg:g at its root. If + the font metadata requests it, Auto-Satin will be applied. + + Parameters: + line -- the line of text to render. + position -- Current position. Will be updated to point to the spot + immediately after the last character. + glyph_set -- a FontVariant instance. + + Returns: + An svg:g element containing the rendered text. + """ + group = inkex.etree.Element(SVG_GROUP_TAG, { + INKSCAPE_LABEL: line + }) + + last_character = None + for character in line: + if character == " ": + position.x += self.word_spacing + last_character = None + else: + glyph = glyph_set[character] or glyph_set[self.default_glyph] + + if glyph is not None: + node = self._render_glyph(glyph, position, character, last_character) + group.append(node) + + last_character = character + + return group + + def _render_glyph(self, glyph, position, character, last_character): + """Render a single glyph. + + An SVG XML node tree will be returned, with an svg:g at its root. + + Parameters: + glyph -- a Glyph instance + position -- Current position. Will be updated based on the width + of this character and the letter spacing. + character -- the current Unicode character. + last_character -- the previous character in the line, or None if + we're at the start of the line or a word. + """ + + node = deepcopy(glyph.node) + + if last_character is not None: + position.x += self.letter_spacing + self.kerning_pairs.get(last_character + character, 0) * PIXELS_PER_MM + + transform = "translate(%s, %s)" % position.as_tuple() + node.set('transform', transform) + position.x += glyph.width + + return node + + def _apply_auto_satin(self, group): + """Apply Auto-Satin to an SVG XML node tree with an svg:g at its root. + + The group's contents will be replaced with the results of the auto- + satin operation. Any nested svg:g elements will be removed. + """ + + elements = nodes_to_elements(group.iterdescendants(SVG_PATH_TAG)) + auto_satin(elements, preserve_order=True) diff --git a/lib/lettering/font_variant.py b/lib/lettering/font_variant.py new file mode 100644 index 000000000..445946e24 --- /dev/null +++ b/lib/lettering/font_variant.py @@ -0,0 +1,86 @@ +# -*- coding: UTF-8 -*- + +import os +import inkex +import simplestyle + +from ..svg.tags import INKSCAPE_GROUPMODE, INKSCAPE_LABEL +from .glyph import Glyph + + +class FontVariant(object): + """Represents a single variant of a font. + + Each font may have multiple variants for left-to-right, right-to-left, + etc. Each variant has a set of Glyphs, one per character. + + A FontVariant instance can be accessed as a dict by using a unicode + character as a key. + + Properties: + path -- the path to the directory containing this font + variant -- the font variant, specified using one of the constants below + glyphs -- a dict of Glyphs, with the glyphs' unicode characters as keys. + """ + + # We use unicode characters rather than English strings for font file names + # in order to be more approachable for languages other than English. + LEFT_TO_RIGHT = u"→" + RIGHT_TO_LEFT = u"←" + TOP_TO_BOTTOM = u"↓" + BOTTOM_TO_TOP = u"↑" + VARIANT_TYPES = (LEFT_TO_RIGHT, RIGHT_TO_LEFT, TOP_TO_BOTTOM, BOTTOM_TO_TOP) + + @classmethod + def reversed_variant(cls, variant): + if variant == cls.LEFT_TO_RIGHT: + return cls.RIGHT_TO_LEFT + elif variant == cls.RIGHT_TO_LEFT: + return cls.LEFT_TO_RIGHT + elif variant == cls.TOP_TO_BOTTOM: + return cls.BOTTOM_TO_TOP + elif variant == cls.BOTTOM_TO_TOP: + return cls.TOP_TO_BOTTOM + else: + return None + + def __init__(self, font_path, variant, default_glyph=None): + self.path = font_path + self.variant = variant + self.default_glyph = default_glyph + self.glyphs = {} + self._load_glyphs() + + def _load_glyphs(self): + svg_path = os.path.join(self.path, u"%s.svg" % self.variant) + svg = inkex.etree.parse(svg_path) + + glyph_layers = svg.xpath(".//svg:g[starts-with(@inkscape:label, 'GlyphLayer-')]", namespaces=inkex.NSS) + for layer in glyph_layers: + self._clean_group(layer) + layer.attrib[INKSCAPE_LABEL] = layer.attrib[INKSCAPE_LABEL].replace("GlyphLayer-", "", 1) + glyph_name = layer.attrib[INKSCAPE_LABEL] + self.glyphs[glyph_name] = Glyph(layer) + + def _clean_group(self, group): + # We'll repurpose the layer as a container group labelled with the + # glyph. + del group.attrib[INKSCAPE_GROUPMODE] + + style_text = group.get('style') + + if style_text: + # The layer may be marked invisible, so we'll clear the 'display' + # style. + style = simplestyle.parseStyle(group.get('style')) + style.pop('display') + group.set('style', simplestyle.formatStyle(style)) + + def __getitem__(self, character): + if character in self.glyphs: + return self.glyphs[character] + else: + return self.glyphs.get(self.default_glyph, None) + + def __contains__(self, character): + return character in self.glyphs diff --git a/lib/lettering/glyph.py b/lib/lettering/glyph.py new file mode 100644 index 000000000..bb1a971cd --- /dev/null +++ b/lib/lettering/glyph.py @@ -0,0 +1,86 @@ +from copy import copy + +import cubicsuperpath +import simpletransform + +from ..svg import apply_transforms, get_guides +from ..svg.tags import SVG_GROUP_TAG, SVG_PATH_TAG + + +class Glyph(object): + """Represents a single character in a single font variant. + + For example, the font inkstitch_small may have variants for left-to-right, + right-to-left, etc. Each variant would have a set of Glyphs, one for each + character in that variant. + + Properties: + width -- total width of this glyph including all component satins + node -- svg:g XML node containing the component satins in this character + """ + + def __init__(self, group): + """Create a Glyph. + + The nodes will be copied out of their parent SVG document (with nested + transforms applied). The original nodes will be unmodified. + + Arguments: + group -- an svg:g XML node containing all the paths that make up + this Glyph. Nested groups are allowed. + """ + + self._process_baseline(group.getroottree().getroot()) + self.node = self._process_group(group) + self._process_bbox() + self._move_to_origin() + + def _process_group(self, group): + new_group = copy(group) + new_group.attrib.pop('transform', None) + del new_group[:] # delete references to the original group's children + + for node in group: + if node.tag == SVG_GROUP_TAG: + new_group.append(self._process_group(node)) + else: + node_copy = copy(node) + + if "d" in node.attrib: + # Convert the path to absolute coordinates, incorporating all + # nested transforms. + path = cubicsuperpath.parsePath(node.get("d")) + apply_transforms(path, node) + node_copy.set("d", cubicsuperpath.formatPath(path)) + + # Delete transforms from paths and groups, since we applied + # them to the paths already. + node_copy.attrib.pop('transform', None) + + new_group.append(node_copy) + + return new_group + + def _process_baseline(self, svg): + for guide in get_guides(svg): + if guide.label == "baseline": + self._baseline = guide.position.y + break + else: + # no baseline guide found, assume 0 for lack of anything better to use... + self._baseline = 0 + + def _process_bbox(self): + left, right, top, bottom = simpletransform.computeBBox(self.node.iterdescendants()) + + self.width = right - left + self._min_x = left + + def _move_to_origin(self): + translate_x = -self._min_x + translate_y = -self._baseline + transform = "translate(%s, %s)" % (translate_x, translate_y) + + for node in self.node.iter(SVG_PATH_TAG): + node.set('transform', transform) + simpletransform.fuseTransform(node) diff --git a/lib/stitches/auto_satin.py b/lib/stitches/auto_satin.py index 59bf6b0ad..7bc3e67ce 100644 --- a/lib/stitches/auto_satin.py +++ b/lib/stitches/auto_satin.py @@ -1,4 +1,4 @@ -from itertools import chain +from itertools import chain, izip import math import cubicsuperpath @@ -9,8 +9,8 @@ import simplestyle import networkx as nx -from ..elements import Stroke -from ..svg import PIXELS_PER_MM, line_strings_to_csp +from ..elements import Stroke, SatinColumn +from ..svg import PIXELS_PER_MM, line_strings_to_csp, get_correction_transform from ..svg.tags import SVG_PATH_TAG from ..utils import Point as InkstitchPoint, cut, cache @@ -25,7 +25,7 @@ class SatinSegment(object): reverse -- if True, reverse the direction of the satin """ - def __init__(self, satin, start=0.0, end=1.0, reverse=False): + def __init__(self, satin, start=0.0, end=1.0, reverse=False, original_satin=None): """Initialize a SatinEdge. Arguments: @@ -38,6 +38,7 @@ class SatinSegment(object): """ self.satin = satin + self.original_satin = original_satin or self.satin self.reverse = reverse # start and end are stored as normalized projections @@ -74,17 +75,19 @@ class SatinSegment(object): to_element = to_satin def to_running_stitch(self): - return RunningStitch(self.center_line, self.satin) + return RunningStitch(self.center_line, self.original_satin) def break_up(self, segment_size): """Break this SatinSegment up into SatinSegments of the specified size.""" num_segments = int(math.ceil(self.center_line.length / segment_size)) segments = [] - satin = self.to_satin() for i in xrange(num_segments): - segments.append(SatinSegment(satin, float( - i) / num_segments, float(i + 1) / num_segments, self.reverse)) + segments.append(SatinSegment(self.satin, + float(i) / num_segments, + float(i + 1) / num_segments, + self.reverse, + self.original_satin)) if self.reverse: segments.reverse() @@ -121,6 +124,10 @@ class SatinSegment(object): def end_point(self): return self.satin.center_line.interpolate(self.end, normalized=True) + @property + def original_node(self): + return self.original_satin.node + def is_sequential(self, other): """Check if a satin segment immediately follows this one on the same satin.""" @@ -213,10 +220,11 @@ class RunningStitch(object): style['stroke-dasharray'] = "0.5,0.5" style = simplestyle.formatStyle(style) node.set("style", style) - node.set("embroider_running_stitch_length_mm", self.running_stitch_length) - return Stroke(node) + stroke = Stroke(node) + + return stroke @property @cache @@ -228,6 +236,10 @@ class RunningStitch(object): def end_point(self): return self.path.interpolate(1.0, normalized=True) + @property + def original_node(self): + return self.original_element.node + @cache def reversed(self): return RunningStitch(shgeo.LineString(reversed(self.path.coords)), self.style) @@ -236,6 +248,9 @@ class RunningStitch(object): if not isinstance(other, RunningStitch): return False + if self.original_element is not other.original_element: + return False + return self.path.distance(other.path) < 0.5 def __add__(self, other): @@ -277,6 +292,11 @@ def auto_satin(elements, preserve_order=False, starting_point=None, ending_point instances (that are running stitch) can be included to indicate how to travel between two SatinColumns. This works best when preserve_order is True. + If preserve_order is True, then the elements and any newly-created elements + will be in the same position in the SVG DOM. If preserve_order is False, then + the elements will be removed from the SVG DOM and it's up to the caller to + decide where to put the returned SVG path nodes. + Returns: a list of SVG path nodes making up the selected stitch order. Jumps between objects are implied if they are not right next to each other. @@ -289,7 +309,13 @@ def auto_satin(elements, preserve_order=False, starting_point=None, ending_point path = find_path(graph, starting_node, ending_node) operations = path_to_operations(graph, path) operations = collapse_sequential_segments(operations) - return operations_to_elements_and_trims(operations) + new_elements, trims, original_parents = operations_to_elements_and_trims(operations, preserve_order) + remove_original_elements(elements) + + if preserve_order: + preserve_original_groups(new_elements, original_parents) + + return new_elements, trims def build_graph(elements, preserve_order=False): @@ -306,7 +332,7 @@ def build_graph(elements, preserve_order=False): segments = [] if isinstance(element, Stroke): segments.append(RunningStitch(element)) - else: + elif isinstance(element, SatinColumn): whole_satin = SatinSegment(element) segments = whole_satin.break_up(PIXELS_PER_MM) @@ -548,26 +574,57 @@ def collapse_sequential_segments(old_operations): return new_operations -def operations_to_elements_and_trims(operations): +def operations_to_elements_and_trims(operations, preserve_order): """Convert a list of operations to Elements and locations of trims. Returns: - (nodes, trims) + (elements, trims, original_parents) - element -- a list of Element instances + elements -- a list of Element instances trims -- indices of nodes after which the thread should be trimmed + original_parents -- a parallel list of the original SVG parent nodes that spawned each element """ elements = [] trims = [] + original_parent_nodes = [] for operation in operations: - # Ignore JumpStitch opertions. Jump stitches in Ink/Stitch are + # Ignore JumpStitch operations. Jump stitches in Ink/Stitch are # implied and added by Embroider if needed. if isinstance(operation, (SatinSegment, RunningStitch)): elements.append(operation.to_element()) + original_parent_nodes.append(operation.original_node.getparent()) elif isinstance(operation, (JumpStitch)): if elements and operation.length > PIXELS_PER_MM: trims.append(len(elements) - 1) - return elements, list(set(trims)) + return elements, list(set(trims)), original_parent_nodes + + +def remove_original_elements(elements): + for element in elements: + for command in element.commands: + remove_from_parent(command.connector) + remove_from_parent(command.use) + remove_from_parent(element.node) + + +def remove_from_parent(node): + if node.getparent() is not None: + node.getparent().remove(node) + + +def preserve_original_groups(elements, original_parent_nodes): + """Ensure that all elements are contained in the original SVG group elements. + + When preserve_order is True, no SatinColumn or Stroke elements will be + reordered in the XML tree. This makes it possible to preserve original SVG + group membership. We'll ensure that each newly-created Element is added + to the group that contained the original SatinColumn that spawned it. + """ + + for element, parent in izip(elements, original_parent_nodes): + if parent is not None: + parent.append(element.node) + element.node.set('transform', get_correction_transform(parent, child=True)) diff --git a/lib/svg/__init__.py b/lib/svg/__init__.py index 74a409b61..df76c0d25 100644 --- a/lib/svg/__init__.py +++ b/lib/svg/__init__.py @@ -1,3 +1,4 @@ from .svg import color_block_to_point_lists, render_stitch_plan from .units import * from .path import apply_transforms, get_node_transform, get_correction_transform, line_strings_to_csp, point_lists_to_csp +from .guides import get_guides diff --git a/lib/svg/guides.py b/lib/svg/guides.py new file mode 100644 index 000000000..3e26a90d0 --- /dev/null +++ b/lib/svg/guides.py @@ -0,0 +1,43 @@ +import simpletransform + +from ..utils import string_to_floats, Point, cache +from .tags import SODIPODI_NAMEDVIEW, SODIPODI_GUIDE, INKSCAPE_LABEL +from .units import get_doc_size, get_viewbox_transform + + +class InkscapeGuide(object): + def __init__(self, node): + self.node = node + self.svg = node.getroottree().getroot() + + self._parse() + + def _parse(self): + self.label = self.node.get(INKSCAPE_LABEL, "") + + doc_size = list(get_doc_size(self.svg)) + + # convert the size from viewbox-relative to real-world pixels + viewbox_transform = get_viewbox_transform(self.svg) + simpletransform.applyTransformToPoint(simpletransform.invertTransform(viewbox_transform), doc_size) + + self.position = Point(*string_to_floats(self.node.get('position'))) + + # inkscape's Y axis is reversed from SVG's, and the guide is in inkscape coordinates + self.position.y = doc_size[1] - self.position.y + + # This one baffles me. I think inkscape might have gotten the order of + # their vector wrong? + parts = string_to_floats(self.node.get('orientation')) + self.direction = Point(parts[1], parts[0]) + + +@cache +def get_guides(svg): + """Find all Inkscape guides and return as InkscapeGuide instances.""" + + namedview = svg.find(SODIPODI_NAMEDVIEW) + if namedview is None: + return [] + + return [InkscapeGuide(node) for node in namedview.findall(SODIPODI_GUIDE)] diff --git a/lib/svg/path.py b/lib/svg/path.py index 6212211ff..d2b4aee1c 100644 --- a/lib/svg/path.py +++ b/lib/svg/path.py @@ -1,3 +1,4 @@ +import inkex import simpletransform from .units import get_viewbox_transform @@ -12,6 +13,19 @@ def apply_transforms(path, node): return path +def compose_parent_transforms(node, mat): + # This is adapted from Inkscape's simpletransform.py's composeParents() + # function. That one can't handle nodes that are detached from a DOM. + + trans = node.get('transform') + if trans: + mat = simpletransform.composeTransform(simpletransform.parseTransform(trans), mat) + if node.getparent() is not None: + if node.getparent().tag == inkex.addNS('g', 'svg'): + mat = compose_parent_transforms(node.getparent(), mat) + return mat + + def get_node_transform(node): # start with the identity transform transform = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] @@ -19,7 +33,7 @@ def get_node_transform(node): # this if is because sometimes inkscape likes to create paths outside of a layer?! if node.getparent() is not None: # combine this node's transform with all parent groups' transforms - transform = simpletransform.composeParents(node, transform) + transform = compose_parent_transforms(node, transform) # add in the transform implied by the viewBox viewbox_transform = get_viewbox_transform(node.getroottree().getroot()) diff --git a/lib/svg/tags.py b/lib/svg/tags.py index 7eb875402..55352be2f 100644 --- a/lib/svg/tags.py +++ b/lib/svg/tags.py @@ -14,5 +14,7 @@ CONNECTION_START = inkex.addNS('connection-start', 'inkscape') CONNECTION_END = inkex.addNS('connection-end', 'inkscape') CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape') XLINK_HREF = inkex.addNS('href', 'xlink') +SODIPODI_NAMEDVIEW = inkex.addNS('namedview', 'sodipodi') +SODIPODI_GUIDE = inkex.addNS('guide', 'sodipodi') EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG) diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py index 78d037f18..a6ae4374e 100644 --- a/lib/utils/__init__.py +++ b/lib/utils/__init__.py @@ -3,3 +3,4 @@ from cache import cache from io import * from inkscape import * from paths import * +from string import * diff --git a/lib/utils/string.py b/lib/utils/string.py new file mode 100644 index 000000000..a7839f7d3 --- /dev/null +++ b/lib/utils/string.py @@ -0,0 +1,5 @@ +def string_to_floats(string, delimiter=","): + """Convert a string of delimiter-separated floats into a list of floats.""" + + floats = string.split(delimiter) + return [float(num) for num in floats] diff --git a/templates/lettering.inx b/templates/lettering.inx new file mode 100644 index 000000000..cc34da8cf --- /dev/null +++ b/templates/lettering.inx @@ -0,0 +1,21 @@ + + + {% trans %}Lettering{% endtrans %} + org.inkstitch.lettering.{{ locale }} + inkstitch.py + inkex.py + + lettering + + all + + + {# L10N This is used for the submenu under Extensions -> Ink/Stitch. Translate this to your language's word for its language, e.g. "Español" for the spanish translation. #} + + + + + +