summaryrefslogtreecommitdiff
path: root/scss/extension/fonts.py
diff options
context:
space:
mode:
Diffstat (limited to 'scss/extension/fonts.py')
-rw-r--r--scss/extension/fonts.py445
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])