diff options
Diffstat (limited to 'scss/extension/fonts.py')
-rw-r--r-- | scss/extension/fonts.py | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/scss/extension/fonts.py b/scss/extension/fonts.py new file mode 100644 index 0000000..9d2d1e1 --- /dev/null +++ b/scss/extension/fonts.py @@ -0,0 +1,445 @@ +"""Functions used for generating custom fonts from SVG files.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals + +import re +import errno +import glob +import logging +import os +import time +import tempfile +import subprocess +import warnings + +try: + import cPickle as pickle +except ImportError: + import pickle + +try: + import fontforge +except: + fontforge = None + +from scss import config +from scss.extension import Extension +from scss.namespace import Namespace +from scss.types import String, Boolean, List +from scss.util import getmtime, escape, make_data_url, make_filename_hash + +log = logging.getLogger(__name__) + +TTFAUTOHINT_EXECUTABLE = 'ttfautohint' +TTF2EOT_EXECUTABLE = 'ttf2eot' + +MAX_FONT_SHEETS = 4096 +KEEP_FONT_SHEETS = int(MAX_FONT_SHEETS * 0.8) + +FONT_TYPES = ('eot', 'woff', 'ttf', 'svg') # eot should be first for IE support + +FONT_MIME_TYPES = { + 'ttf': 'application/x-font-ttf', + 'svg': 'image/svg+xml', + 'woff': 'application/x-font-woff', + 'eot': 'application/vnd.ms-fontobject', +} + +FONT_FORMATS = { + 'ttf': "format('truetype')", + 'svg': "format('svg')", + 'woff': "format('woff')", + 'eot': "format('embedded-opentype')", +} + +GLYPH_WIDTH_RE = re.compile(r'width="(\d+(\.\d+)?)') +GLYPH_HEIGHT_RE = re.compile(r'height="(\d+(\.\d+)?)') + +GLYPH_HEIGHT = 512 +GLYPH_ASCENT = 448 +GLYPH_DESCENT = GLYPH_HEIGHT - GLYPH_ASCENT +GLYPH_WIDTH = GLYPH_HEIGHT + +# Offset to work around Chrome Windows bug +GLYPH_START = 0xf100 + + +class FontsExtension(Extension): + """Functions for creating and manipulating fonts.""" + name = 'fonts' + namespace = Namespace() + + +# Alias to make the below declarations less noisy +ns = FontsExtension.namespace + +font_sheets = {} +_font_sheet_cache = {} + + +def ttfautohint(ttf): + try: + proc = subprocess.Popen( + [TTFAUTOHINT_EXECUTABLE, '--hinting-limit=200', '--hinting-range-max=50', '--symbol'], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + except OSError as e: + if e.errno in (errno.EACCES, errno.ENOENT): + warnings.warn('Could not autohint ttf font: The executable %s could not be run: %s' % (TTFAUTOHINT_EXECUTABLE, e)) + return None + else: + raise e + output, output_err = proc.communicate(ttf) + if proc.returncode != 0: + warnings.warn("Could not autohint ttf font: Unknown error!") + return None + return output + + +def ttf2eot(ttf): + try: + proc = subprocess.Popen( + [TTF2EOT_EXECUTABLE], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + except OSError as e: + if e.errno in (errno.EACCES, errno.ENOENT): + warnings.warn('Could not generate eot font: The executable %s could not be run: %s' % (TTF2EOT_EXECUTABLE, e)) + return None + else: + raise e + output, output_err = proc.communicate(ttf) + if proc.returncode != 0: + warnings.warn("Could not generate eot font: Unknown error!") + return None + return output + + +@ns.declare +def font_sheet(g, **kwargs): + if not fontforge: + raise Exception("Fonts manipulation require fontforge") + + now_time = time.time() + + globs = String(g, quotes=None).value + globs = sorted(g.strip() for g in globs.split(',')) + + _k_ = ','.join(globs) + + files = None + rfiles = None + tfiles = None + base_name = None + glob_path = None + glyph_name = None + + if _k_ in font_sheets: + font_sheets[_k_]['*'] = now_time + else: + files = [] + rfiles = [] + tfiles = [] + for _glob in globs: + if '..' not in _glob: # Protect against going to prohibited places... + if callable(config.STATIC_ROOT): + _glob_path = _glob + _rfiles = _files = sorted(config.STATIC_ROOT(_glob)) + else: + _glob_path = os.path.join(config.STATIC_ROOT, _glob) + _files = glob.glob(_glob_path) + _files = sorted((f, None) for f in _files) + _rfiles = [(rf[len(config.STATIC_ROOT):], s) for rf, s in _files] + if _files: + files.extend(_files) + rfiles.extend(_rfiles) + base_name = os.path.basename(os.path.dirname(_glob)) + _glyph_name, _, _glyph_type = base_name.partition('.') + if _glyph_type: + _glyph_type += '-' + if not glyph_name: + glyph_name = _glyph_name + tfiles.extend([_glyph_type] * len(_files)) + else: + glob_path = _glob_path + + if files is not None: + if not files: + log.error("Nothing found at '%s'", glob_path) + return String.unquoted('') + + key = [f for (f, s) in files] + [repr(kwargs), config.ASSETS_URL] + key = glyph_name + '-' + make_filename_hash(key) + asset_files = { + 'eot': key + '.eot', + 'woff': key + '.woff', + 'ttf': key + '.ttf', + 'svg': key + '.svg', + } + ASSETS_ROOT = config.ASSETS_ROOT or os.path.join(config.STATIC_ROOT, 'assets') + asset_paths = dict((type_, os.path.join(ASSETS_ROOT, asset_file)) for type_, asset_file in asset_files.items()) + cache_path = os.path.join(config.CACHE_ROOT or ASSETS_ROOT, key + '.cache') + + inline = Boolean(kwargs.get('inline', False)) + + font_sheet = None + asset = None + file_assets = {} + inline_assets = {} + if all(os.path.exists(asset_path) for asset_path in asset_paths.values()) or inline: + try: + save_time, file_assets, inline_assets, font_sheet, codepoints = pickle.load(open(cache_path)) + if file_assets: + file_asset = List([file_asset for file_asset in file_assets.values()], separator=",") + font_sheets[file_asset.render()] = font_sheet + if inline_assets: + inline_asset = List([inline_asset for inline_asset in inline_assets.values()], separator=",") + font_sheets[inline_asset.render()] = font_sheet + if inline: + asset = inline_asset + else: + asset = file_asset + except: + pass + + if font_sheet: + for file_, storage in files: + _time = getmtime(file_, storage) + if save_time < _time: + if _time > now_time: + log.warning("File '%s' has a date in the future (cache ignored)" % file_) + font_sheet = None # Invalidate cached custom font + break + + if font_sheet is None or asset is None: + cache_buster = Boolean(kwargs.get('cache_buster', True)) + autowidth = Boolean(kwargs.get('autowidth', False)) + autohint = Boolean(kwargs.get('autohint', True)) + + font = fontforge.font() + font.encoding = 'UnicodeFull' + font.design_size = 16 + font.em = GLYPH_HEIGHT + font.ascent = GLYPH_ASCENT + font.descent = GLYPH_DESCENT + font.fontname = glyph_name + font.familyname = glyph_name + font.fullname = glyph_name + + def glyphs(f=lambda x: x): + for file_, storage in f(files): + if storage is not None: + _file = storage.open(file_) + else: + _file = open(file_) + svgtext = _file.read() + svgtext = svgtext.replace('<switch>', '') + svgtext = svgtext.replace('</switch>', '') + svgtext = svgtext.replace('<svg>', '<svg xmlns="http://www.w3.org/2000/svg">') + m = GLYPH_WIDTH_RE.search(svgtext) + if m: + width = float(m.group(1)) + else: + width = None + m = GLYPH_HEIGHT_RE.search(svgtext) + if m: + height = float(m.group(1)) + else: + height = None + _glyph = tempfile.NamedTemporaryFile(delete=False, suffix=".svg") + _glyph.file.write(svgtext) + _glyph.file.close() + yield _glyph.name, width, height + + names = tuple(os.path.splitext(os.path.basename(file_))[0] for file_, storage in files) + tnames = tuple(tfiles[i] + n for i, n in enumerate(names)) + + codepoints = [] + for i, (glyph_filename, glyph_width, glyph_height) in enumerate(glyphs()): + if glyph_height and glyph_height != GLYPH_HEIGHT: + warnings.warn("Glyphs should be %spx-high" % GLYPH_HEIGHT) + codepoint = i + GLYPH_START + name = names[i] + codepoints.append(codepoint) + glyph = font.createChar(codepoint, name) + glyph.importOutlines(glyph_filename) + os.unlink(glyph_filename) + glyph.width = glyph_width or GLYPH_WIDTH + if autowidth: + # Autowidth removes side bearings + glyph.left_side_bearing = glyph.right_side_bearing = 0 + glyph.round() + + filetime = int(now_time) + + # Generate font files + if not inline: + urls = {} + for type_ in reversed(FONT_TYPES): + asset_path = asset_paths[type_] + try: + if type_ == 'eot': + ttf_path = asset_paths['ttf'] + with open(ttf_path) as ttf_fh: + contents = ttf2eot(ttf_fh.read()) + if contents is not None: + with open(asset_path, 'wb') as asset_fh: + asset_fh.write(contents) + else: + font.generate(asset_path) + if type_ == 'ttf': + contents = None + if autohint: + with open(asset_path) as asset_fh: + contents = ttfautohint(asset_fh.read()) + if contents is not None: + with open(asset_path, 'wb') as asset_fh: + asset_fh.write(contents) + asset_file = asset_files[type_] + url = '%s%s' % (config.ASSETS_URL, asset_file) + params = [] + if not urls: + params.append('#iefix') + if cache_buster: + params.append('v=%s' % filetime) + if type_ == 'svg': + params.append('#' + glyph_name) + if params: + url += '?' + '&'.join(params) + urls[type_] = url + except IOError: + inline = False + + if inline: + urls = {} + for type_ in reversed(FONT_TYPES): + contents = None + if type_ == 'eot': + ttf_path = asset_paths['ttf'] + with open(ttf_path) as ttf_fh: + contents = ttf2eot(ttf_fh.read()) + if contents is None: + continue + else: + _tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.' + type_) + _tmp.file.close() + font.generate(_tmp.name) + with open(_tmp.name) as asset_fh: + if autohint: + if type_ == 'ttf': + _contents = asset_fh.read() + contents = ttfautohint(_contents) + if contents is None: + contents = _contents + os.unlink(_tmp.name) + mime_type = FONT_MIME_TYPES[type_] + url = make_data_url(mime_type, contents) + urls[type_] = url + + assets = {} + for type_, url in urls.items(): + format_ = FONT_FORMATS[type_] + url = "url('%s')" % escape(url) + if inline: + assets[type_] = inline_assets[type_] = List([String.unquoted(url), String.unquoted(format_)]) + else: + assets[type_] = file_assets[type_] = List([String.unquoted(url), String.unquoted(format_)]) + asset = List([assets[type_] for type_ in FONT_TYPES if type_ in assets], separator=",") + + # Add the new object: + font_sheet = dict(zip(tnames, zip(rfiles, codepoints))) + font_sheet['*'] = now_time + font_sheet['*f*'] = asset_files + font_sheet['*k*'] = key + font_sheet['*n*'] = glyph_name + font_sheet['*t*'] = filetime + + codepoints = zip(files, codepoints) + cache_tmp = tempfile.NamedTemporaryFile(delete=False, dir=ASSETS_ROOT) + pickle.dump((now_time, file_assets, inline_assets, font_sheet, codepoints), cache_tmp) + cache_tmp.close() + os.rename(cache_tmp.name, cache_path) + + # Use the sorted list to remove older elements (keep only 500 objects): + if len(font_sheets) > MAX_FONT_SHEETS: + for a in sorted(font_sheets, key=lambda a: font_sheets[a]['*'], reverse=True)[KEEP_FONT_SHEETS:]: + del font_sheets[a] + log.warning("Exceeded maximum number of font sheets (%s)" % MAX_FONT_SHEETS) + font_sheets[asset.render()] = font_sheet + for file_, codepoint in codepoints: + _font_sheet_cache[file_] = codepoint + # TODO this sometimes returns an empty list, or is never assigned to + return asset + + +@ns.declare_alias('glyph-names') +@ns.declare +def glyphs(sheet, remove_suffix=False): + sheet = sheet.render() + font_sheet = font_sheets.get(sheet, {}) + return List([String.unquoted(f) for f in sorted(set(f.rsplit('-', 1)[0] if remove_suffix else f for f in font_sheet if not f.startswith('*')))]) + + +@ns.declare +def glyph_classes(sheet): + return glyphs(sheet, True) + + +@ns.declare +def font_url(sheet, type_, only_path=False, cache_buster=True): + font_sheet = font_sheets.get(sheet.render()) + type_ = String.unquoted(type_).render() + if font_sheet: + asset_files = font_sheet['*f*'] + asset_file = asset_files.get(type_) + if asset_file: + url = '%s%s' % (config.ASSETS_URL, asset_file) + params = [] + # if type_ == 'eot': + # params.append('#iefix') + if cache_buster: + params.append('v=%s' % font_sheet['*t*']) + if type_ == 'svg': + params.append('#' + font_sheet['*n*']) + if params: + url += '?' + '&'.join(params) + if not only_path: + url = "url('%s')" % escape(url) + return String.unquoted(url) + return String.unquoted('') + + +@ns.declare +def font_format(type_): + type_ = type_.render() + if type_ in FONT_FORMATS: + return String.unquoted(FONT_FORMATS[type_]) + return String.unquoted('') + + +@ns.declare +def has_glyph(sheet, glyph): + sheet = sheet.render() + font_sheet = font_sheets.get(sheet) + glyph_name = String.unquoted(glyph).value + glyph = font_sheet and font_sheet.get(glyph_name) + if not font_sheet: + log.error("No font sheet found: %s", sheet, extra={'stack': True}) + return Boolean(bool(glyph)) + + +@ns.declare +def glyph_code(sheet, glyph): + sheet = sheet.render() + font_sheet = font_sheets.get(sheet) + glyph_name = String.unquoted(glyph).value + glyph = font_sheet and font_sheet.get(glyph_name) + if not font_sheet: + log.error("No font sheet found: %s", sheet, extra={'stack': True}) + elif not glyph: + log.error("No glyph found: %s in %s", glyph_name, font_sheet['*n*'], extra={'stack': True}) + return String('%x' % glyph[1]) |