import os import sys import subprocess import fontTools.pens.basePen import fontTools.ttLib import fontTools.t1Lib from fontTools.pens.boundsPen import ControlBoundsPen import enum import math from xml.etree import ElementTree from ..nvector import NVector from ..objects.bezier import Bezier, BezierPoint from ..objects.shapes import Path, Group, Fill, Stroke from ..objects.text import TextJustify from ..objects.base import LottieProp, CustomObject from ..objects.layers import ShapeLayer class BezierPen(fontTools.pens.basePen.BasePen): def __init__(self, glyphSet, offset=NVector(0, 0)): super().__init__(glyphSet) self.beziers = [] self.current = Bezier() self.offset = offset def _point(self, pt): return self.offset + NVector(pt[0], -pt[1]) def _moveTo(self, pt): self._endPath() def _endPath(self): if len(self.current.points): self.beziers.append(self.current) self.current = Bezier() def _closePath(self): self.current.close() self._endPath() def _lineTo(self, pt): if len(self.current.points) == 0: self.current.points.append(self._point(self._getCurrentPoint())) self.current.points.append(self._point(pt)) def _curveToOne(self, pt1, pt2, pt3): if len(self.current.points) == 0: cp = self._point(self._getCurrentPoint()) self.current.points.append( BezierPoint( cp, None, self._point(pt1) - cp ) ) else: self.current.points[-1].out_tangent = self._point(pt1) - self.current.points[-1].vertex dest = self._point(pt3) self.current.points.append( BezierPoint( dest, self._point(pt2) - dest, None, ) ) class SystemFont: def __init__(self, family): self.family = family self.files = {} self.styles = set() self._renderers = {} def add_file(self, styles, file): self.styles |= set(styles) key = self._key(styles) self.files.setdefault(key, file) def filename(self, styles): return self.files[self._key(styles)] def _key(self, styles): if isinstance(styles, str): return (styles,) return tuple(sorted(styles)) def __getitem__(self, styles): key = self._key(styles) if key in self._renderers: return self._renderers[key] fr = RawFontRenderer(self.files[key]) self._renderers[key] = fr return fr def __repr__(self): return "" % self.family class FontQuery: """! @see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN21 https://manpages.ubuntu.com/manpages/cosmic/man1/fc-pattern.1.html """ def __init__(self, str=""): self._query = {} if isinstance(str, FontQuery): self._query = str._query.copy() elif str: chunks = str.split(":") family = chunks.pop(0) self._query = dict( chunk.split("=") for chunk in chunks if chunk ) self.family(family) def family(self, name): self._query["family"] = name return self def weight(self, weight): self._query["weight"] = weight return self def css_weight(self, weight): """! Weight from CSS weight value. Weight is different between CSS and fontconfig This creates some interpolations to ensure known values are translated properly @see https://www.freedesktop.org/software/fontconfig/fontconfig-user.html#AEN178 https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight#Common_weight_name_mapping """ if weight < 200: v = max(0, weight - 100) / 100 * 40 elif weight < 500: v = -weight**3 / 200000 + weight**2 * 11/2000 - weight * 17/10 + 200 elif weight < 700: v = -weight**2 * 3/1000 + weight * 41/10 - 1200 else: v = (weight - 700) / 200 * 10 + 200 return self.weight(int(round(v))) def style(self, *styles): self._query["style"] = " ".join(styles) return self def charset(self, *hex_ranges): self._query["charset"] = " ".join(hex_ranges) return self def char(self, char): return self.charset("%x" % ord(char)) def custom(self, property, value): self._query[property] = value return self def clone(self): return FontQuery(self) def __getitem__(self, key): return self._query.get(key, "") def __contains__(self, item): return item in self._query def get(self, key, default=None): return self._query.get(key, default) def __str__(self): return self._query.get("family", "") + ":" + ":".join( "%s=%s" % (p, v) for p, v in self._query.items() if p != "family" ) def __repr__(self): return "" % str(self) def weight_to_css(self): x = int(self["weight"]) if x < 40: v = x / 40 * 100 + 100 elif x < 100: v = x**3/300 - x**2 * 11/15 + x*167/3 - 3200/3 elif x < 200: v = (2050 - 10 * math.sqrt(5) * math.sqrt(1205 - 6 * x)) / 3 else: v = (x - 200) * 200 / 10 + 700 return int(round(v)) class _SystemFontList: def __init__(self): self.fonts = None def _lazy_load(self): if self.fonts is None: self.load() def load(self): self.fonts = {} self.load_fc_list() def cmd(self, *a): p = subprocess.Popen(a, stdout=subprocess.PIPE) out, err = p.communicate() out = out.decode("utf-8").strip() return out, p.returncode def load_fc_list(self): out, returncode = self.cmd("fc-list", r'--format=%{file}\t%{family[0]}\t%{style[0]}\n') if returncode == 0: for line in out.splitlines(): file, family, styles = line.split("\t") self._get(family).add_file(styles.split(" "), file) def best(self, query): """! Returns the renderer best matching the name """ out, returncode = self.cmd("fc-match", r"--format=%{family}\t%{style}", str(query)) if returncode == 0: return self._font_from_match(out) def _font_from_match(self, out): fam, style = out.split("\t") fam = fam.split(",")[0] style = style.split(",")[0].split() return self[fam][style] def all(self, query): """! Yields all the renderers matching a query """ out, returncode = self.cmd("fc-match", "-s", r"--format=%{family}\t%{style}\n", str(query)) if returncode == 0: for line in out.splitlines(): try: yield self._font_from_match(line) except (fontTools.ttLib.TTLibError, fontTools.t1Lib.T1Error): pass def default(self): """! Returns the default fornt renderer """ return self.best() def _get(self, family): self._lazy_load() if family in self.fonts: return self.fonts[family] font = SystemFont(family) self.fonts[family] = font return font def __getitem__(self, key): self._lazy_load() return self.fonts[key] def __iter__(self): self._lazy_load() return iter(self.fonts.values()) def keys(self): self._lazy_load() return self.fonts.keys() def __contains__(self, item): self._lazy_load() return item in self.fonts ## Dictionary of system fonts fonts = _SystemFontList() def collect_kerning_pairs(font): if "GPOS" not in font: return {} gpos_table = font["GPOS"].table unique_kern_lookups = set() for item in gpos_table.FeatureList.FeatureRecord: if item.FeatureTag == "kern": feature = item.Feature unique_kern_lookups |= set(feature.LookupListIndex) kerning_pairs = {} for kern_lookup_index in sorted(unique_kern_lookups): lookup = gpos_table.LookupList.Lookup[kern_lookup_index] if lookup.LookupType in {2, 9}: for pairPos in lookup.SubTable: if pairPos.LookupType == 9: # extension table if pairPos.ExtensionLookupType == 8: # contextual continue elif pairPos.ExtensionLookupType == 2: pairPos = pairPos.ExtSubTable if pairPos.Format != 1: continue firstGlyphsList = pairPos.Coverage.glyphs for ps_index, _ in enumerate(pairPos.PairSet): for pairValueRecordItem in pairPos.PairSet[ps_index].PairValueRecord: secondGlyph = pairValueRecordItem.SecondGlyph valueFormat = pairPos.ValueFormat1 if valueFormat == 5: # RTL kerning kernValue = "<%d 0 %d 0>" % ( pairValueRecordItem.Value1.XPlacement, pairValueRecordItem.Value1.XAdvance) elif valueFormat == 0: # RTL pair with value <0 0 0 0> kernValue = "<0 0 0 0>" elif valueFormat == 4: # LTR kerning kernValue = pairValueRecordItem.Value1.XAdvance else: print( "\tValueFormat1 = %d" % valueFormat, file=sys.stdout) continue # skip the rest kerning_pairs[(firstGlyphsList[ps_index], secondGlyph)] = kernValue return kerning_pairs class GlyphMetrics: def __init__(self, glyph, lsb, aw, xmin, xmax): self.glyph = glyph self.lsb = lsb self.advance = aw self.xmin = xmin self.xmax = xmax self.width = xmax - xmin self.advance = xmax def draw(self, pen): return self.glyph.draw(pen) class Font: def __init__(self, wrapped): self.wrapped = wrapped if isinstance(self.wrapped, fontTools.ttLib.TTFont): self.cmap = self.wrapped.getBestCmap() or {} else: self.cmap = {} self.glyphset = self.wrapped.getGlyphSet() @classmethod def open(cls, filename): try: f = fontTools.ttLib.TTFont(filename) except fontTools.ttLib.TTLibError: f = fontTools.t1Lib.T1Font(filename) f.parse() return cls(f) def getGlyphSet(self): return self.wrapped.getGlyphSet() def getBestCmap(self): return {} def glyph_name(self, codepoint): if isinstance(codepoint, str): if len(codepoint) != 1: return "" codepoint = ord(codepoint) if codepoint in self.cmap: return self.cmap[codepoint] return self.calculated_glyph_name(codepoint) @staticmethod def calculated_glyph_name(codepoint): from fontTools import agl # Adobe Glyph List if codepoint in agl.UV2AGL: return agl.UV2AGL[codepoint] elif codepoint <= 0xFFFF: return "uni%04X" % codepoint else: return "u%X" % codepoint def scale(self): if isinstance(self.wrapped, fontTools.ttLib.TTFont): return 1 / self.wrapped["head"].unitsPerEm elif isinstance(self.wrapped, fontTools.t1Lib.T1Font): return self.wrapped["FontMatrix"][0] def yMax(self): if isinstance(self.wrapped, fontTools.ttLib.TTFont): return self.wrapped["head"].yMax elif isinstance(self.wrapped, fontTools.t1Lib.T1Font): return self.wrapped["FontBBox"][3] def glyph(self, glyph_name): if isinstance(self.wrapped, fontTools.ttLib.TTFont): glyph = self.glyphset[glyph_name] xmin = getattr(glyph._glyph, "xMin", glyph.lsb) xmax = getattr(glyph._glyph, "xMax", glyph.width) return GlyphMetrics(glyph, glyph.lsb, glyph.width, xmin, xmax) elif isinstance(self.wrapped, fontTools.t1Lib.T1Font): glyph = self.glyphset[glyph_name] bounds_pen = ControlBoundsPen(self.glyphset) bounds = bounds_pen.bounds glyph.draw(bounds_pen) if not hasattr(glyph, "width"): advance = bounds[2] else: advance = glyph.width return GlyphMetrics(glyph, bounds[0], advance, bounds[0], bounds[2]) def __contains__(self, key): if isinstance(self.wrapped, fontTools.t1Lib.T1Font): return key in self.wrapped.font return key in self.wrapped def __getitem__(self, key): return self.wrapped[key] class FontRenderer: tab_width = 4 @property def font(self): raise NotImplementedError def get_query(self): raise NotImplementedError def kerning(self, c1, c2): return 0 def text_to_chars(self, text): return text def _on_missing(self, char, size, pos, group): """! - Character as string - Font size - [in, out] Character position - Group shape """ def glyph_name(self, ch): return self.font.glyph_name(ch) def scale(self, size): return size * self.font.scale() def line_height(self, size): return self.font.yMax() * self.scale(size) def ex(self, size): return self.font.glyph("x").advance * self.scale(size) def glyph_beziers(self, glyph, offset=NVector(0, 0)): pen = BezierPen(self.font.glyphset, offset) glyph.draw(pen) return pen.beziers def glyph_shapes(self, glyph, offset=NVector(0, 0)): beziers = self.glyph_beziers(glyph, offset) return [ Path(bez) for bez in beziers ] def _on_character(self, ch, size, pos, scale, line, use_kerning, chars, i): chname = self.glyph_name(ch) if chname in self.font.glyphset: glyphdata = self.font.glyph(chname) #pos.x += glyphdata.lsb * scale glyph_shapes = self.glyph_shapes(glyphdata, pos / scale) if glyph_shapes: if len(glyph_shapes) > 1: glyph_shape_group = line.add_shape(Group()) glyph_shape = glyph_shape_group else: glyph_shape_group = line glyph_shape = glyph_shapes[0] for sh in glyph_shapes: sh.shape.value.scale(scale) glyph_shape_group.add_shape(sh) glyph_shape.name = ch kerning = 0 if use_kerning and i < len(chars) - 1: nextcname = chars[i+1] kerning = self.kerning(chname, nextcname) pos.x += (glyphdata.advance + kerning) * scale return True return False def render(self, text, size, pos=None, use_kerning=True): """! Renders some text @param text String to render @param size Font size (in pizels) @param[in,out] pos Text position @param use_kerning Whether to honour kerning info from the font file @returns a Group shape, augmented with some extra attributes: - line_height Line height - next_x X position of the next character """ scale = self.scale(size) line_height = self.line_height(size) group = Group() group.name = text if pos is None: pos = NVector(0, 0) start_x = pos.x line = Group() group.add_shape(line) #group.transform.scale.value = NVector(100, 100) * scale chars = self.text_to_chars(text) for i, ch in enumerate(chars): if ch == "\n": line.next_x = pos.x pos.x = start_x pos.y += line_height line = Group() group.add_shape(line) continue elif ch == "\t": chname = self.glyph_name(ch) if chname in self.font.glyphset: width = self.font.glyph(chname).advance else: width = self.ex(size) pos.x += width * scale * self.tab_width continue self._on_character(ch, size, pos, scale, line, use_kerning, chars, i) group.line_height = line_height group.next_x = line.next_x = pos.x return group class RawFontRenderer(FontRenderer): def __init__(self, filename): self.filename = filename self._font = Font.open(filename) self._kerning = None @property def font(self): return self._font def kerning(self, c1, c2): if self._kerning is None: self._kerning = collect_kerning_pairs(self.font) return self._kerning.get((c1, c2), 0) def __repr__(self): return "" % self.filename def get_query(self): return self.filename class FallbackFontRenderer(FontRenderer): def __init__(self, query, max_attempts=10): self.query = FontQuery(query) self._best = None self._bq = None self._fallback = {} self.max_attempts = max_attempts @property def font(self): return self.best.font def get_query(self): return self.query def ex(self, size): best = self.best if "x" not in self.font.glyphset: best = fonts.best(self.query.clone().char("x")) return best.ex(size) @property def best(self): cq = str(self.query) if self._best is None or self._bq != cq: self._best = fonts.best(self.query) self._bq = cq return self._best def fallback_renderer(self, char): if char in self._fallback: return self._fallback[char] if len(char) != 1: return None codepoint = ord(char) name = Font.calculated_glyph_name(codepoint) for i, font in enumerate(fonts.all(self.query.clone().char(char))): # For some reason fontconfig sometimes returns a font that doesn't # actually contain the glyph if name in font.font.glyphset or codepoint in font.cmap: self._fallback[char] = font return font if i > self.max_attempts: self._fallback[char] = None return None def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i): if self.best._on_character(char, size, pos, scale, group, use_kerning, chars, i): return True font = self.fallback_renderer(char) if not font: return False child = font.render(char, size, pos) if len(child.shapes) == 2: group.add_shape(child.shapes[0]) else: group.add_shape(child) def __repr__(self): return "" % self.query class EmojiRenderer(FontRenderer): _split = None def __init__(self, wrapped, emoji_dir): if not os.path.isdir(emoji_dir): raise Exception("Not a valid directory: %s" % emoji_dir) self.wrapped = wrapped self.emoji_dir = emoji_dir self._svgs = {} @property def font(self): return self.wrapped.font def _get_svg(self, char): from ..parsers.svg import parse_svg_file if char in self._svgs: return self._svgs[char] basename = "-".join("%x" % ord(cp) for cp in char) + ".svg" filename = os.path.join(self.emoji_dir, basename) if not os.path.isfile(filename): self._svgs[char] = None return None svga = parse_svg_file(filename) svgshape = Group() svgshape.name = basename for layer in svga.layers: if isinstance(layer, ShapeLayer): for shape in layer.shapes: svgshape.add_shape(shape) self._svgs[char] = svgshape svgshape._bbox = svgshape.bounding_box() return svgshape def _on_character(self, char, size, pos, scale, group, use_kerning, chars, i): svgshape = self._get_svg(char) if svgshape: target_height = self.line_height(size) scale = target_height / svgshape._bbox.height shape_group = Group() shape_group = svgshape.clone() shape_group.transform.scale.value *= scale offset = NVector( -svgshape._bbox.x1 + svgshape._bbox.width * 0.075, -svgshape._bbox.y2 + svgshape._bbox.height * 0.1 ) shape_group.transform.position.value = pos + offset * scale group.add_shape(shape_group) pos.x += svgshape._bbox.width * scale return True return self.wrapped._on_character(char, size, pos, scale, group, use_kerning, chars, i) def get_query(self): return self.wrapped.get_query() @staticmethod def _get_splitter(): if EmojiRenderer._split is None: try: import grapheme EmojiRenderer._split = grapheme.graphemes except ImportError: sys.stderr.write("Install `grapheme` for better Emoji support\n") EmojiRenderer._split = lambda x: x return EmojiRenderer._split @staticmethod def emoji_split(string): return EmojiRenderer._get_splitter()(string) def text_to_chars(self, string): return list(self.emoji_split(string)) class FontStyle: def __init__(self, query, size, justify=TextJustify.Left, position=None, use_kerning=True, emoji_svg=None): self.emoji_svg = emoji_svg self._set_query(query) self.size = size self.justify = justify self.position = position.clone() if position else NVector(0, 0) self.use_kerning = use_kerning def _set_query(self, query): if isinstance(query, str) and os.path.isfile(query): self._renderer = RawFontRenderer(query) else: self._renderer = FallbackFontRenderer(query) if self.emoji_svg: self._renderer = EmojiRenderer(self._renderer, self.emoji_svg) @property def query(self): return self._renderer.get_query() @query.setter def query(self, value): if str(value) != str(self.query): self._set_query(value) @property def renderer(self): return self._renderer def render(self, text, pos=NVector(0, 0)): group = self._renderer.render(text, self.size, self.position+pos, self.use_kerning) for subg in group.shapes[:-1]: width = subg.next_x - self.position.x - pos.x if self.justify == TextJustify.Center: subg.transform.position.value.x -= width / 2 elif self.justify == TextJustify.Right: subg.transform.position.value.x -= width return group def clone(self): return FontStyle(str(self._renderer.query), self.size, self.justify, NVector(*self.position), self.use_kerning) @property def ex(self): return self._renderer.ex(self.size) @property def line_height(self): return self._renderer.line_height(self.size) def _propfac(a): return property(lambda s: s._get(a), lambda s, v: s._set(a, v)) class FontShape(CustomObject): _props = [ LottieProp("query_string", "_query", str), LottieProp("size", "_size", float), LottieProp("justify", "_justify", TextJustify), LottieProp("text", "_text", str), LottieProp("position", "_position", NVector), ] wrapped_lottie = Group def __init__(self, text="", query="", size=64, justify=TextJustify.Left): CustomObject.__init__(self) if isinstance(query, FontStyle): self.style = query else: self.style = FontStyle(query, size, justify) self.text = text self.hidden = None def _get(self, a): return getattr(self.style, a) def _set(self, a, v): return setattr(self.style, a, v) query = _propfac("query") size = _propfac("size") justify = _propfac("justify") position = _propfac("position") @property def query_string(self): return str(self.query) @query_string.setter def query_string(self, v): self.query = v def _build_wrapped(self): g = self.style.render(self.text) self.line_height = g.line_height return g def bounding_box(self, time=0): return self.wrapped.bounding_box(time)