diff --git a/bin/font-patcher b/bin/font-patcher index 7d063ab..fe7d9e8 100755 --- a/bin/font-patcher +++ b/bin/font-patcher @@ -1,11 +1,14 @@ #!/usr/bin/env python # coding=utf8 -# Nerd Fonts Version: 2.1.0 -# script version: 3.0.1 +# Nerd Fonts Version: 2.2.1 +# Script version is further down from __future__ import absolute_import, print_function, unicode_literals -version = "2.1.0" +# Change the script version when you edit this script: +script_version = "3.0.3" + +version = "2.2.1" projectName = "Nerd Fonts" projectNameAbbreviation = "NF" projectNameSingular = projectName[:-1] @@ -36,6 +39,122 @@ except ImportError: ) ) +# This is for experimenting +sys.path.insert(0, os.path.abspath(os.path.dirname(sys.argv[0])) + '/bin/scripts/name_parser/') +try: + from FontnameParser import FontnameParser + from FontnameTools import FontnameTools + FontnameParserOK = True +except ImportError: + FontnameParserOK = False + +class TableHEADWriter: + """ Access to the HEAD table without external dependencies """ + def getlong(self, pos = None): + """ Get four bytes from the font file as integer number """ + if pos: + self.goto(pos) + return (ord(self.f.read(1)) << 24) + (ord(self.f.read(1)) << 16) + (ord(self.f.read(1)) << 8) + ord(self.f.read(1)) + + def getshort(self, pos = None): + """ Get two bytes from the font file as integer number """ + if pos: + self.goto(pos) + return (ord(self.f.read(1)) << 8) + ord(self.f.read(1)) + + def putlong(self, num, pos = None): + """ Put number as four bytes into font file """ + if pos: + self.goto(pos) + self.f.write(bytearray([(num >> 24) & 0xFF, (num >> 16) & 0xFF ,(num >> 8) & 0xFF, num & 0xFF])) + self.modified = True + + def putshort(self, num, pos = None): + """ Put number as two bytes into font file """ + if pos: + self.goto(pos) + self.f.write(bytearray([(num >> 8) & 0xFF, num & 0xFF])) + self.modified = True + + def calc_checksum(self, start, end, checksum = 0): + """ Calculate a font table checksum, optionally ignoring another embedded checksum value (for table 'head') """ + self.f.seek(start) + for i in range(start, end - 4, 4): + checksum += self.getlong() + checksum &= 0xFFFFFFFF + i += 4 + extra = 0 + for j in range(4): + if i + j <= end: + extra += ord(self.f.read(1)) + extra = extra << 8 + checksum = (checksum + extra) & 0xFFFFFFFF + return checksum + + def find_head_table(self): + """ Search all tables for the HEAD table and store its metadata """ + self.f.seek(4) + numtables = self.getshort() + self.f.seek(3*2, 1) + + for i in range(numtables): + tab_name = self.f.read(4) + self.tab_check_offset = self.f.tell() + self.tab_check = self.getlong() + self.tab_offset = self.getlong() + self.tab_length = self.getlong() + if tab_name == b'head': + return + raise Exception('No HEAD table found') + + def goto(self, where): + """ Go to a named location in the file or to the specified index """ + if type(where) is str: + positions = {'checksumAdjustment': 2+2+4, + 'flags': 2+2+4+4+4, + 'lowestRecPPEM': 2+2+4+4+4+2+2+8+8+2+2+2+2+2, + } + where = self.tab_offset + positions[where] + self.f.seek(where) + + + def calc_full_checksum(self, check = False): + """ Calculate the whole file's checksum """ + self.f.seek(0, 2) + self.end = self.f.tell() + full_check = self.calc_checksum(0, self.end, (-self.checksum_adj) & 0xFFFFFFFF) + if check and (0xB1B0AFBA - full_check) & 0xFFFFFFFF != self.checksum_adj: + sys.exit("Checksum of whole font is bad") + return full_check + + def calc_table_checksum(self, check = False): + tab_check_new = self.calc_checksum(self.tab_offset, self.tab_offset + self.tab_length - 1, (-self.checksum_adj) & 0xFFFFFFFF) + if check and tab_check_new != self.tab_check: + sys.exit("Checksum of 'head' in font is bad") + return tab_check_new + + def reset_table_checksum(self): + new_check = self.calc_table_checksum() + self.putlong(new_check, self.tab_check_offset) + + def reset_full_checksum(self): + new_adj = (0xB1B0AFBA - self.calc_full_checksum()) & 0xFFFFFFFF + self.putlong(new_adj, 'checksumAdjustment') + + def close(self): + self.f.close() + + + def __init__(self, filename): + self.modified = False + self.f = open(filename, 'r+b') + + self.find_head_table() + + self.flags = self.getshort('flags') + self.lowppem = self.getshort('lowestRecPPEM') + self.checksum_adj = self.getlong('checksumAdjustment') + class font_patcher: def __init__(self): @@ -44,7 +163,6 @@ class font_patcher: self.config = None # class 'configparser.ConfigParser' self.sourceFont = None # class 'fontforge.font' self.octiconsExactEncodingPosition = True - self.fontlinuxExactEncodingPosition = True self.patch_set = None # class 'list' self.font_dim = None # class 'dict' self.onlybitmaps = 0 @@ -62,7 +180,8 @@ class font_patcher: self.sourceFont = fontforge.open(self.args.font, 1) # 1 = ("fstypepermitted",)) except Exception: sys.exit(projectName + ": Can not open font, try to open with fontforge interactively to get more information") - self.setup_font_names() + self.setup_version() + self.setup_name_backup() self.remove_ligatures() make_sure_path_exists(self.args.outputdir) self.check_position_conflicts() @@ -81,7 +200,7 @@ class font_patcher: def patch(self): - print("{} Patcher v{} executing\n".format(projectName, version)) + print("{} Patcher v{} ({}) executing\n".format(projectName, version, script_version)) if self.args.single: # Force width to be equal on all glyphs to ensure the font is considered monospaced on Windows. @@ -124,19 +243,53 @@ class font_patcher: if symfont: symfont.close() - print("\nDone with Patch Sets, generating font...") + # The grave accent and fontforge: + # If the type is 'auto' fontforge changes it to 'mark' on export. + # We can not prevent this. So set it to 'baseglyph' instead, as + # that resembles the most common expectations. + # This is not needed with fontforge March 2022 Release anymore. + if "grave" in self.sourceFont: + self.sourceFont["grave"].glyphclass="baseglyph" + + + def generate(self): # the `PfEd-comments` flag is required for Fontforge to save '.comment' and '.fontlog'. if self.sourceFont.fullname != None: - self.sourceFont.generate(self.args.outputdir + "/" + self.sourceFont.fullname + self.extension, flags=(str('opentype'), str('PfEd-comments'))) - print("\nGenerated: {}".format(self.sourceFont.fontname)) + outfile = self.args.outputdir + "/" + self.sourceFont.fullname + self.extension + self.sourceFont.generate(outfile, flags=(str('opentype'), str('PfEd-comments'))) + message = "\nGenerated: {} in '{}'".format(self.sourceFont.fullname, outfile) else: - self.sourceFont.generate(self.args.outputdir + "/" + self.sourceFont.cidfontname + self.extension, flags=(str('opentype'), str('PfEd-comments'))) - print("\nGenerated: {}".format(self.sourceFont.fullname)) + outfile = self.args.outputdir + "/" + self.sourceFont.cidfontname + self.extension + self.sourceFont.generate(outfile, flags=(str('opentype'), str('PfEd-comments'))) + message = "\nGenerated: {} in '{}'".format(self.sourceFont.fontname, outfile) + + # Adjust flags that can not be changed via fontforge + try: + source_font = TableHEADWriter(self.args.font) + dest_font = TableHEADWriter(outfile) + if source_font.flags & 0x08 == 0 and dest_font.flags & 0x08 != 0: + print("Changing flags from 0x{:X} to 0x{:X}".format(dest_font.flags, dest_font.flags & ~0x08)) + dest_font.putshort(dest_font.flags & ~0x08, 'flags') # clear 'ppem_to_int' + if source_font.lowppem != dest_font.lowppem: + print("Changing lowestRecPPEM from {} to {}".format(dest_font.lowppem, source_font.lowppem)) + dest_font.putshort(source_font.lowppem, 'lowestRecPPEM') + if dest_font.modified: + dest_font.reset_table_checksum() + dest_font.reset_full_checksum() + except Exception as error: + print("Can not handle font flags ({})".format(repr(error))) + finally: + try: + source_font.close() + dest_font.close() + except: + pass + print(message) if self.args.postprocess: - subprocess.call([self.args.postprocess, self.args.outputdir + "/" + self.sourceFont.fullname + self.extension]) - print("\nPost Processed: {}".format(self.sourceFont.fullname)) + subprocess.call([self.args.postprocess, outfile]) + print("\nPost Processed: {}".format(outfile)) def setup_arguments(self): @@ -166,12 +319,14 @@ class font_patcher: parser.add_argument('-ext', '--extension', dest='extension', default="", type=str, nargs='?', help='Change font file type to create (e.g., ttf, otf)') parser.add_argument('-out', '--outputdir', dest='outputdir', default=".", type=str, nargs='?', help='The directory to output the patched font file to') parser.add_argument('--glyphdir', dest='glyphdir', default=__dir__ + "/src/glyphs/", type=str, nargs='?', help='Path to glyphs to be used for patching') + parser.add_argument('--makegroups', dest='makegroups', default=False, action='store_true', help='Use alternative method to name patched fonts (experimental)') # progress bar arguments - https://stackoverflow.com/questions/15008758/parsing-boolean-values-with-argparse progressbars_group_parser = parser.add_mutually_exclusive_group(required=False) progressbars_group_parser.add_argument('--progressbars', dest='progressbars', action='store_true', help='Show percentage completion progress bars per Glyph Set') progressbars_group_parser.add_argument('--no-progressbars', dest='progressbars', action='store_false', help='Don\'t show percentage completion progress bars per Glyph Set') parser.set_defaults(progressbars=True) + parser.add_argument('--also-windows', dest='alsowindows', default=False, action='store_true', help='Create two fonts, the normal and the --windows version') # symbol fonts to include arguments sym_font_group = parser.add_argument_group('Symbol Fonts') @@ -189,6 +344,9 @@ class font_patcher: self.args = parser.parse_args() + if self.args.makegroups and not FontnameParserOK: + sys.exit(projectName + ": FontnameParser module missing (bin/scripts/name_parser/Fontname*), can not --makegroups".format(projectName)) + # if you add a new font, set it to True here inside the if condition if self.args.complete: self.args.fontawesome = True @@ -219,6 +377,9 @@ class font_patcher: font_complete = False self.args.complete = font_complete + if self.args.alsowindows: + self.args.windows = False + # this one also works but it needs to be updated every time a font is added # it was a conditional in self.setup_font_names() before, but it was missing # a symbol font, so it would name the font complete without being so sometimes. @@ -239,7 +400,17 @@ class font_patcher: # ]) + def setup_name_backup(self): + """ Store the original font names to be able to rename the font multiple times """ + self.original_fontname = self.sourceFont.fontname + self.original_fullname = self.sourceFont.fullname + self.original_familyname = self.sourceFont.familyname + + def setup_font_names(self): + self.sourceFont.fontname = self.original_fontname + self.sourceFont.fullname = self.original_fullname + self.sourceFont.familyname = self.original_familyname verboseAdditionalFontNameSuffix = " " + projectNameSingular if self.args.windows: # attempt to shorten here on the additional name BEFORE trimming later additionalFontNameSuffix = " " + projectNameAbbreviation @@ -285,6 +456,27 @@ class font_patcher: additionalFontNameSuffix += " M" verboseAdditionalFontNameSuffix += " Mono" + if FontnameParserOK and self.args.makegroups: + use_fullname = type(self.sourceFont.fullname) == str # Usually the fullname is better to parse + # Use fullname if it is 'equal' to the fontname + if self.sourceFont.fullname: + use_fullname |= self.sourceFont.fontname.lower() == FontnameTools.postscript_char_filter(self.sourceFont.fullname).lower() + # Use fullname for any of these source fonts (that are impossible to disentangle from the fontname, we need the blanks) + for hit in [ 'Meslo' ]: + use_fullname |= self.sourceFont.fontname.lower().startswith(hit.lower()) + parser_name = self.sourceFont.fullname if use_fullname else self.sourceFont.fontname + # Gohu fontnames hide the weight, but the file names are ok... + if parser_name.startswith('Gohu'): + parser_name = os.path.splitext(os.path.basename(self.args.font))[0] + n = FontnameParser(parser_name) + if not n.parse_ok: + print("Have only minimal naming information, check resulting name. Maybe omit --makegroups option") + n.drop_for_powerline() + n.enable_short_families(True, "Noto") + n.set_for_windows(self.args.windows) + + # All the following stuff is ignored in makegroups-mode + # basically split the font name around the dash "-" to get the fontname and the style (e.g. Bold) # this does not seem very reliable so only use the style here as a fallback if the font does not # have an internal style defined (in sfnt_names) @@ -343,8 +535,9 @@ class font_patcher: familyname += " Mono" # Don't truncate the subfamily to keep fontname unique. MacOS treats fonts with - # the same name as the same font, even if subFamily is different. - fontname += '-' + subFamily + # the same name as the same font, even if subFamily is different. Make sure to + # keep the resulting fontname (PostScript name) valid by removing spaces. + fontname += '-' + subFamily.replace(' ', '') # rename font # @@ -418,18 +611,28 @@ class font_patcher: fullname = replace_font_name(fullname, additionalFontNameReplacements2) fontname = replace_font_name(fontname, additionalFontNameReplacements2) - # replace any extra whitespace characters: - self.sourceFont.familyname = " ".join(familyname.split()) - self.sourceFont.fullname = " ".join(fullname.split()) - self.sourceFont.fontname = " ".join(fontname.split()) + if not (FontnameParserOK and self.args.makegroups): + # replace any extra whitespace characters: + self.sourceFont.familyname = " ".join(familyname.split()) + self.sourceFont.fullname = " ".join(fullname.split()) + self.sourceFont.fontname = " ".join(fontname.split()) + + self.sourceFont.appendSFNTName(str('English (US)'), str('Preferred Family'), self.sourceFont.familyname) + self.sourceFont.appendSFNTName(str('English (US)'), str('Family'), self.sourceFont.familyname) + self.sourceFont.appendSFNTName(str('English (US)'), str('Compatible Full'), self.sourceFont.fullname) + self.sourceFont.appendSFNTName(str('English (US)'), str('SubFamily'), subFamily) + else: + fam_suffix = projectNameSingular if not self.args.windows else projectNameAbbreviation + fam_suffix += ' Mono' if self.args.single else '' + n.inject_suffix(verboseAdditionalFontNameSuffix, additionalFontNameSuffix, fam_suffix) + n.rename_font(self.sourceFont) - self.sourceFont.appendSFNTName(str('English (US)'), str('Preferred Family'), self.sourceFont.familyname) - self.sourceFont.appendSFNTName(str('English (US)'), str('Family'), self.sourceFont.familyname) - self.sourceFont.appendSFNTName(str('English (US)'), str('Compatible Full'), self.sourceFont.fullname) - self.sourceFont.appendSFNTName(str('English (US)'), str('SubFamily'), subFamily) self.sourceFont.comment = projectInfo self.sourceFont.fontlog = projectInfo + + def setup_version(self): + """ Add the Nerd Font version to the original version """ # print("Version was {}".format(sourceFont.version)) if self.sourceFont.version != None: self.sourceFont.version += ";" + projectName + " " + version @@ -463,8 +666,6 @@ class font_patcher: # Prevent glyph encoding position conflicts between glyph sets if self.args.fontawesome and self.args.octicons: self.octiconsExactEncodingPosition = False - if self.args.fontawesome or self.args.octicons: - self.fontlinuxExactEncodingPosition = False def setup_patch_set(self): @@ -603,7 +804,7 @@ class font_patcher: {'Enabled': self.args.powersymbols, 'Name': "Power Symbols", 'Filename': "Unicode_IEC_symbol_font.otf", 'Exact': True, 'SymStart': 0x2B58, 'SymEnd': 0x2B58, 'SrcStart': None, 'SrcEnd': None, 'ScaleGlyph': None, 'Attributes': SYM_ATTR_DEFAULT}, # Heavy Circle (aka Power Off) {'Enabled': self.args.material, 'Name': "Material", 'Filename': "materialdesignicons-webfont.ttf", 'Exact': False, 'SymStart': 0xF001, 'SymEnd': 0xF847, 'SrcStart': 0xF500, 'SrcEnd': 0xFD46, 'ScaleGlyph': None, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.weather, 'Name': "Weather Icons", 'Filename': "weather-icons/weathericons-regular-webfont.ttf", 'Exact': False, 'SymStart': 0xF000, 'SymEnd': 0xF0EB, 'SrcStart': 0xE300, 'SrcEnd': 0xE3EB, 'ScaleGlyph': None, 'Attributes': SYM_ATTR_DEFAULT}, - {'Enabled': self.args.fontlinux, 'Name': "Font Logos (Font Linux)", 'Filename': "font-logos.ttf", 'Exact': self.fontlinuxExactEncodingPosition, 'SymStart': 0xF100, 'SymEnd': 0xF12D, 'SrcStart': 0xF300, 'SrcEnd': 0xF32D, 'ScaleGlyph': None, 'Attributes': SYM_ATTR_DEFAULT}, + {'Enabled': self.args.fontlinux, 'Name': "Font Logos (Font Linux)", 'Filename': "font-logos.ttf", 'Exact': True, 'SymStart': 0xF300, 'SymEnd': 0xF32F, 'SrcStart': None, 'SrcEnd': None , 'ScaleGlyph': None, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': self.octiconsExactEncodingPosition, 'SymStart': 0xF000, 'SymEnd': 0xF105, 'SrcStart': 0xF400, 'SrcEnd': 0xF505, 'ScaleGlyph': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Magnifying glass {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': self.octiconsExactEncodingPosition, 'SymStart': 0x2665, 'SymEnd': 0x2665, 'SrcStart': None, 'SrcEnd': None, 'ScaleGlyph': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Heart {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': self.octiconsExactEncodingPosition, 'SymStart': 0X26A1, 'SymEnd': 0X26A1, 'SrcStart': None, 'SrcEnd': None, 'ScaleGlyph': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Zap @@ -653,7 +854,9 @@ class font_patcher: # Ignore the y-values, os2_winXXXXX values set above are used for line height # # 0x00-0x17f is the Latin Extended-A range - for glyph in range(0x00, 0x17f): + for glyph in range(0x21, 0x17f): + if glyph in range(0x7F, 0xBF): + continue # ignore special characters like '1/4' etc try: (_, _, xmax, _) = self.sourceFont[glyph].boundingBox() except TypeError: @@ -665,6 +868,17 @@ class font_patcher: # Calculate font height self.font_dim['height'] = abs(self.font_dim['ymin']) + self.font_dim['ymax'] + if self.font_dim['height'] == 0: + # This can only happen if the input font is empty + # Assume we are using our prepared templates + self.font_dim = { + 'xmin' : 0, + 'ymin' : -self.sourceFont.descent, + 'xmax' : self.sourceFont.em, + 'ymax' : self.sourceFont.ascent, + 'width' : self.sourceFont.em, + 'height': abs(self.sourceFont.descent) + self.sourceFont.ascent, + } def get_scale_factor(self, sym_dim): @@ -1030,6 +1244,14 @@ def main(): check_fontforge_min_version() patcher = font_patcher() patcher.patch() + print("\nDone with Patch Sets, generating font...\n") + patcher.setup_font_names() + patcher.generate() + # This mainly helps to improve CI runtime + if patcher.args.alsowindows: + patcher.args.windows = True + patcher.setup_font_names() + patcher.generate() if __name__ == "__main__": diff --git a/src/glyphs/font-logos.ttf b/src/glyphs/font-logos.ttf index fa1175d..e8cea1c 100644 Binary files a/src/glyphs/font-logos.ttf and b/src/glyphs/font-logos.ttf differ