summaryrefslogtreecommitdiffstats
path: root/lib/silfont/scripts/psfftml2TThtml.py
diff options
context:
space:
mode:
Diffstat (limited to 'lib/silfont/scripts/psfftml2TThtml.py')
-rwxr-xr-xlib/silfont/scripts/psfftml2TThtml.py389
1 files changed, 389 insertions, 0 deletions
diff --git a/lib/silfont/scripts/psfftml2TThtml.py b/lib/silfont/scripts/psfftml2TThtml.py
new file mode 100755
index 0000000..e9e1128
--- /dev/null
+++ b/lib/silfont/scripts/psfftml2TThtml.py
@@ -0,0 +1,389 @@
+#! /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('''\
+<?xml version = "1.0"?>
+<!DOCTYPE features_set SYSTEM "feat_set.dtd">
+<features_set version = "1.0"/>
+''')
+ # 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 <title> 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()