#! /usr/bin/python3 '''Build fonts for all combinations of TypeTuner features needed for specific ftml then build html that uses those fonts''' __url__ = 'http://github.com/silnrsi/pysilfont' __copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' __license__ = 'Released under the MIT License (http://opensource.org/licenses/MIT)' __author__ = 'Bob Hallissy' from silfont.core import execute from fontTools import ttLib from lxml import etree as ET # using this because it supports xslt and HTML from collections import OrderedDict from subprocess import check_output, CalledProcessError import os, re import gzip from glob import glob argspec = [ ('ttfont', {'help': 'Input Tunable TTF file'}, {'type': 'filename'}), ('map', {'help': 'Feature mapping CSV file'}, {'type': 'incsv'}), ('-o', '--outputdir', {'help': 'Output directory. Default: tests/typetuner', 'default': 'tests/typetuner'}, {}), ('--ftml', {'help': 'ftml file(s) to process. Can be used multiple times and can contain filename patterns.', 'action': 'append'}, {}), ('--xsl', {'help': 'standard xsl file. Default: ../tools/ftml.xsl', 'default': '../tools/ftml.xsl'}, {'type': 'filename'}), ('--norebuild', {'help': 'assume existing fonts are good', 'action': 'store_true'}, {}), ] # Define globals needed everywhere: logger = None sourcettf = None outputdir = None fontdir = None # Dictionary of TypeTuner features, derived from 'feat_all.xml', indexed by feature name feat_all = dict() class feat(object): 'TypeTuner feature' def __init__(self, elem, sortkey): self.name = elem.attrib.get('name') self.tag = elem.attrib.get('tag') self.default = elem.attrib.get('value') self.values = OrderedDict() self.sortkey = sortkey for v in elem.findall('value'): # Only add those values which aren't importing line metrics if v.find("./cmd[@name='line_metrics_scaled']") is None: self.values[v.attrib.get('name')] = v.attrib.get('tag') # Dictionaries of mappings from OpenType tags to TypeTuner names, derived from map csv feat_maps = dict() lang_maps = dict() class feat_map(object): 'mapping from OpenType feature tag to TypeTuner feature name, default value, and all values' def __init__(self, r): self.ottag, self.ttfeature, self.default = r[0:3] self.ttvalues = r[3:] class lang_map(object): 'mapping from OpenType language tag to TypeTuner language feature name and value' def __init__(self,r): self.ottag, self.ttfeature, self.ttvalue = r # About font_tag values # # In this code, a font_tag uniquely identifies a font we've built. # # Because different ftml files could use different style names for the same set of features and language, and we # want to build only one font for any given combination of features and language, we don't depend on the name of the # ftml style for identifying and caching the fonts we build. Rather we build a font_tag which is a the # concatenation of the ftml feature/value tags and the ftml lang feature/value tag. # Font object used to cache information about a tuned font we've created class font(object): 'Cache of tuned font information' def __init__(self, font_tag, feats, lang, fontface): self.font_tag = font_tag self.feats = feats self.lang = lang self.fontface = fontface # Dictionaries for finding font objects # Finding font from font_tag: font_tag2font = dict() # If an ftml style contains no feats, only the lang tag will show up in the html. Special mapping for those cases: lang2font = dict() # RE to match strings like: # "'cv02' 1" feature_settingRE = re.compile(r"^'(\w{4,4})'(?:\s+(\w+))?$") # RE to split strings of multiple features around comma (with optional whitespace) features_splitRE = re.compile(r"\s*,\s*") def cache_font(feats, lang, norebuild): 'Create (and cache) a TypeTuned font and @fontface for this combination of features and lang' # feats is either None or a css font-feature-settings string using single quotes (according to ftml spec), e.g. "'cv02' 1, 'cv60' 1" # lang is either None or bcp47 langtag # norebuild is a debugging aid that causes the code to skip building a .ttf if it is already present thus making the # program run faster but with the risk that the built TTFs don't match the current build. # First step is to construct a name for this set of languages and features, something we'll call the "font tag" parts = [] ttsettings = dict() # place to save TT setting name and value in case we need to build the font fatal_errors = False if feats: # Need to split the font-feature-settings around commas and parse each part, mapping css tag and value to # typetuner tag and value for setting in features_splitRE.split(feats): m = feature_settingRE.match(setting) if m is None: logger.log('Invalid CSS feature setting in ftml: {}'.format(setting), 'E') fatal_errors = True continue f,v = m.groups() # Feature tag and value if v in ['normal','off']: v = '0' elif v == 'on': v = '1' try: v = int(v) assert v >= 0 except: logger.log('Invalid feature value {} found in map file'.format(setting), 'E') fatal_errors = True continue if not v: continue # No need to include 0/off values # we need this one... so translate to TypeTuner feature & value using the map file try: fmap = feat_maps[f] except KeyError: logger.log('Font feature "{}" not found in map file'.format(f), 'E') fatal_errors = True continue f = fmap.ttfeature try: v = fmap.ttvalues[v - 1] except IndexError: logger.log('TypeTuner feature "{}" doesn\'t have a value index {}'.format(f, v), 'E') fatal_errors = True continue # Now translate to TypeTuner tags using feat_all info if f not in feat_all: logger.log('Tunable font doesn\'t contain a feature "{}"'.format(f), 'E') fatal_errors = True elif v not in feat_all[f].values: logger.log('Tunable font feature "{}" doesn\'t have a value {}'.format(f, v), 'E') fatal_errors = True else: ttsettings[f] = v # Save TT setting name and value name in case we need to build the font ttfeat = feat_all[f] f = ttfeat.tag v = ttfeat.values[v] # Finally! parts.append(f+v) if lang: if lang not in lang_maps: logger.log('Language tag "{}" not found in map file'.format(lang), 'E') fatal_errors = True else: # Translate to TypeTuner feature & value using the map file lmap = lang_maps[lang] f = lmap.ttfeature v = lmap.ttvalue # Translate to TypeTuner tags using feat_all info if f not in feat_all: logger.log('Tunable font doesn\'t contain a feature "{}"'.format(f), 'E') fatal_errors = True elif v not in feat_all[f].values: logger.log('Tunable font feature "{}" doesn\'t have a value {}'.format(f, v), 'E') fatal_errors = True else: ttsettings[f] = v # Save TT setting name and value in case we need to build the font ttfeat = feat_all[f] f = ttfeat.tag v = ttfeat.values[v] # Finally! parts.append(f+v) if fatal_errors: return None if len(parts) == 0: logger.log('No features or languages found'.format(f), 'E') return None # the Font Tag is how we name everything (the ttf, the xml, etc) font_tag = '_'.join(sorted(parts)) # See if we've had this combination before: if font_tag in font_tag2font: logger.log('Found cached font {}'.format(font_tag), 'I') return font_tag # Path to font, which may already exist, and @fontface ttfname = os.path.join(fontdir, font_tag + '.ttf') fontface = '@font-face { font-family: {}; src: url(fonts/{}.ttf); } .{} {font-family: {}; }'.replace('{}',font_tag) # Create new font object and remember how to find it: thisfont = font(font_tag, feats, lang, fontface) font_tag2font[font_tag] = thisfont if lang and not feats: lang2font[lang] = thisfont # Debugging shortcut: use the existing fonts without rebuilding if norebuild and os.path.isfile(ttfname): logger.log('Blindly using existing font {}'.format(font_tag), 'I') return font_tag # Ok, need to build the font logger.log('Building font {}'.format(font_tag), 'I') # Create and save the TypeTuner feature settings file sfname = os.path.join(fontdir, font_tag + '.xml') root = ET.XML('''\ ''') # Note: Order of elements in settings file should be same as order in feat_all # (because this is the way TypeTuner does it and some fonts may expect this) for name, ttfeat in sorted(feat_all.items(), key=lambda x: x[1].sortkey): if name in ttsettings: # Output the non-default value for this one: ET.SubElement(root, 'feature',{'name': name, 'value': ttsettings[name]}) else: ET.SubElement(root, 'feature', {'name': name, 'value': ttfeat.default}) xml = ET.tostring(root,pretty_print = True, encoding='UTF-8', xml_declaration=True) with open(sfname, '+wb')as f: f.write(xml) # Now invoke TypeTuner to create the tuned font try: cmd = ['typetuner', '-o', ttfname, '-n', font_tag, sfname, sourcettf] res = check_output(cmd) if len(res): print('\n', res) except CalledProcessError as err: logger.log("couldn't tune font: {}".format(err.output), 'S') return font_tag def doit(args) : global logger, sourcettf, outputdir, fontdir logger = args.logger sourcettf = args.ttfont # Create output directory, including fonts subdirectory, if not present outputdir = args.outputdir os.makedirs(outputdir, exist_ok = True) fontdir = os.path.join(outputdir, 'fonts') os.makedirs(fontdir, exist_ok = True) # Read and save feature mapping for r in args.map: # remove empty cells from the end while len(r) and len(r[-1]) == 0: r.pop() if len(r) == 0 or r[0].startswith('#'): continue elif r[0].startswith('lang='): if len(r[0]) < 7 or len(r) != 3: logger.log("Invalid lang mapping: '" + ','.join(r) + "' ignored", "W") else: r[0] = r[0][5:] lang_maps[r[0]] = lang_map(r) else: if len(r) < 4: logger.log("Invalid feature mapping: '" + ','.join(r) + "' ignored", "W") else: feat_maps[r[0]] = feat_map(r) # Open and verify input file is a tunable font; extract and parse feat_all from font. font = ttLib.TTFont(sourcettf) raw_data = font.getTableData('Silt') feat_xml = gzip.decompress(raw_data) # .decode('utf-8') root = ET.fromstring(feat_xml) if root.tag != 'all_features': logger.log("Invalid TypeTuner feature file: missing root element", "S") for i, f in enumerate(root.findall('.//feature')): # add to dictionary ttfeat = feat(f,i) feat_all[ttfeat.name] = ttfeat # Open and prepare the xslt file to transform the ftml: xslt = ET.parse(args.xsl) xslt_transform = ET.XSLT(xslt) # Process all ftml files: for arg in args.ftml: for infname in glob(arg): # based on input filename, construct output name # find filename and change extension to html: outfname = os.path.join(outputdir, os.path.splitext(os.path.basename(infname))[0] + '.html') logger.log('Processing: {} -> {}'.format(infname, outfname), 'P') # Each named style in the FTML ultimately maps to a TypeTuned font that will be added via @fontface. # We need to remember the names of the styles and their associated fonts so we can hack the html. sname2font = dict() # Indexed by ftml stylename; result is a font object # Parse the FTML ftml_doc = ET.parse(infname) # Adjust to show this is from TypeTuner head = ftml_doc.find('head') title = head.find('title') title.text += " - TypeTuner" # Replace all <fontsrc> elements with two identical from the input font: # One will remain unchanged, the other will eventually be changed to a typetuned font. ET.strip_elements(head, 'fontsrc') fpathname = os.path.relpath(sourcettf, outputdir).replace('\\','/') # for css make sure all slashes are forward! head.append(ET.fromstring('<fontsrc>url({})</fontsrc>'.format(fpathname))) # First font head.append(ET.fromstring('<fontsrc>url({})</fontsrc>'.format(fpathname))) # Second font, same as the first # iterate over all the styles in this ftml file, building tuned fonts to match if not already done. for style in head.iter('style'): sname = style.get('name') # e.g. "some_style" feats = style.get('feats') # e.g "'cv02' 1, 'cv60' 1" -- this we'll parse to get need tt features lang = style.get('lang') # e.g., "sd" font_tag = cache_font(feats, lang, args.norebuild) # font_tag could be None due to errors, but messages should already have been logged # If it is valid, remember how to find this font from the ftml stylename if font_tag: sname2font[sname] = font_tag2font[font_tag] # convert to html via supplied xslt html_doc = xslt_transform(ftml_doc) # Two modifications to make in the html: # 1) add all @fontface specs to the <style> element # 2) Fix up all occurrences of <td> elements referencing font2 # Add @fontface to <style> style = html_doc.find('//style') style.text = style.text + '\n' + '\n'.join([x.fontface for x in sname2font.values()]) # Iterate over all <td> elements looking for font2 and a style or lang indicating feature settings classRE = re.compile(r'string\s+(?:(\w+)\s+)?font2$') for td in html_doc.findall('//td'): tdclass = td.get('class') tdlang = td.get('lang') m = classRE.match(tdclass) if m: sname = m.group(1) if sname: # stylename will get us directly to the font try: td.set('class', 'string {}'.format(sname2font[sname].font_tag)) if tdlang: # If there is also a lang attribute, we no longer need it. del td.attrib['lang'] except KeyError: logger.log("Style name {} not available.".format(sname), "W") elif tdlang: # Otherwise we'll assume there is only the lang attribute try: td.set('class', 'string {}'.format(lang2font[tdlang].font_tag)) del td.attrib['lang'] # lang attribute no longer needed. except KeyError: logger.log("Style for langtag {} not available.".format(tdlang), "W") # Ok -- write the html out! html = ET.tostring(html_doc, pretty_print=True, method='html', encoding='UTF-8') with open(outfname, '+wb')as f: f.write(html) def cmd() : execute(None,doit,argspec) if __name__ == "__main__": cmd()