diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b9ecb79 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,17 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + Copyright Sion + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4c49eb --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Improve compatibility of fonts from macOS + +Granted, macOS carries many high quality fonts, but Apple modified them intentionally +for incompatibility with other Operating system. However, these modifications, actually, +violate OpenType spec. This project aims to recover the original faces of these fonts, +thereby making fonts installed on other OS, e.g. Windows. + +## Highlight + +Do never touch typefaces themselves + +Accept ttc font (TrueType Collection) without splitting + +Support conversion from OpenType to TrueType + +Support multiprocessing to leverage modern multi-core CPU + +Support file wildcards + +### Requirements + +Python >= 3.6 + +fontTools >= 3.22.0 + +afdko or cu2qu if need TrueType conversion + +### Installation + +`pip install "fixMacFonts-*.whl"` + +### Usage + +Fixes some incompatible fonts from macOS + +`fixMacFonts font.ttc fonts/*.otf` + +Convert to TrueType (CPU intensive!) + +`fixMacFonts --otf2ttf font.ttc fonts/*.otf` + +"Need Internet connection for the first use in order to download data for speculation from +language subtag to script subtag" + +### Details + +Fix 'cmap' table given [here](https://docs.microsoft.com/typography/opentype/spec/cmap) +and [there](https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6cmap.html) + +Fix 'meta' table given [here](https://docs.microsoft.com/typography/opentype/spec/meta) +and [there](https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6meta.html) + +Fix 'name' table given [here](https://docs.microsoft.com/typography/opentype/spec/name) +and [there](https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6name.html) + +fix 'head' table given [here](https://docs.microsoft.com/typography/opentype/spec/head) +and [there](https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6head.html) + +Apparently and ironically, Apple do not adhere to their own documents. diff --git a/fixMacFonts/__init__.py b/fixMacFonts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fixMacFonts/fix.py b/fixMacFonts/fix.py new file mode 100644 index 0000000..2e8a22a --- /dev/null +++ b/fixMacFonts/fix.py @@ -0,0 +1,205 @@ +import argparse +import copy +import glob +import logging +import os +from functools import singledispatch +from itertools import chain +from multiprocessing import Pool + +from fontTools.ttLib import TTCollection, TTFont, getTableClass + +from fixMacFonts import scriptLangTag + +basicStyles = ('Regular', 'Bold', 'Italic', 'Bold Italic') + +options = dict(recalcBBoxes=False, recalcTimestamp=False) + +parser = argparse.ArgumentParser(description='Fixes some incompatible fonts from macOS.') +parser.add_argument('files', metavar='font', nargs='+', + help='incompatible font files (supports file wildcards).') +parser.add_argument('-d', '--dir', dest='dirname', default='compatible', + help='the directory to store the font files.' + '(default: "compatible" directory relative to the font file)') +parser.add_argument('--otf2ttf', action='store_true', + help='converts OpenType font to TrueType font.') +parser.add_argument('-v', '--verbose', action='count', default=0, + help='increases output verbosity') +args = parser.parse_args() + +if args.otf2ttf: + try: + from afdko.otf2ttf import otf_to_ttf + except ImportError: + from fixMacFonts.otf2ttf import otf_to_ttf + +if args.verbose == 1: + logging.basicConfig(level=logging.INFO) +elif args.verbose > 1: + logging.basicConfig(level=logging.DEBUG) + +logger = logging.getLogger(__name__) + +Script, likelyLang, likelyScript = scriptLangTag.get() +Script = set(Script) + + +@singledispatch +def rewrite(table): + logger.debug(f"The '{table.tableTag}' table will not be rewritten") + + +@rewrite.register(getTableClass('cmap')) +def _(table): + if table.getcmap(platformID=3, platEncID=1): + return + + logger.info("Adding a format 4 'cmap' subtable") + + best_cmap = table.getBestCmap() + + cmap_format_4 = table.tables[-1].newSubtable(format=4) + + cmap_format_4.platformID, cmap_format_4.platEncID, cmap_format_4.language = 3, 1, 0 + cmap_format_4.cmap = {k: v for k, v in best_cmap.items() if k < 0x10000} + + table.tables.append(cmap_format_4) + + +@rewrite.register(getTableClass('meta')) +def _(table): + for tag in ['dlng', 'slng']: + langs = {lang.strip() for lang in table.data[tag].split(',')} + scripts = set() + + for lang in langs: + for script in lang.split('-'): + if script in Script: + scripts.add(script) + break + else: + try: + script = likelyScript[lang] + if likelyLang[script] in langs: + scripts.add(script) + except KeyError: + logger.warning(f'The ScriptLangTag "{lang}" probably' + ' not conforming to BCP 47 is ignored') + + logger.debug(f"Rewriting values for '{tag}' in the 'meta' table") + logger.info(f'old {tag}: "{table.data[tag]}"') + + table.data[tag] = ','.join(sorted(scripts)) + + logger.info(f'new {tag}: "{table.data[tag]}"') + + +@rewrite.register(getTableClass('name')) +def _(table): + names = dict() + langs = set() + + for namerecord in table.names: + if namerecord.platformID == 3: + names[(namerecord.nameID, namerecord.langID)] = namerecord + langs.add(namerecord.langID) + + for style in basicStyles: + if str(names[(17, 0x409)]).endswith(style): + break + else: + style = '' + + style = style.split() + + for lang in langs: + try: + family, subfamily = str(names[(16, lang)]), str(names[(17, lang)]) + except KeyError: + continue + + fullname = family.split() + subfamily.split() + + logger.debug(f'Rewriting Family name') + logger.info(f'old Family name: "{names[(1, lang)]!s}"') + + names[(1, lang)].string = ' '.join(fullname[:-len(style)]) if style else ' '.join(fullname) + + logger.info(f'new Family name: "{names[(1, lang)]!s}"') + + logger.debug(f'Rewriting Subamily name') + logger.info(f'old Subamily name: "{names[(2, lang)]!s}"') + + names[(2, lang)].string = ' '.join(fullname[-len(style):]) if style else 'Regular' + + logger.info(f'new Subamily name: "{names[(2, lang)]!s}"') + + try: + logger.debug(f'Rewriting Full name') + logger.info(f'old Full name: "{names[(4, lang)]!s}"') + + names[(4, lang)].string = ' '.join(fullname) + + logger.info(f'new Full name: "{names[(4, lang)]!s}"') + except KeyError: + name4 = copy.copy(names[(1, lang)]) + name4.nameID = 4 + name4.string = ' '.join(fullname) + table.names.append(name4) + + logger.info(f'Adding Full name "{name4.string!s}"') + + +@singledispatch +def repair(font): + for tag in font.reader.keys(): + rewrite(font[tag]) + + if font['OS/2'].fsSelection & 1: + font['head'].macStyle |= 1 << 1 + if font['OS/2'].fsSelection & 1 << 5: + font['head'].macStyle |= 1 + if font['OS/2'].fsSelection & 1 << 6: + font['head'].macStyle &= ~0b11 + + if args.otf2ttf: + font.recalcBBoxes = True + otf_to_ttf(font) + + +@repair.register(TTCollection) +def _(fonts): + for font in fonts: + repair(font) + + +def fix(file): + dirname = args.dirname if os.path.exists(args.dirname) \ + else os.path.join(os.path.dirname(file), args.dirname) + basename = os.path.basename(file) + + if not os.path.exists(dirname): + os.mkdir(dirname) + + logger.name = basename + + with open(file, 'rb') as f: + header = f.read(4) + + font = TTCollection(file, **options) if header == b'ttcf' \ + else TTFont(file, **options) + + repair(font) + + font.save(os.path.join(dirname, basename)) + + +def main(): + files = chain.from_iterable(map(glob.glob, args.files)) + + with Pool() as pool: + pool.map(fix, files) + + +if __name__ == '__main__': + main() diff --git a/fixMacFonts/otf2ttf.py b/fixMacFonts/otf2ttf.py new file mode 100644 index 0000000..84104d2 --- /dev/null +++ b/fixMacFonts/otf2ttf.py @@ -0,0 +1,159 @@ +""" +Copyright 2014-2018 Adobe. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use these files except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +import argparse +import glob +import logging +import os +import sys +from functools import partial, singledispatch +from itertools import chain +from multiprocessing import Pool + +from cu2qu.pens import Cu2QuPen +from fontTools import configLogger +from fontTools.misc.cliTools import makeOutputFileName +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTCollection, TTFont, newTable + +log = logging.getLogger() + +# default approximation error, measured in UPEM +MAX_ERR = 1.0 + +# default 'post' table format +POST_FORMAT = 2.0 + +# assuming the input contours' direction is correctly set (counter-clockwise), +# we just flip it to clockwise +REVERSE_DIRECTION = True + + +def glyphs_to_quadratic( + glyphs, max_err=MAX_ERR, reverse_direction=REVERSE_DIRECTION): + quadGlyphs = {} + for gname in glyphs.keys(): + glyph = glyphs[gname] + ttPen = TTGlyphPen(glyphs) + cu2quPen = Cu2QuPen(ttPen, max_err, + reverse_direction=reverse_direction) + glyph.draw(cu2quPen) + quadGlyphs[gname] = ttPen.glyph() + return quadGlyphs + + +@singledispatch +def otf_to_ttf(ttFont, post_format=POST_FORMAT, **kwargs): + assert ttFont.sfntVersion == "OTTO" + assert "CFF " in ttFont + + glyphOrder = ttFont.getGlyphOrder() + + ttFont["loca"] = newTable("loca") + ttFont["glyf"] = glyf = newTable("glyf") + glyf.glyphOrder = glyphOrder + glyf.glyphs = glyphs_to_quadratic(ttFont.getGlyphSet(), **kwargs) + del ttFont["CFF "] + if "VORG" in ttFont: + del ttFont["VORG"] + glyf.compile(ttFont) + + ttFont["maxp"] = maxp = newTable("maxp") + maxp.tableVersion = 0x00010000 + maxp.maxZones = 1 + maxp.maxTwilightPoints = 0 + maxp.maxStorage = 0 + maxp.maxFunctionDefs = 0 + maxp.maxInstructionDefs = 0 + maxp.maxStackElements = 0 + maxp.maxSizeOfInstructions = 0 + maxp.maxComponentElements = max( + len(g.components if hasattr(g, 'components') else []) + for g in glyf.glyphs.values()) + maxp.compile(ttFont) + + post = ttFont["post"] + post.formatType = post_format + post.extraNames = [] + post.mapping = {} + post.glyphOrder = glyphOrder + try: + post.compile(ttFont) + except OverflowError: + post.formatType = 3 + log.warning("Dropping glyph names, they do not fit in 'post' table.") + + ttFont.sfntVersion = "\000\001\000\000" + + +@otf_to_ttf.register(TTCollection) +def _(fonts, **kwargs): + for font in fonts: + otf_to_ttf(font, **kwargs) + + +def run(path, options): + with open(path, 'rb') as f: + header = f.read(4) + + if header == b'ttcf' and options.face_index == -1: + extension = '.ttc' + font = TTCollection(path) + else: + extension = '.ttf' + font = TTFont(path, fontNumber=options.face_index) + + if options.output and not os.path.isdir(options.output): + output = options.output + else: + output = makeOutputFileName(path, outputDir=options.output, + extension=extension, + overWrite=options.overwrite) + + otf_to_ttf(font, + post_format=options.post_format, + max_err=options.max_error, + reverse_direction=options.reverse_direction) + font.save(output) + + +def main(args=None): + configLogger(logger=log) + + parser = argparse.ArgumentParser() + parser.add_argument("input", nargs='+', metavar="INPUT") + parser.add_argument("-o", "--output") + parser.add_argument("-e", "--max-error", type=float, default=MAX_ERR) + parser.add_argument("--post-format", type=float, default=POST_FORMAT) + parser.add_argument( + "--keep-direction", dest='reverse_direction', action='store_false') + parser.add_argument("--face-index", type=int, default=-1) + parser.add_argument("--overwrite", action='store_true') + options = parser.parse_args(args) + + if options.output and len(options.input) > 1: + if not os.path.isdir(options.output): + parser.error("-o/--output option must be a directory when " + "processing multiple fonts") + + files = chain.from_iterable(map(glob.glob, options.input)) + + with Pool() as pool: + pool.map(partial(run, options=options), files) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fixMacFonts/scriptLangTag.py b/fixMacFonts/scriptLangTag.py new file mode 100644 index 0000000..8e3dbd2 --- /dev/null +++ b/fixMacFonts/scriptLangTag.py @@ -0,0 +1,55 @@ +import json +import logging +import lzma +import os +import xml.etree.ElementTree as ET +from urllib.request import urlopen + +ScriptLangTagFile = os.path.splitext(__file__)[0] + '.json.xz' + +subtagUrl = 'https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry' + +likelySubtagUrl = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/likelySubtags.xml' + +logger = logging.getLogger(__name__) + + +def get(file=ScriptLangTagFile): + try: + with lzma.open(file, 'rt') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError, lzma.LZMAError): + return download(file) + + +def download(file=ScriptLangTagFile): + Script = list() + likelyLang = dict() + likelyScript = dict() + + logger.info(f'Downloading "{os.path.basename(subtagUrl)}" from {subtagUrl}') + with urlopen(subtagUrl) as f: + for line in f: + if line == b'Type: script\n': + script = f.readline().split()[1] + Script.append(script.decode('utf-8')) + + logger.info(f'Downloading "{os.path.basename(likelySubtagUrl)}" from {likelySubtagUrl}') + with urlopen(likelySubtagUrl) as f: + root = ET.fromstring(f.read()) + + for likelySubtag in root.iter('likelySubtag'): + tag = likelySubtag.get('from').replace('_', '-') + lang, script, region = likelySubtag.get('to').split('_') + + if tag.startswith('und'): + if len(tag) == 8 or len(tag) == 3: + likelyLang[script] = lang + elif len(tag) < 4: + likelyScript[lang] = script + + logger.info(f'Saving "{file}"') + with lzma.open(file, 'wt') as f: + json.dump([Script, likelyLang, likelyScript], f) + + return Script, likelyLang, likelyScript diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..016dbde --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from setuptools import setup + +setup( + name='fixMacFonts', + version='1.0.0', + packages=['fixMacFonts'], + license='apache-2.0', + description='Fixes some incompatible fonts from macOS.', + python_requires='>=3.6', + install_requires=['fontTools >= 3.22.0'], + extras_require={ + 'ttf': ['cu2qu'], + }, + entry_points={ + 'console_scripts': [ + 'fixMacFonts=fixMacFonts.fix:main', + ], + }, +)