diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
commit | 012d9cb5faed22cb9b4151569d30cc08563b02d1 (patch) | |
tree | fd901b9c231aeb8afa713851f23369fa4a1af2b3 /src/silfont/scripts/psfmakefea.py | |
parent | Initial commit. (diff) | |
download | pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.tar.xz pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.zip |
Adding upstream version 1.8.0.upstream/1.8.0upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src/silfont/scripts/psfmakefea.py')
-rw-r--r-- | src/silfont/scripts/psfmakefea.py | 369 |
1 files changed, 369 insertions, 0 deletions
diff --git a/src/silfont/scripts/psfmakefea.py b/src/silfont/scripts/psfmakefea.py new file mode 100644 index 0000000..e335230 --- /dev/null +++ b/src/silfont/scripts/psfmakefea.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +__doc__ = 'Make features.fea file' +# TODO: add conditional compilation, compare to fea, compile to ttf +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Martin Hosken, Alan Ward' + +import silfont.ufo as ufo +from collections import OrderedDict +from silfont.feax_parser import feaplus_parser +from xml.etree import ElementTree as et +import re + +from silfont.core import execute + +def getbbox(g): + res = (65536, 65536, -65536, -65536) + if g['outline'] is None: + return (0, 0, 0, 0) + for c in g['outline'].contours: + for p in c['point']: + if 'type' in p.attrib: # any actual point counts + x = float(p.get('x', '0')) + y = float(p.get('y', '0')) + res = (min(x, res[0]), min(y, res[1]), max(x, res[2]), max(y, res[3])) + return res + +class Glyph(object) : + def __init__(self, name, advance=0, bbox=None) : + self.name = name + self.anchors = {} + self.is_mark = False + self.advance = int(float(advance)) + self.bbox = bbox or (0, 0, 0, 0) + + def add_anchor(self, info) : + self.anchors[info['name']] = (int(float(info['x'])), int(float(info['y']))) + + def decide_if_mark(self) : + for a in self.anchors.keys() : + if a.startswith("_") : + self.is_mark = True + break + +def decode_element(e): + '''Convert plist element into python structures''' + res = None + if e.tag == 'string': + return e.text + elif e.tag == 'integer': + return int(e.text) + elif e.tag== 'real': + return float(e.text) + elif e.tag == 'array': + res = [decode_element(x) for x in e] + elif e.tag == 'dict': + res = {} + for p in zip(e[::2], e[1::2]): + res[p[0].text] = decode_element(p[1]) + return res + +class Font(object) : + def __init__(self, defines = None): + self.glyphs = OrderedDict() + self.classes = OrderedDict() + self.all_aps = OrderedDict() + self.fontinfo = {} + self.kerns = {} + self.defines = {} if defines is None else defines + + def readaps(self, filename, omitaps='', params = None) : + omittedaps = set(omitaps.replace(',',' ').split()) # allow comma- and/or space-separated list + if filename.endswith('.ufo') : + f = ufo.Ufont(filename, params = params) + self.fontinfo = {} + for k, v in f.fontinfo._contents.items(): + self.fontinfo[k] = decode_element(v[1]) + skipglyphs = set(f.lib.getval('public.skipExportGlyphs', [])) + for g in f.deflayer : + if g in skipglyphs: + continue + ufo_g = f.deflayer[g] + advb = ufo_g['advance'] + adv = advb.width if advb is not None and advb.width is not None else 0 + bbox = getbbox(ufo_g) + glyph = Glyph(g, advance=adv, bbox=bbox) + self.glyphs[g] = glyph + if 'anchor' in ufo_g._contents : + for a in ufo_g._contents['anchor'] : + if a.element.attrib['name'] not in omittedaps: + glyph.add_anchor(a.element.attrib) + self.all_aps.setdefault(a.element.attrib['name'], []).append(glyph) + if hasattr(f, 'groups'): + for k, v in f.groups._contents.items(): + self.classes[k.lstrip('@')] = decode_element(v[1]) + if hasattr(f, 'kerning'): + for k, v in f.kerning._contents.items(): + key = k.lstrip('@') + if key in self.classes: + key = "@" + key + subelements = decode_element(v[1]) + kerndict = {} + for s, n in subelements.items(): + skey = s.lstrip('@') + if skey in self.classes: + skey = "@" + skey + kerndict[skey] = n + self.kerns[key] = kerndict + elif filename.endswith('.xml') : + currGlyph = None + currPoint = None + self.fontinfo = {} + for event, elem in et.iterparse(filename, events=('start', 'end')): + if event == 'start': + if elem.tag == 'glyph': + name = elem.get('PSName', '') + if name: + currGlyph = Glyph(name) + self.glyphs[name] = currGlyph + currPoint = None + elif elem.tag == 'point': + currPoint = {'name' : elem.get('type', '')} + elif elem.tag == 'location' and currPoint is not None: + currPoint['x'] = int(elem.get('x', 0)) + currPoint['y'] = int(elem.get('y', 0)) + elif elem.tag == 'font': + n = elem.get('name', '') + x = n.split('-') + if len(x) == 2: + self.fontinfo['familyName'] = x[0] + self.fontinfo['openTypeNamePreferredFamilyName'] = x[0] + self.fontinfo['styleMapFamilyName'] = x[0] + self.fontinfo['styleName'] = x[1] + self.fontinfo['openTypeNamePreferredSubfamilyName'] = x[1] + self.fontinfo['postscriptFullName'] = "{0} {1}".format(*x) + self.fontinfo['postscriptFontName'] = n + elif event == 'end': + if elem.tag == 'point': + if currGlyph and currPoint['name'] not in omittedaps: + currGlyph.add_anchor(currPoint) + self.all_aps.setdefault(currPoint['name'], []).append(currGlyph) + currPoint = None + elif elem.tag == 'glyph': + currGlyph = None + + def read_classes(self, fname, classproperties=False): + doc = et.parse(fname) + for c in doc.findall('.//class'): + class_name = c.get('name') + m = re.search('\[(\d+)\]$', class_name) + # support fixedclasses like make_gdl.pl via AP.pm + if m: + class_nm = class_name[0:m.start()] + ix = int(m.group(1)) + else: + class_nm = class_name + ix = None + cl = self.classes.setdefault(class_nm, []) + for e in c.get('exts', '').split() + [""]: + for g in c.text.split(): + if g+e in self.glyphs or (e == '' and g.startswith('@')): + if ix: + cl.insert(ix, g+e) + else: + cl.append(g+e) + if not classproperties: + return + for c in doc.findall('.//property'): + for e in c.get('exts', '').split() + [""]: + for g in c.text.split(): + if g+e in self.glyphs: + cname = c.get('name') + "_" + c.get('value') + self.classes.setdefault(cname, []).append(g+e) + + def make_classes(self, ligmode) : + for name, g in self.glyphs.items() : + # pull off suffix and make classes + # TODO: handle ligatures + base = name + if ligmode is None or 'comp' not in ligmode or "_" not in name: + pos = base.rfind('.') + while pos > 0 : + old_base = base + ext = base[pos+1:] + base = base[:pos] + ext_class_nm = "c_" + ext + if base in self.glyphs and old_base in self.glyphs: + glyph_lst = self.classes.setdefault(ext_class_nm, []) + if not old_base in glyph_lst: + glyph_lst.append(old_base) + self.classes.setdefault("cno_" + ext, []).append(base) + pos = base.rfind('.') + if ligmode is not None and "_" in name: + comps = name.split("_") + if "comp" in ligmode or "." not in comps[-1]: + base = comps.pop(-1 if "last" in ligmode else 0) + cname = base.replace(".", "_") + noname = "_".join(comps) + if base in self.glyphs and noname in self.glyphs: + glyph_lst = self.classes.setdefault("clig_"+cname, []) + if name not in glyph_lst: + glyph_lst.append(name) + self.classes.setdefault("cligno_"+cname, []).append(noname) + if g.is_mark : + self.classes.setdefault('GDEF_marks', []).append(name) + else : + self.classes.setdefault('GDEF_bases', []).append(name) + + def make_marks(self) : + for name, g in self.glyphs.items() : + g.decide_if_mark() + + def order_classes(self): + # return ordered list of classnames as desired for FEA + + # Start with alphabetical then correct: + # 1. Put classes like "cno_whatever" adjacent to "c_whatever" + # 2. Classes can be defined in terms of other classes but FEA requires that + # classes be defined before they can be referenced. + + def sortkey(x): + key1 = 'c_' + x[4:] if x.startswith('cno_') else x + return (key1, x) + + classes = sorted(self.classes.keys(), key=sortkey) + links = {} # key = classname; value = list of other classes that include this one + counts = {} # key = classname; value = count of un-output classes that this class includes + for name in classes: + y = [c[1:] for c in self.classes[name] if c.startswith('@')] #list of included classes + counts[name] = len(y) + for c in y: + links.setdefault(c, []).append(name) + + outclasses = [] + while len(classes) > 0: + foundone = False + for name in classes: + if counts[name] == 0: + foundone = True + # output this class + outclasses.append(name) + classes.remove(name) + # adjust counts of classes that include this one + if name in links: + for n in links[name]: + counts[n] -= 1 + # It may now be possible to output some we skipped earlier, + # so start over from the beginning of the list + break + if not foundone: + # all remaining classes include un-output classes and thus there is a loop somewhere + raise ValueError("Class reference loop(s) found: " + ", ".join(classes)) + return outclasses + + def addComment(self, parser, text): + cmt = parser.ast.Comment("# " + text, location=None) + cmt.pretext = "\n" + parser.add_statement(cmt) + + def append_classes(self, parser) : + # normal glyph classes + self.addComment(parser, "Main Classes") + for name in self.order_classes(): + gc = parser.ast.GlyphClass(None, location=None) + for g in self.classes[name] : + gc.append(g) + gcd = parser.ast.GlyphClassDefinition(name, gc, location=None) + parser.add_statement(gcd) + parser.define_glyphclass(name, gcd) + + def _addGlyphsToClass(self, parser, glyphs, gc, anchor, definer): + if len(glyphs) > 1 : + val = parser.ast.GlyphClass(glyphs, location=None) + else : + val = parser.ast.GlyphName(glyphs[0], location=None) + classdef = definer(gc, anchor, val, location=None) + gc.addDefinition(classdef) + parser.add_statement(classdef) + + def append_positions(self, parser): + # create base and mark classes, add to fea file dicts and parser symbol table + bclassdef_lst = [] + mclassdef_lst = [] + self.addComment(parser, "Positioning classes and statements") + for ap_nm, glyphs_w_ap in self.all_aps.items() : + self.addComment(parser, "AP: " + ap_nm) + # e.g. all glyphs with U AP + if not ap_nm.startswith("_"): + if any(not x.is_mark for x in glyphs_w_ap): + gcb = parser.set_baseclass(ap_nm) + parser.add_statement(gcb) + if any(x.is_mark for x in glyphs_w_ap): + gcm = parser.set_baseclass(ap_nm + "_MarkBase") + parser.add_statement(gcm) + else: + gc = parser.set_markclass(ap_nm) + + # create lists of glyphs that use the same point (name and coordinates) + # that can share a class definition + anchor_cache = OrderedDict() + markanchor_cache = OrderedDict() + for g in glyphs_w_ap : + p = g.anchors[ap_nm] + if g.is_mark and not ap_nm.startswith("_"): + markanchor_cache.setdefault(p, []).append(g.name) + else: + anchor_cache.setdefault(p, []).append(g.name) + + if ap_nm.startswith("_"): + for p, glyphs_w_pt in anchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gc, anchor, parser.ast.MarkClassDefinition) + else: + for p, glyphs_w_pt in anchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gcb, anchor, parser.ast.BaseClassDefinition) + for p, glyphs_w_pt in markanchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gcm, anchor, parser.ast.BaseClassDefinition) + +#TODO: provide more argument info +argspec = [ + ('infile', {'nargs': '?', 'help': 'Input UFO or file'}, {'def': None, 'type': 'filename'}), + ('-i', '--input', {'required': 'True', 'help': 'Fea file to merge'}, {}), + ('-o', '--output', {'help': 'Output fea file'}, {}), + ('-c', '--classfile', {'help': 'Classes file'}, {}), + ('-L', '--ligmode', {'help': 'Parse ligatures: last - use last element as class name, first - use first element as class name, lastcomp, firstcomp - final variants are part of the component not the whole ligature'}, {}), + ('-D', '--define', {'action': 'append', 'help': 'Add option definition to pass to fea code --define=var=val'}, {}), + # ('--debug', {'help': 'Drop into pdb', 'action': 'store_true'}, {}), + ('--classprops', {'help': 'Include property elements from classes file', 'action': 'store_true'}, {}), + ('--omitaps', {'help': 'names of attachment points to omit (comma- or space-separated)', 'default': '', 'action': 'store'}, {}) +] + +def doit(args) : + defines = dict(x.split('=') for x in args.define) if args.define else {} + font = Font(defines) + # if args.debug: + # import pdb; pdb.set_trace() + if "checkfix" not in args.params: + args.paramsobj.sets["main"]["checkfix"] = "None" + if args.infile is not None: + font.readaps(args.infile, args.omitaps, args.paramsobj) + + font.make_marks() + font.make_classes(args.ligmode) + if args.classfile: + font.read_classes(args.classfile, classproperties = args.classprops) + + p = feaplus_parser(None, font.glyphs, font.fontinfo, font.kerns, font.defines) + doc_ufo = p.parse() # returns an empty ast.FeatureFile + + # Add goodies from the font + font.append_classes(p) + font.append_positions(p) + + # parse the input fea file + if args.input : + doc_fea = p.parse(args.input) + else: + doc_fea = doc_ufo + + # output as doc.asFea() + if args.output : + with open(args.output, "w") as of : + of.write(doc_fea.asFea()) + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() |