mirror of
https://github.com/google/fonts.git
synced 2024-11-30 18:52:46 +03:00
502 lines
15 KiB
Python
502 lines
15 KiB
Python
|
|
|
|
import collections
|
|
import contextlib
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
|
|
from fontTools import ttLib
|
|
|
|
from google.apputils import app
|
|
import gflags as flags
|
|
|
|
from util import google_fonts as fonts
|
|
|
|
FLAGS = flags.FLAGS
|
|
|
|
flags.DEFINE_boolean('suppress_pass', True, 'Whether to print pass: results')
|
|
flags.DEFINE_boolean('check_metadata', True, 'Whether to check METADATA values')
|
|
flags.DEFINE_boolean('check_font', True, 'Whether to check font values')
|
|
flags.DEFINE_string('repair_script', None, 'Where to write a repair script')
|
|
_FIX_TYPE_OPTS = ['all', 'name', 'filename', 'postScriptName', 'fullName',
|
|
'fsSelection', 'fsType', 'usWeightClass', 'emptyGlyphLSB']
|
|
flags.DEFINE_multistring('fix_type', 'all',
|
|
'What types of problems should be fixed by '
|
|
'repair_script. Choices: ' + ', '.join(_FIX_TYPE_OPTS))
|
|
|
|
|
|
ResultMessageTuple = collections.namedtuple(
|
|
'ResultMessageTuple', ['happy', 'message', 'path', 'repair_script'])
|
|
|
|
|
|
def _HappyResult(message, path):
|
|
return ResultMessageTuple(True, message, path, None)
|
|
|
|
|
|
def _SadResult(message, path, repair_script=None):
|
|
return ResultMessageTuple(False, message, path, repair_script)
|
|
|
|
|
|
def _DropEmptyPathSegments(path):
|
|
"""Removes empty segments from the end of path.
|
|
|
|
Args:
|
|
path: A filesystem path.
|
|
Returns:
|
|
path with trailing empty segments removed. Eg /duck/// => /duck.
|
|
"""
|
|
while True:
|
|
(head, tail) = os.path.split(path)
|
|
if tail:
|
|
break
|
|
path = head
|
|
return path
|
|
|
|
|
|
def _SanityCheck(path):
|
|
"""Runs various sanity checks on the font family under path.
|
|
|
|
Args:
|
|
path: A directory containing a METADATA.pb file.
|
|
Returns:
|
|
A list of ResultMessageTuple's.
|
|
"""
|
|
try:
|
|
fonts.Metadata(path)
|
|
except ValueError as e:
|
|
return [_SadResult('Bad METADATA.pb: ' + e.message, path)]
|
|
|
|
results = []
|
|
if FLAGS.check_metadata:
|
|
results.extend(_CheckLicense(path))
|
|
results.extend(_CheckNameMatching(path))
|
|
|
|
if FLAGS.check_font:
|
|
results.extend(_CheckFontInternalValues(path))
|
|
|
|
return results
|
|
|
|
|
|
def _CheckLicense(path):
|
|
"""Verifies that METADATA.pb license is correct under path.
|
|
|
|
Assumes path is of the form .../<license>/<whatever>/METADATA.pb.
|
|
|
|
Args:
|
|
path: A directory containing a METADATA.pb file.
|
|
Returns:
|
|
A list with one ResultMessageTuple. If happy, license is good.
|
|
"""
|
|
metadata = fonts.Metadata(path)
|
|
lic = metadata.license
|
|
lic_dir = os.path.basename(os.path.dirname(path))
|
|
|
|
# We use /apache for the license Apache2
|
|
if lic_dir == 'apache':
|
|
lic_dir += '2'
|
|
|
|
result = _HappyResult('License consistantly %s' % lic, path)
|
|
# if we were Python 3 we'd use casefold(); this will suffice
|
|
if lic_dir.lower() != lic.lower():
|
|
result = _SadResult(
|
|
'Dir license != METADATA license: %s != %s' % (lic_dir, lic), path)
|
|
|
|
return [result]
|
|
|
|
|
|
def _CheckNameMatching(path):
|
|
"""Verifies the various name fields in the METADATA.pb file are sane.
|
|
|
|
Args:
|
|
path: A directory containing a METADATA.pb file.
|
|
Returns:
|
|
A list of ResultMessageTuple, one per validation performed.
|
|
"""
|
|
results = []
|
|
metadata = fonts.Metadata(path)
|
|
name = metadata.name
|
|
|
|
for font in metadata.fonts:
|
|
# We assume style/weight is correct in METADATA
|
|
style = font.style
|
|
weight = font.weight
|
|
values = [
|
|
('name', name, font.name),
|
|
('filename', fonts.FilenameFor(name, style, weight, '.ttf'),
|
|
font.filename),
|
|
('postScriptName', fonts.FilenameFor(name, style, weight),
|
|
font.post_script_name),
|
|
('fullName', fonts.FullnameFor(name, style, weight), font.full_name)
|
|
]
|
|
|
|
for (key, expected, actual) in values:
|
|
if expected != actual:
|
|
results.append(_SadResult(
|
|
'%s METADATA %s/%d %s expected %s, got %s' %
|
|
(name, style, weight, key, expected, actual), path,
|
|
_FixMetadata(style, weight, key, expected)))
|
|
|
|
if not results:
|
|
results.append(_HappyResult('METADATA name consistently derived from "%s"'
|
|
% name, path))
|
|
|
|
return results
|
|
|
|
|
|
def _IsItalic(style):
|
|
return style.lower() == 'italic'
|
|
|
|
|
|
def _IsBold(weight):
|
|
"""Is this weight considered bold?
|
|
|
|
Per Dave C, only 700 will be considered bold.
|
|
|
|
Args:
|
|
weight: Font weight.
|
|
Returns:
|
|
True if weight is considered bold, otherwise False.
|
|
"""
|
|
return weight == 700
|
|
|
|
|
|
def _ShouldFix(key):
|
|
return (FLAGS.fix_type and (
|
|
key in FLAGS.fix_type or 'all' in FLAGS.fix_type))
|
|
|
|
|
|
def _FixMetadata(style, weight, key, expected):
|
|
if not _ShouldFix(key):
|
|
return None
|
|
|
|
if not isinstance(expected, int):
|
|
expected = '\'%s\'' % expected
|
|
|
|
return ('[f for f in metadata.fonts if f.style == \'%s\' '
|
|
'and f.weight == %d][0].%s = %s') % (
|
|
style, weight, re.sub('([a-z])([A-Z])', r'\1_\2', key)
|
|
.lower(), expected)
|
|
|
|
|
|
def _FixFsSelectionBit(key, expected):
|
|
"""Write a repair script to fix a bad fsSelection bit.
|
|
|
|
Args:
|
|
key: The name of an fsSelection flag, eg 'ITALIC' or 'BOLD'.
|
|
expected: Expected value, true/false, of the flag.
|
|
Returns:
|
|
A python script to fix the problem.
|
|
"""
|
|
if not _ShouldFix('fsSelection'):
|
|
return None
|
|
|
|
op = '|='
|
|
verb = 'set'
|
|
mask = bin(fonts.FsSelectionMask(key))
|
|
if not expected:
|
|
op = '&='
|
|
verb = 'unset'
|
|
mask = '~' + mask
|
|
|
|
return 'ttf[\'OS/2\'].fsSelection %s %s # %s %s' % (op, mask, verb, key)
|
|
|
|
|
|
def _FixFsType(expected):
|
|
if not _ShouldFix('fsType'):
|
|
return None
|
|
return 'ttf[\'OS/2\'].fsType = %d' % expected
|
|
|
|
|
|
def _FixWeightClass(expected):
|
|
if not _ShouldFix('usWeightClass'):
|
|
return None
|
|
return 'ttf[\'OS/2\'].usWeightClass = %d' % expected
|
|
|
|
|
|
def _FixBadNameRecord(friendly_name, name_id, expected):
|
|
if not _ShouldFix(friendly_name):
|
|
return None
|
|
|
|
return ('for nr in [n for n in ttf[\'name\'].names if n.nameID == %d]:\n'
|
|
' nr.string = \'%s\'.encode(nr.getEncoding()) # Fix %s'
|
|
% (name_id, expected, friendly_name))
|
|
|
|
|
|
def _FixMissingNameRecord(friendly_name, name_id, expected):
|
|
if not _ShouldFix(friendly_name):
|
|
return None
|
|
|
|
return ('nr = ttLib.tables._n_a_m_e.NameRecord()\n'
|
|
'nr.nameID = %d # %s'
|
|
'nr.langID = 0x409\n'
|
|
'nr.platEncID = 1\n'
|
|
'nr.platformID = 3\n'
|
|
'nr.string = \'%s\'.encode(nr.getEncoding())\n'
|
|
'ttf[\'name\'].names.append(nr)\n' % (
|
|
name_id, friendly_name, expected))
|
|
|
|
|
|
def _FixEmptyGlyphLsb(glyph_name):
|
|
if not _ShouldFix('emptyGlyphLSB'):
|
|
return None
|
|
|
|
return 'ttf[\'hmtx\'][\'%s\'] = [ttf[\'hmtx\'][\'%s\'][0], 0]\n' % (
|
|
glyph_name, glyph_name)
|
|
|
|
|
|
def _CheckFontOS2Values(path, font, ttf):
|
|
"""Check sanity of values hidden in the 'OS/2' table.
|
|
|
|
Notably usWeightClass, fsType, fsSelection.
|
|
|
|
Args:
|
|
path: Path to directory containing font.
|
|
font: A font record from a METADATA.pb.
|
|
ttf: A fontTools.ttLib.TTFont for the font.
|
|
Returns:
|
|
A list of ResultMessageTuple for tests performed.
|
|
"""
|
|
results = []
|
|
|
|
font_file = font.filename
|
|
full_font_file = os.path.join(path, font_file)
|
|
expected_style = font.style
|
|
expected_weight = font.weight
|
|
|
|
os2 = ttf['OS/2']
|
|
fs_selection_flags = fonts.FsSelectionFlags(os2.fsSelection)
|
|
actual_weight = os2.usWeightClass
|
|
fs_type = os2.fsType
|
|
|
|
marked_oblique = 'OBLIQUE' in fs_selection_flags
|
|
marked_italic = 'ITALIC' in fs_selection_flags
|
|
marked_bold = 'BOLD' in fs_selection_flags
|
|
|
|
expect_italic = _IsItalic(expected_style)
|
|
expect_bold = _IsBold(expected_weight)
|
|
# Per Dave C, we should NEVER set oblique, use 0 for italic
|
|
expect_oblique = False
|
|
|
|
results.append(ResultMessageTuple(
|
|
marked_italic == expect_italic,
|
|
'%s %s/%d fsSelection marked_italic %d' % (
|
|
font_file, expected_style, expected_weight, marked_italic),
|
|
full_font_file, _FixFsSelectionBit('ITALIC', expect_italic)))
|
|
results.append(ResultMessageTuple(
|
|
marked_bold == expect_bold,
|
|
'%s %s/%d fsSelection marked_bold %d' %
|
|
(font_file, expected_style, expected_weight, marked_bold), full_font_file,
|
|
_FixFsSelectionBit('BOLD', expect_bold)))
|
|
|
|
results.append(ResultMessageTuple(
|
|
marked_oblique == expect_oblique,
|
|
'%s %s/%d fsSelection marked_oblique %d' % (
|
|
font_file, expected_style, expected_weight, marked_oblique),
|
|
full_font_file, _FixFsSelectionBit('OBLIQUE', expect_oblique)))
|
|
|
|
# For weight < 300, just confirm weight [250, 300)
|
|
# TODO(user): we should also verify ordering is correct
|
|
weight_ok = expected_weight == actual_weight
|
|
weight_msg = str(expected_weight)
|
|
if expected_weight < 300:
|
|
weight_ok = actual_weight >= 250 and actual_weight < 300
|
|
weight_msg = '[250, 300)'
|
|
|
|
results.append(ResultMessageTuple(
|
|
weight_ok,
|
|
'%s %s/%d weight expected: %s usWeightClass: %d' %
|
|
(font_file, expected_style, expected_weight, weight_msg, actual_weight),
|
|
full_font_file, _FixWeightClass(expected_weight)))
|
|
|
|
expected_fs_type = 0
|
|
results.append(ResultMessageTuple(
|
|
expected_fs_type == fs_type,
|
|
'%s %s/%d fsType expected: %d fsType: %d' %
|
|
(font_file, expected_style, expected_weight, expected_fs_type, fs_type),
|
|
full_font_file, _FixFsType(expected_fs_type)))
|
|
|
|
return results
|
|
|
|
|
|
def _CheckFontNameValues(path, name, font, ttf):
|
|
"""Check sanity of values in the 'name' table.
|
|
|
|
Specifically the fullname and postScriptName.
|
|
|
|
Args:
|
|
path: Path to directory containing font.
|
|
name: The name of the family.
|
|
font: A font record from a METADATA.pb.
|
|
ttf: A fontTools.ttLib.TTFont for the font.
|
|
Returns:
|
|
A list of ResultMessageTuple for tests performed.
|
|
"""
|
|
results = []
|
|
|
|
style = font.style
|
|
weight = font.weight
|
|
full_font_file = os.path.join(path, font.filename)
|
|
|
|
expectations = [
|
|
('family', fonts.NAME_FAMILY, name),
|
|
('postScriptName', fonts.NAME_PSNAME,
|
|
fonts.FilenameFor(name, style, weight)),
|
|
('fullName', fonts.NAME_FULLNAME, fonts.FullnameFor(name, style, weight))]
|
|
|
|
for (friendly_name, name_id, expected) in expectations:
|
|
# If you have lots of name records they should ALL have the right value
|
|
actuals = fonts.ExtractNames(ttf, name_id)
|
|
for (idx, actual) in enumerate(actuals):
|
|
results.append(ResultMessageTuple(
|
|
expected == actual,
|
|
'%s %s/%d \'name\' %s[%d] expected %s, got %s' %
|
|
(name, style, weight, friendly_name, idx, expected, actual),
|
|
full_font_file,
|
|
_FixBadNameRecord(friendly_name, name_id, expected)))
|
|
|
|
# should have at least one actual
|
|
if not actuals:
|
|
results.append(_SadResult(
|
|
'%s %s/%d \'name\' %s has NO values' %
|
|
(name, style, weight, friendly_name), full_font_file,
|
|
_FixMissingNameRecord(friendly_name, name_id, expected)))
|
|
|
|
return results
|
|
|
|
|
|
def _CheckLSB0ForEmptyGlyphs(path, font, ttf):
|
|
"""Checks if font has empty (loca[n] == loca[n+1]) glyphs that have non-0 lsb.
|
|
|
|
There is no reason to set such lsb's.
|
|
|
|
Args:
|
|
path: Path to directory containing font.
|
|
font: A font record from a METADATA.pb.
|
|
ttf: A fontTools.ttLib.TTFont for the font.
|
|
Returns:
|
|
A list of ResultMessageTuple for tests performed.
|
|
"""
|
|
results = []
|
|
if 'loca' not in ttf:
|
|
return results
|
|
for glyph_index, glyph_name in enumerate(ttf.getGlyphOrder()):
|
|
is_empty = ttf['loca'][glyph_index] == ttf['loca'][glyph_index + 1]
|
|
lsb = ttf['hmtx'][glyph_name][1]
|
|
if is_empty and lsb != 0:
|
|
results.append(_SadResult(
|
|
'%s %s/%d [\'hmtx\'][\'%s\'][1] (lsb) should be 0 but is %d' %
|
|
(font.name, font.style, font.weight, glyph_name, lsb),
|
|
os.path.join(path, font.filename), _FixEmptyGlyphLsb(glyph_name)))
|
|
return results
|
|
|
|
|
|
def _CheckFontInternalValues(path):
|
|
"""Validates fonts internal metadata matches METADATA.pb values.
|
|
|
|
In particular, checks 'OS/2' {usWeightClass, fsSelection, fsType} and 'name'
|
|
{fullName, postScriptName} values.
|
|
|
|
Args:
|
|
path: A directory containing a METADATA.pb file.
|
|
Returns:
|
|
A list of ResultMessageTuple, one per validation performed.
|
|
"""
|
|
results = []
|
|
metadata = fonts.Metadata(path)
|
|
name = metadata.name
|
|
|
|
for font in metadata.fonts:
|
|
font_file = font.filename
|
|
with contextlib.closing(ttLib.TTFont(os.path.join(path, font_file))) as ttf:
|
|
results.extend(_CheckFontOS2Values(path, font, ttf))
|
|
results.extend(_CheckFontNameValues(path, name, font, ttf))
|
|
results.extend(_CheckLSB0ForEmptyGlyphs(path, font, ttf))
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
def _WriteRepairScript(dest_file, results):
|
|
with open(dest_file, 'w') as out:
|
|
out.write('import collections\n')
|
|
out.write('import contextlib\n')
|
|
out.write('from fontTools import ttLib\n')
|
|
out.write('from google.protobuf.text_format import text_format\n')
|
|
out.write('from fonts_public_pb2 import fonts_pb2\n')
|
|
out.write('from fonts_public_pb2 '
|
|
'import fonts_metadata_pb2\n')
|
|
out.write('\n')
|
|
|
|
# group by path
|
|
by_path = collections.defaultdict(list)
|
|
for result in results:
|
|
if result.happy or not result.repair_script:
|
|
continue
|
|
if result.repair_script not in by_path[result.path]:
|
|
by_path[result.path].append(result.repair_script)
|
|
|
|
for path in sorted(by_path.keys()):
|
|
out.write('# repair %s\n' % os.path.basename(path))
|
|
_, ext = os.path.splitext(path)
|
|
|
|
|
|
prefix = ''
|
|
if ext == '.ttf':
|
|
prefix = ' '
|
|
out.write('with contextlib.closing(ttLib.TTFont(\'%s\')) as ttf:\n'
|
|
% path)
|
|
elif os.path.isdir(path):
|
|
metadata_file = os.path.join(path, 'METADATA.pb')
|
|
out.write('metadata = fonts_pb2.FamilyProto()\n')
|
|
out.write('with open(\'%s\', \'r\') as f:\n' % metadata_file)
|
|
out.write(' text_format.Merge(f.read(), metadata)\n')
|
|
else:
|
|
raise ValueError('Not sure how to script %s' % path)
|
|
|
|
for repair in by_path[path]:
|
|
out.write(prefix)
|
|
out.write(re.sub('\n', '\n' + prefix, repair))
|
|
out.write('\n')
|
|
|
|
if ext == '.ttf':
|
|
out.write(' ttf.save(\'%s\')\n' % path)
|
|
|
|
if os.path.isdir(path):
|
|
out.write('with open(\'%s\', \'w\') as f:\n' % metadata_file)
|
|
out.write(' f.write(text_format.MessageToString(metadata))\n')
|
|
|
|
out.write('\n')
|
|
|
|
|
|
def main(argv):
|
|
result_code = 0
|
|
all_results = []
|
|
paths = [_DropEmptyPathSegments(os.path.expanduser(p)) for p in argv[1:]]
|
|
for path in paths:
|
|
if not os.path.isdir(path):
|
|
raise ValueError('Not a directory: %s' % path)
|
|
|
|
for path in paths:
|
|
for font_dir in fonts.FontDirs(path):
|
|
results = _SanityCheck(font_dir)
|
|
all_results.extend(results)
|
|
for result in results:
|
|
result_msg = 'pass'
|
|
if not result.happy:
|
|
result_code = 1
|
|
result_msg = 'FAIL'
|
|
if not result.happy or not FLAGS.suppress_pass:
|
|
print '%s: %s (%s)' % (result_msg, result.message, font_dir)
|
|
|
|
if FLAGS.repair_script:
|
|
_WriteRepairScript(FLAGS.repair_script, all_results)
|
|
|
|
sys.exit(result_code)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
app.run()
|