diff options
| author | Lex Neva <lexelby@users.noreply.github.com> | 2018-11-14 20:23:06 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-11-14 20:23:06 -0500 |
| commit | f5c85183d9c874fca806917e50992daea4101496 (patch) | |
| tree | a2450e2e37a7d94625a917240e78eadc939fd65b /lib/lettering | |
| parent | 238ad843dd658de6c7afd5b8697c0e080b1cf965 (diff) | |
basic lettering (#344)
Can handle multiple lines of text and routes the stitching in alternating directions on each line.
Diffstat (limited to 'lib/lettering')
| -rw-r--r-- | lib/lettering/__init__.py | 1 | ||||
| -rw-r--r-- | lib/lettering/font.py | 185 | ||||
| -rw-r--r-- | lib/lettering/font_variant.py | 86 | ||||
| -rw-r--r-- | lib/lettering/glyph.py | 86 |
4 files changed, 358 insertions, 0 deletions
diff --git a/lib/lettering/__init__.py b/lib/lettering/__init__.py new file mode 100644 index 00000000..c6201223 --- /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 00000000..4a89df47 --- /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 00000000..445946e2 --- /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 00000000..bb1a971c --- /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) |
