diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ac1609 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/font-patcher-log.txt +__pycache__/ diff --git a/Makefile b/Makefile deleted file mode 100644 index 3624c94..0000000 --- a/Makefile +++ /dev/null @@ -1,14 +0,0 @@ -all: update iosevka - -update: - ./update.sh - -# Keep the patched font files -clean: - rm -rf original/*.ttf - rm -rf original/*.ttx - rm -rf patched/*.ttx - rm -rf patched/*.original.ttf - -iosevka: clean - ./patch_Iosevka.sh $(IOSEVKA_VERSION) diff --git a/bin/scripts/name_parser/FontnameParser.py b/bin/scripts/name_parser/FontnameParser.py new file mode 100644 index 0000000..2fb2060 --- /dev/null +++ b/bin/scripts/name_parser/FontnameParser.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python +# coding=utf8 + +import re +from FontnameTools import FontnameTools + +class FontnameParser: + """Parse a font name and generate all kinds of names""" + + def __init__(self, filename, logger): + """Parse a font filename and store the results""" + self.parse_ok = False + self.use_short_families = (False, False, False) # ( camelcase name, short styles, aggressive ) + self.keep_regular_in_family = None # None = auto, True, False + self.suppress_preferred_if_identical = True + self.family_suff = '' + self.ps_fontname_suff = '' + self.short_family_suff = '' + self.name_subst = [] + [ self.parse_ok, self._basename, self.weight_token, self.style_token, self.other_token, self._rest ] = FontnameTools.parse_font_name(filename) + self.basename = self._basename + self.rest = self._rest + self.add_name_substitution_table(FontnameTools.SIL_TABLE) + self.rename_oblique = True + self.logger = logger + + def _make_ps_name(self, n, is_family): + """Helper to limit font name length in PS names""" + fam = 'family ' if is_family else '' + limit = 31 if is_family else 63 + if len(n) <= limit: + return n + r = re.search('(.*)(-.*)', n) + if not r: + new_n = n[:limit] + else: + q = limit - len(r.groups()[1]) + if q < 1: + q = 1 + self.logger.error('====-< Shortening too long PS {}name: Garbage warning'. format(fam)) + new_n = r.groups()[0][:q] + r.groups()[1] + if new_n != n: + self.logger.error('====-< Shortening too long PS {}name: {} -> {}'.format(fam, n, new_n)) + return new_n + + def _shortened_name(self): + """Return a blank free basename-rest combination""" + if not self.use_short_families[0]: + return (self.basename, self.rest) + else: + return (FontnameTools.concat(self.basename, self.rest).replace(' ', ''), '') + + def set_keep_regular_in_family(self, keep): + """Familyname may contain 'Regular' where it should normally be suppressed""" + self.keep_regular_in_family = keep + + def set_expect_no_italic(self, noitalic): + """Prevents rewriting Oblique as family name part""" + # To prevent naming clashes usually Oblique is moved out in the family name + # because some fonts have Italic and Oblique, and we want to generate pure + # RIBBI families in ID1/2. + # But some fonts have Oblique instead of Italic, here the prevential movement + # is not needed, or rather contraproductive. This can not be detected on a + # font file level but needs to be specified per family from the outside. + # Returns true if setting was successful. + if 'Italic' in self.style_token: + self.rename_oblique = True + return not noitalic + self.rename_oblique = not noitalic + return True + + def set_suppress_preferred(self, suppress): + """Suppress ID16/17 if it is identical to ID1/2 (True is default)""" + self.suppress_preferred_if_identical = suppress + + def inject_suffix(self, family, ps_fontname, short_family): + """Add a custom additonal string that shows up in the resulting names""" + self.family_suff = family.strip() + self.ps_fontname_suff = ps_fontname.replace(' ', '') + self.short_family_suff = short_family.strip() + return self + + def enable_short_families(self, camelcase_name, prefix, aggressive): + """Enable short styles in Family when (original) font name starts with prefix; enable CamelCase basename in (Typog.) Family""" + # camelcase_name is boolean + # prefix is either a string or False/True + if isinstance(prefix, str): + prefix = self._basename.startswith(prefix) + self.use_short_families = ( camelcase_name, prefix, aggressive ) + return self + + def add_name_substitution_table(self, table): + """Have some fonts renamed, takes list of tuples (regex, replacement)""" + # The regex will be anchored to name begin and used case insensitive + # Replacement can have regex matches, mind to catch the correct source case + self.name_subst = table + self.basename = self._basename + self.rest = self._rest + for regex, replacement in self.name_subst: + base_and_rest = self.basename + (' ' + self.rest if len(self.rest) else '') + m = re.match(regex, base_and_rest, re.IGNORECASE) + if not m: + continue + i = len(self.basename) - len(m.group(0)) + if i < 0: + self.basename = m.expand(replacement).rstrip() + self.rest = self.rest[-(i+1):].lstrip() + else: + self.basename = m.expand(replacement) + self.basename[len(m.group(0)):] + return self + + def drop_for_powerline(self): + """Remove 'for Powerline' from all names (can not be undone)""" + if 'Powerline' in self.other_token: + idx = self.other_token.index('Powerline') + self.other_token.pop(idx) + if idx > 0 and self.other_token[idx - 1] == 'For': + self.other_token.pop(idx - 1) + self._basename = re.sub(r'(\b|for\s?)?powerline\b', '', self._basename, 1, re.IGNORECASE).strip() + self.add_name_substitution_table(self.name_subst) # re-evaluate + return self + + ### Following the creation of the name parts: + # + # Relevant websites + # https://www.fonttutorials.com/how-to-name-font-family/ + # https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids + # https://docs.microsoft.com/en-us/typography/opentype/spec/os2#fss + # https://docs.microsoft.com/en-us/typography/opentype/spec/head#macstyle + + # Example (mind that they group 'semibold' as classic-group-of-4 Bold, while we will always only take bold as Bold): + # Adobe Caslon Pro Regular ID1: Adobe Caslon Pro ID2: Regular + # Adobe Caslon Pro Italic ID1: Adobe Caslon Pro ID2: Italic + # Adobe Caslon Pro Semibold ID1: Adobe Caslon Pro ID2: Bold ID16: Adobe Caslon Pro ID17: Semibold + # Adobe Caslon Pro Semibold Italic ID1: Adobe Caslon Pro ID2: Bold Italic ID16: Adobe Caslon Pro ID17: Semibold Italic + # Adobe Caslon Pro Bold ID1: Adobe Caslon Pro Bold ID2: Regular ID16: Adobe Caslon Pro ID17: Bold + # Adobe Caslon Pro Bold Italic ID1: Adobe Caslon Pro Bold ID2: Italic ID16: Adobe Caslon Pro ID17: Bold Italic + + # fontname === preferred_family + preferred_styles + # fontname === family + subfamily + # + # familybase = basename + rest + other (+ suffix) + # ID 1/2 just have self.style in the subfamily, all the rest ends up in the family + # ID 16/17 have self.style and self.weight in the subfamily, the rest ends up in the family + + def fullname(self): + """Get the SFNT Fullname (ID 4)""" + styles = self.style_token + weights = self.weight_token + if self.keep_regular_in_family == None: + keep_regular = FontnameTools.is_keep_regular(self._basename + ' ' + self._rest) + else: + keep_regular = self.keep_regular_in_family + if ('Regular' in styles + and (not keep_regular + or len(self.weight_token) > 0)): # This is actually a malformed font name + styles = list(self.style_token) + styles.remove('Regular') + # For naming purposes we want Oblique to be part of the styles + (weights, styles) = FontnameTools.make_oblique_style(weights, styles) + (name, rest) = self._shortened_name() + if self.use_short_families[1]: + [ weights, styles ] = FontnameTools.short_styles([ weights, styles ], self.use_short_families[2]) + return FontnameTools.concat(name, rest, self.other_token, self.short_family_suff, weights, styles) + + def psname(self): + """Get the SFNT PostScriptName (ID 6)""" + # This is almost self.family() + '-' + self.subfamily() + (name, rest) = self._shortened_name() + styles = self.style_token + weights = self.weight_token + if self.use_short_families[1]: + styles = FontnameTools.short_styles(styles, self.use_short_families[2]) + weights = FontnameTools.short_styles(weights, self.use_short_families[2]) + fam = FontnameTools.camel_casify(FontnameTools.concat(name, rest, self.other_token, self.ps_fontname_suff)) + sub = FontnameTools.camel_casify(FontnameTools.concat(weights, styles)) + if len(sub) > 0: + sub = '-' + sub + fam = FontnameTools.postscript_char_filter(fam) + sub = FontnameTools.postscript_char_filter(sub) + return self._make_ps_name(fam + sub, False) + + def preferred_family(self): + """Get the SFNT Preferred Familyname (ID 16)""" + (name, rest) = self._shortened_name() + pfn = FontnameTools.concat(name, rest, self.other_token, self.family_suff) + if self.suppress_preferred_if_identical and pfn == self.family(): + # Do not set if identical to ID 1 + return '' + return pfn + + def preferred_styles(self): + """Get the SFNT Preferred Styles (ID 17)""" + styles = self.style_token + weights = self.weight_token + # For naming purposes we want Oblique to be part of the styles + (weights, styles) = FontnameTools.make_oblique_style(weights, styles) + pfs = FontnameTools.concat(weights, styles) + if self.suppress_preferred_if_identical and pfs == self.subfamily(): + # Do not set if identical to ID 2 + return '' + return pfs + + def family(self): + """Get the SFNT Familyname (ID 1)""" + # We use the short form of the styles to save on number of chars + (name, rest) = self._shortened_name() + other = self.other_token + weights = self.weight_token + aggressive = self.use_short_families[2] + if not self.rename_oblique: + (weights, styles) = FontnameTools.make_oblique_style(weights, []) + if self.use_short_families[1]: + [ other, weights ] = FontnameTools.short_styles([ other, weights ], aggressive) + weights = [ w if w != 'Oblique' else 'Obl' for w in weights ] + return FontnameTools.concat(name, rest, other, self.short_family_suff, weights) + + def subfamily(self): + """Get the SFNT SubFamily (ID 2)""" + styles = self.style_token + weights = self.weight_token + if not self.rename_oblique: + (weights, styles) = FontnameTools.make_oblique_style(weights, styles) + if len(styles) == 0: + if 'Oblique' in weights: + return FontnameTools.concat(styles, 'Italic') + return 'Regular' + if 'Oblique' in weights and not 'Italic' in styles: + return FontnameTools.concat(styles, 'Italic') + return FontnameTools.concat(styles) + + def ps_familyname(self): + """Get the PS Familyname""" + fam = self.preferred_family() + if len(fam) < 1: + fam = self.family() + return self._make_ps_name(fam, True) + + def macstyle(self, style): + """Modify a given macStyle value for current name, just bits 0 and 1 touched""" + b = style & (~3) + b |= 1 if 'Bold' in self.style_token else 0 + b |= 2 if 'Italic' in self.style_token else 0 + return b + + def fs_selection(self, fs): + """Modify a given fsSelection value for current name, bits 0, 5, 6, 8, 9 touched""" + ITALIC = 1 << 0; BOLD = 1 << 5; REGULAR = 1 << 6; WWS = 1 << 8; OBLIQUE = 1 << 9 + b = fs & (~(ITALIC | BOLD | REGULAR | WWS | OBLIQUE)) + if 'Bold' in self.style_token: + b |= BOLD + # Ignore Italic if we have Oblique + if 'Oblique' in self.weight_token: + b |= OBLIQUE + elif 'Italic' in self.style_token: + b |= ITALIC + # Regular is just the basic weight + if len(self.weight_token) == 0: + b |= REGULAR + b |= WWS # We assert this by our naming process + return b + + def checklen(self, max_len, entry_id, name): + """Check the length of a name string and report violations""" + if len(name) <= max_len: + self.logger.debug('=====> {:18} ok ({:2} <={:2}): {}'.format(entry_id, len(name), max_len, name)) + else: + self.logger.error('====-< {:18} too long ({:2} > {:2}): {}'.format(entry_id, len(name), max_len, name)) + return name + + def rename_font(self, font): + """Rename the font to include all information we found (font is fontforge font object)""" + font.fondname = None + font.fontname = self.psname() + font.fullname = self.fullname() + font.familyname = self.ps_familyname() + + # We have to work around several issues in fontforge: + # + # a. Remove some entries from SFNT table; fontforge has no API function for that + # + # b. Fontforge does not allow to set SubFamily (and other) to any value: + # + # Fontforge lets you set any value, unless it is the default value. If it + # is the default value it does not set anything. It also does not remove + # a previously existing non-default value. Why it is done this way is + # unclear: + # fontforge/python.c SetSFNTName() line 11431 + # return( 1 ); /* If they set it to the default, there's nothing to do */ + # + # Then is the question: What is the default? It is taken from the + # currently set fontname (??!). The fontname is parsed and everything + # behind the dash is the default SubFamily: + # fontforge/tottf.c DefaultTTFEnglishNames() + # fontforge/splinefont.c _GetModifiers() + # + # To fix this without touching Fontforge we need to set the SubFamily + # directly in the SFNT table: + # + # c. Fontforge has the bug that it allows to write empty-string to a SFNT field + # and it is actually embedded as empty string, but empty strings are not + # shown if you query the sfnt_names *rolleyes* + + version_tag = '' + sfnt_list = [] + TO_DEL = ['Family', 'SubFamily', 'Fullname', 'PostScriptName', 'Preferred Family', + 'Preferred Styles', 'Compatible Full', 'WWS Family', 'WWS Subfamily', + 'UniqueID', 'CID findfont Name'] + # Remove these entries in all languages and add (at least the vital ones) some + # back, but only as 'English (US)'. This makes sure we do not leave contradicting + # names over different languages. + for l, k, v in list(font.sfnt_names): + if not k in TO_DEL: + sfnt_list += [( l, k, v )] + if k == 'Version' and l == 'English (US)': + version_tag = ' ' + v.split()[-1] + + sfnt_list += [( 'English (US)', 'Family', self.checklen(31, 'Family (ID 1)', self.family()) )] # 1 + sfnt_list += [( 'English (US)', 'SubFamily', self.checklen(31, 'SubFamily (ID 2)', self.subfamily()) )] # 2 + sfnt_list += [( 'English (US)', 'UniqueID', self.fullname() + version_tag )] # 3 + sfnt_list += [( 'English (US)', 'Fullname', self.checklen(63, 'Fullname (ID 4)', self.fullname()) )] # 4 + sfnt_list += [( 'English (US)', 'PostScriptName', self.checklen(63, 'PSN (ID 6)', self.psname()) )] # 6 + + p_fam = self.preferred_family() + if len(p_fam): + sfnt_list += [( 'English (US)', 'Preferred Family', self.checklen(31, 'PrefFamily (ID 16)', p_fam) )] # 16 + p_sty = self.preferred_styles() + if len(p_sty): + sfnt_list += [( 'English (US)', 'Preferred Styles', self.checklen(31, 'PrefStyles (ID 17)', p_sty) )] # 17 + + font.sfnt_names = tuple(sfnt_list) + + font.macstyle = self.macstyle(0) + font.os2_stylemap = self.fs_selection(0) diff --git a/bin/scripts/name_parser/FontnameTools.py b/bin/scripts/name_parser/FontnameTools.py new file mode 100644 index 0000000..f4a9c13 --- /dev/null +++ b/bin/scripts/name_parser/FontnameTools.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python +# coding=utf8 + +import re +import sys + +class FontnameTools: + """Deconstruct a font filename to get standardized name parts""" + + @staticmethod + def front_upper(word): + """Capitalize a string (but keep case of subsequent chars)""" + return word[:1].upper() + word[1:] + + @staticmethod + def camel_casify(word): + """Remove blanks and use CamelCase for the new word""" + return ''.join(map(FontnameTools.front_upper, word.split(' '))) + + @staticmethod + def camel_explode(word): + """Explode CamelCase -> Camel Case""" + # But do not explode "JetBrains" etc at string start... + excludes = [ + 'JetBrains', + 'DejaVu', + 'OpenDyslexicAlta', + 'OpenDyslexicMono', + 'OpenDyslexic', + 'DaddyTimeMono', + 'InconsolataGo', + 'ProFontWindows', + 'ProFont', + 'ProggyClean', + ] + m = re.match('(' + '|'.join(excludes) + ')(.*)', word) + (prefix, word) = m.group(1,2) if m != None else ('', word) + if len(word) == 0: + return prefix + parts = re.split('(?<=[a-z0-9])(?=[A-Z])', word) + if len(prefix): + parts.insert(0, prefix) + return ' '.join(parts) + + @staticmethod + def drop_empty(l): + """Remove empty strings from list of strings""" + return [x for x in l if len(x) > 0] + + @staticmethod + def concat(*all_things): + """Flatten list of (strings or lists of strings) to a blank-separated string""" + all = [] + for thing in all_things: + if type(thing) is not list: + all.append(thing) + else: + all += thing + return ' '.join(FontnameTools.drop_empty(all)) + + @staticmethod + def unify_style_names(style_name): + """Substitude some known token with standard wording""" + known_names = { + # Source of the table is the current sourcefonts + # Left side needs to be lower case + '-': '', + 'book': '', + 'text': '', + 'ce': 'CE', + #'semibold': 'Demi', + 'ob': 'Oblique', + 'it': 'Italic', + 'i': 'Italic', + 'b': 'Bold', + 'normal': 'Regular', + 'c': 'Condensed', + 'r': 'Regular', + 'm': 'Medium', + 'l': 'Light', + } + if style_name in known_names: + return known_names[style_name.lower()] + return style_name + + @staticmethod + def find_in_dicts(key, dicts): + """Find an entry in a list of dicts, return entry and in which list it was""" + for i, d in enumerate(dicts): + if key in d: + return ( d[key], i ) + return (None, 0) + + @staticmethod + def get_shorten_form_idx(aggressive, prefix, form_if_prefixed): + """Get the tuple index of known_* data tables""" + if aggressive: + return 0 + if len(prefix): + return form_if_prefixed + return 1 + + @staticmethod + def shorten_style_name(name, aggressive): + """Substitude some known styles to short form""" + # If aggressive is False create the mild short form + # aggressive == True: Always use first form of everything + # aggressive == False: + # - has no modifier: use the second form + # - has modifier: use second form of mod plus first form of weights2 + # - has modifier: use second form of mod plus second form of widths + name_rest = name + name_pre = '' + form = FontnameTools.get_shorten_form_idx(aggressive, '', 0) + for mod in FontnameTools.known_modifiers: + if name.startswith(mod) and len(name) > len(mod): # Second condition specifically for 'Demi' + name_pre = FontnameTools.known_modifiers[mod][form] + name_rest = name[len(mod):] + break + subst, i = FontnameTools.find_in_dicts(name_rest, [ FontnameTools.known_weights2, FontnameTools.known_widths ]) + form = FontnameTools.get_shorten_form_idx(aggressive, name_pre, i) + if isinstance(subst, tuple): + return name_pre + subst[form] + if not len(name_pre): + # The following sets do not allow modifiers + subst, _ = FontnameTools.find_in_dicts(name_rest, [ FontnameTools.known_weights1, FontnameTools.known_slopes ]) + if isinstance(subst, tuple): + return subst[form] + return name + + @staticmethod + def short_styles(lists, aggressive): + """Shorten all style names in a list or a list of lists""" + if not len(lists) or not isinstance(lists[0], list): + return list(map(lambda x: FontnameTools.shorten_style_name(x, aggressive), lists)) + return [ list(map(lambda x: FontnameTools.shorten_style_name(x, aggressive), styles)) for styles in lists ] + + @staticmethod + def make_oblique_style(weights, styles): + """Move "Oblique" from weights to styles for font naming purposes""" + if 'Oblique' in weights: + weights = list(weights) + weights.remove('Oblique') + styles = list(styles) + styles.append('Oblique') + return (weights, styles) + + @staticmethod + def get_name_token(name, tokens, allow_regex_token = False): + """Try to find any case insensitive token from tokens in the name, return tuple with found token-list and rest""" + # The default mode (allow_regex_token = False) will try to find any verbatim string in the + # tokens list (case insensitive matching) and give that tokens list item back with + # unchanged case (i.e. [ 'Bold' ] will match "bold" and return it as [ 'Bold', ] + # In the regex mode (allow_regex_token = True) it will use the tokens elements as + # regexes and return the original (i.e. from name) case. + # + # Token are always used in a regex and may not capture, use non capturing + # grouping if needed (?: ... ) + lower_tokens = [ t.lower() for t in tokens ] + not_matched = "" + all_tokens = [] + j = 1 + regex = re.compile('(.*?)(' + '|'.join(tokens) + ')(.*)', re.IGNORECASE) + while j: + j = regex.match(name) + if not j: + break + if len(j.groups()) != 3: + sys.exit('Malformed regex in FontnameTools.get_name_token()') + not_matched += ' ' + j.groups()[0] # Blanc prevents unwanted concatenation of unmatched substrings + tok = j.groups()[1].lower() + if tok in lower_tokens: + tok = tokens[lower_tokens.index(tok)] + tok = FontnameTools.unify_style_names(tok) + if len(tok): + all_tokens.append(tok) + name = j.groups()[2] # Recurse rest + not_matched += ' ' + name + return ( not_matched.strip(), all_tokens ) + + @staticmethod + def postscript_char_filter(name): + """Filter out characters that are not allowed in Postscript names""" + # The name string must be restricted to the printable ASCII subset, codes 33 to 126, + # except for the 10 characters '[', ']', '(', ')', '{', '}', '<', '>', '/', '%' + out = "" + for c in name: + if c in '[](){}<>/%' or ord(c) < 33 or ord(c) > 126: + continue + out += c + return out + + SIL_TABLE = [ + ( '(a)nonymous', r'\1nonymice' ), + ( '(b)itstream( ?)(v)era( ?sans ?mono)?', r'\1itstrom\2Wera' ), + ( '(s)ource', r'\1auce' ), + ( '(h)ermit', r'\1urmit' ), + ( '(h)asklig', r'\1asklug' ), + ( '(s)hare', r'\1hure' ), + ( 'IBM[- ]?plex', r'Blex' ), # We do not keep the case here + ( '(t)erminus', r'\1erminess' ), + ( '(l)iberation', r'\1iteration' ), + ( 'iA([- ]?)writer', r'iM\1Writing' ), + ( '(a)nka/(c)oder', r'\1na\2onder' ), + ( '(c)ascadia( ?)(c)ode', r'\1askaydia\2\3ove' ), + ( '(c)ascadia( ?)(m)ono', r'\1askaydia\2\3ono' ), + ( '(m)( ?)plus', r'\1+'), # Added this, because they use a plus symbol :-> + ( 'Gohufont', r'GohuFont'), # Correct to CamelCase + # Noone cares that font names starting with a digit are forbidden: + ( 'IBM 3270', r'3270'), # for historical reasons and 'IBM' is a TM or something + # Some name parts that are too long for us + ( '(.*sans ?m)ono', r'\1'), # Various SomenameSansMono fonts + ( '(.*code ?lat)in Expanded', r'\1X'), # for 'M PLUS Code Latin Expanded' + ( '(.*code ?lat)in', r'\1'), # for 'M PLUS Code Latin' + ( '(b)ig( ?)(b)lue( ?)(t)erminal', r'\1ig\3lue\5erm'), # Shorten BigBlueTerminal + ( '(.*)437TT', r'\g<1>437'), # Shorten BigBlueTerminal 437 TT even further + ( '(.*dyslexic ?alt)a', r'\1'), # Open Dyslexic Alta -> Open Dyslexic Alt + ( '(.*dyslexic ?m)ono', r'\1'), # Open Dyslexic Mono -> Open Dyslexic M + ( '(overpass ?m)ono', r'\1'), # Overpass Mono -> Overpass M + ( '(proggyclean) ?tt', r'\1'), # Remove TT from ProggyClean + ( '(terminess) ?\(ttf\)', r'\1'), # Remove TTF from Terminus (after renamed to Terminess) + ( '(im ?writing ?q)uattro', r'\1uat'), # Rename iM Writing Quattro to Quat + ( '(im ?writing ?(mono|duo|quat)) ?s', r'\1'), # Remove S from all iM Writing styles + ] + + # From https://adobe-type-tools.github.io/font-tech-notes/pdfs/5088.FontNames.pdf + # The first short variant is from the linked table. + # The second (longer) short variant is from diverse fonts like Noto. + # We can + # - use the long form + # - use the very short form (first) + # - use mild short form: + # - has no modifier: use the second form + # - has modifier: use second form of mod plus first form of weights2 + # - has modifier: use second form of mod plus second form of widths + # This is encoded in get_shorten_form_idx() + known_weights1 = { # can not take modifiers + 'Medium': ('Md', 'Med'), + 'Nord': ('Nd', 'Nord'), + 'Book': ('Bk', 'Book'), + 'Poster': ('Po', 'Poster'), + 'Demi': ('Dm', 'Demi'), # Demi is sometimes used as a weight, sometimes as a modifier + 'Regular': ('Rg', 'Reg'), + 'Display': ('DS', 'Disp'), + 'Super': ('Su', 'Sup'), + 'Retina': ('Rt', 'Ret'), + } + known_weights2 = { # can take modifiers + 'Black': ('Blk', 'Black'), + 'Bold': ('Bd', 'Bold'), + 'Heavy': ('Hv', 'Heavy'), + 'Thin': ('Th', 'Thin'), + 'Light': ('Lt', 'Light'), + ' ': (), # Just for CodeClimate :-/ + } + known_widths = { # can take modifiers + 'Compressed': ('Cm', 'Comp'), + 'Extended': ('Ex', 'Extd'), + 'Condensed': ('Cn', 'Cond'), + 'Narrow': ('Nr', 'Narrow'), + 'Compact': ('Ct', 'Compact'), + } + known_slopes = { # can not take modifiers + 'Inclined': ('Ic', 'Incl'), + 'Oblique': ('Obl', 'Obl'), + 'Italic': ('It', 'Italic'), + 'Upright': ('Up', 'Uprght'), + 'Kursiv': ('Ks', 'Kurs'), + 'Sloped': ('Sl', 'Slop'), + } + known_modifiers = { + 'Demi': ('Dm', 'Dem'), + 'Ultra': ('Ult', 'Ult'), + 'Semi': ('Sm', 'Sem'), + 'Extra': ('X', 'Ext'), + } + + @staticmethod + def is_keep_regular(basename): + """This has been decided by the font designers, we need to mimic that (for comparison purposes)""" + KEEP_REGULAR = [ + 'Agave', + 'Arimo', + 'Aurulent', + 'Cascadia', + 'Cousine', + 'Fantasque', + 'Fira', + + 'Overpass', + 'Lilex', + 'Inconsolata$', # not InconsolataGo + 'IAWriter', + 'Meslo', + 'Monoid', + 'Mononoki', + 'Hack', + 'JetBrains Mono', + 'Noto Sans', + 'Noto Serif', + 'Victor', + ] + for kr in KEEP_REGULAR: + if (basename.rstrip() + '$').startswith(kr): return True + return False + + @staticmethod + def _parse_simple_font_name(name): + """Parse a filename that does not follow the 'FontFamilyName-FontStyle' pattern""" + # No dash in name, maybe we have blanc separated filename? + if ' ' in name: + return FontnameTools.parse_font_name(name.replace(' ', '-')) + # Do we have a number-name boundary? + p = re.split('(?<=[0-9])(?=[a-zA-Z])', name) + if len(p) > 1: + return FontnameTools.parse_font_name('-'.join(p)) + # Or do we have CamelCase? + n = FontnameTools.camel_explode(name) + if n != name: + return FontnameTools.parse_font_name(n.replace(' ', '-')) + return (False, FontnameTools.camel_casify(name), [], [], [], '') + + @staticmethod + def parse_font_name(name): + """Expects a filename following the 'FontFamilyName-FontStyle' pattern and returns ... parts""" + name = re.sub(r'\bsemi-condensed\b', 'SemiCondensed', name, 1, re.IGNORECASE) # Just for "3270 Semi-Condensed" :-/ + name = re.sub('[_\s]+', ' ', name) + matches = re.match(r'([^-]+)(?:-(.*))?', name) + familyname = FontnameTools.camel_casify(matches.group(1)) + style = matches.group(2) + + if not style: + return FontnameTools._parse_simple_font_name(name) + + # These are the FontStyle keywords we know, in three categories + # Weights end up as Typographic Family parts ('after the dash') + # Styles end up as Family parts (for classic grouping of four) + # Others also end up in Typographic Family ('before the dash') + weights = [ m + s + for s in list(FontnameTools.known_weights2) + list(FontnameTools.known_widths) + for m in list(FontnameTools.known_modifiers) + [''] if m != s + ] + list(FontnameTools.known_weights1) + list(FontnameTools.known_slopes) + styles = [ 'Bold', 'Italic', 'Regular', 'Normal', ] + weights = [ w for w in weights if w not in styles ] + # Some font specialities: + other = [ + '-', 'Book', 'For', 'Powerline', + 'Text', # Plex + 'IIx', # Profont IIx + 'LGC', # Inconsolata LGC + r'\bCE\b', # ProggycleanTT CE + r'[12][cmp]n?', # MPlus + r'(?:uni-)?1[14]', # GohuFont uni + ] + + # Sometimes used abbreviations + weight_abbrevs = [ 'ob', 'c', 'm', 'l', ] + style_abbrevs = [ 'it', 'r', 'b', 'i', ] + + ( style, weight_token ) = FontnameTools.get_name_token(style, weights) + ( style, style_token ) = FontnameTools.get_name_token(style, styles) + ( style, other_token ) = FontnameTools.get_name_token(style, other, True) + if (len(style) < 4 + and style.lower() != 'pro'): # Prevent 'r' of Pro to be detected as style_abbrev + ( style, weight_token_abbrevs ) = FontnameTools.get_name_token(style, weight_abbrevs) + ( style, style_token_abbrevs ) = FontnameTools.get_name_token(style, style_abbrevs) + weight_token += weight_token_abbrevs + style_token += style_token_abbrevs + while 'Regular' in style_token and len(style_token) > 1: + # Correct situation where "Regular" and something else is given + style_token.remove('Regular') + + # Recurse to see if unmatched stuff between dashes can belong to familyname + matches2 = re.match(r'(\w+)-(.*)', style) + if matches2: + return FontnameTools.parse_font_name(familyname + matches2.group(1) + '-' + matches2.group(2)) + + style = re.sub(r'(^|\s)\d+(\.\d+)+(\s|$)', r'\1\3', style) # Remove (free standing) version numbers + style_parts = FontnameTools.drop_empty(style.split(' ')) + style = ' '.join(map(FontnameTools.front_upper, style_parts)) + familyname = FontnameTools.camel_explode(familyname) + return (True, familyname, weight_token, style_token, other_token, style) diff --git a/bin/scripts/name_parser/query_monospace b/bin/scripts/name_parser/query_monospace new file mode 100644 index 0000000..3132ffa --- /dev/null +++ b/bin/scripts/name_parser/query_monospace @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +# coding=utf8 + +import sys +import os.path +import fontforge + +###### Some helpers (code from font-patcher) + +def check_panose_monospaced(font): + """ Check if the font's Panose flags say it is monospaced """ + # https://forum.high-logic.com/postedfiles/Panose.pdf + panose = list(font.os2_panose) + if panose[0] < 2 or panose[0] > 5: + return -1 # invalid Panose info + panose_mono = ((panose[0] == 2 and panose[3] == 9) or + (panose[0] == 3 and panose[3] == 3)) + return 1 if panose_mono else 0 + +def is_monospaced(font): + """ Check if a font is probably monospaced """ + # Some fonts lie (or have not any Panose flag set), spot check monospaced: + width = -1 + width_mono = True + for glyph in [ 0x49, 0x4D, 0x57, 0x61, 0x69, 0x2E ]: # wide and slim glyphs 'I', 'M', 'W', 'a', 'i', '.' + if not glyph in font: + # A 'strange' font, believe Panose + return check_panose_monospaced(font) == 1 + # print(" -> {} {}".format(glyph, font[glyph].width)) + if width < 0: + width = font[glyph].width + continue + if font[glyph].width != width: + # Exception for fonts like Code New Roman Regular or Hermit Light/Bold: + # Allow small 'i' and dot to be smaller than normal + # I believe the source fonts are buggy + if glyph in [ 0x69, 0x2E ]: + if width > font[glyph].width: + continue + (xmin, _, xmax, _) = font[glyph].boundingBox() + if width > xmax - xmin: + continue + width_mono = False + break + # We believe our own check more then Panose ;-D + return width_mono + +def get_advance_width(font, extended, minimum): + """ Get the maximum/minimum advance width in the extended(?) range """ + width = 0 + if extended: + end = 0x17f + else: + end = 0x07e + for glyph in range(0x21, end): + if not glyph in font: + continue + if glyph in range(0x7F, 0xBF): + continue # ignore special characters like '1/4' etc + if width == 0: + width = font[glyph].width + continue + if not minimum and width < font[glyph].width: + width = font[glyph].width + elif minimum and width > font[glyph].width: + width = font[glyph].width + return width + + +###### Let's go! + +if len(sys.argv) < 2: + print('Usage: {} font_name [font_name ...]\n'.format(sys.argv[0])) + sys.exit(1) + +print('Examining {} font files'.format(len(sys.argv) - 1)) + + +for filename in sys.argv[1:]: + fullfile = os.path.basename(filename) + fname = os.path.splitext(fullfile)[0] + + font = fontforge.open(filename, 1) + width_mono = is_monospaced(font) + panose_mono = check_panose_monospaced(font) + if (width_mono and panose_mono == 0) or (not width_mono and panose_mono == 1): + print('[{:50.50}] Warning: Monospaced check: Panose assumed to be wrong; Glyph widths {} / {} - {} and Panose says "monospace {}" ({})'.format(fullfile, get_advance_width(font, False, True), + get_advance_width(font, False, False), get_advance_width(font, True, False), panose_mono, list(font.os2_panose))) + if not width_mono: + print('[{:50.50}] Warning: Sourcefont is not monospaced - forcing to monospace not advisable, results might be useless; Glyph widths {} / {} - {}'.format(fullfile, get_advance_width(font, False, True), + get_advance_width(font, False, False), get_advance_width(font, True, False), panose_mono, list(font.os2_panose))) + else: + print('[{:50.50}] OK'.format(fullfile)) + font.close() diff --git a/bin/scripts/name_parser/query_names b/bin/scripts/name_parser/query_names new file mode 100644 index 0000000..e05c351 --- /dev/null +++ b/bin/scripts/name_parser/query_names @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# coding=utf8 +# +# Usually called via +# $ fontforge query_names fontfile.tff 2>/dev/null + +import sys +import os.path +import fontforge + +###### Some helpers + +def get_sfnt_dict(font): + """Extract SFNT table as nice dict""" + return { k: v for l, k, v in font.sfnt_names } + +def format_names(header, *stuff): + """Unify outputs (with header)""" + f = '| {:50.50}|{:>2.2}| {:64.64}|{:>2.2}| {:64.64}|{:>2.2}| {:55.55}|{:>2.2}| {:30.30}|{:>2.2}| {:40.40}|{:>2.2}| {:40.40}|{:>2.2}|' + if header: + d = '' + return f.format(*stuff) + '\n' + f.format(d, d, d, d, d, d, d, d, d, d, d, d, d, d).replace(' ', '-') + return f.format(*stuff).rstrip() + +###### Let's go! + +if len(sys.argv) < 2: + print('Usage: {} font_name [font_name ...]\n'.format(sys.argv[0])) + sys.exit(1) + +print('Examining {} font files'.format(len(sys.argv) - 1)) + +print(format_names(True, 'Filename', '', 'PS Name', '', 'Fullname', '', 'Family', '', 'Subfamily', '', 'Typogr. Family', '', 'Typogr. Subfamily', '')) + +for filename in sys.argv[1:]: + fullfile = os.path.basename(filename) + fname = os.path.splitext(fullfile)[0] + + font = fontforge.open(filename, 1) + sfnt = get_sfnt_dict(font) + psname = font.fontname + font.close() + + sfnt_full = sfnt['Fullname'] + sfnt_fam = sfnt['Family'] + sfnt_subfam = sfnt['SubFamily'] + sfnt_pfam = sfnt['Preferred Family'] if 'Preferred Family' in sfnt else '' + sfnt_psubfam = sfnt['Preferred Styles'] if 'Preferred Styles' in sfnt else '' + + o2 = format_names(False, + fullfile, str(len(fullfile)), + psname, str(len(psname)), + sfnt_full, str(len(sfnt_full)), + sfnt_fam, str(len(sfnt_fam)), + sfnt_subfam, str(len(sfnt_subfam)), + # show length zero if a zero length string is stored, show nothing if nothing is stored: + sfnt_pfam, str(len(sfnt_pfam)) if 'Preferred Family' in sfnt else '', + sfnt_psubfam, str(len(sfnt_psubfam)) if 'Preferred Family' in sfnt else '') + + print(o2) diff --git a/bin/scripts/name_parser/query_panose b/bin/scripts/name_parser/query_panose new file mode 100644 index 0000000..2492444 --- /dev/null +++ b/bin/scripts/name_parser/query_panose @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +# coding=utf8 + +import fontforge +import sys + +if len(sys.argv) != 2: + print("Usage: {} font_name\n".format(sys.argv[0])) + sys.exit(1) + +font = fontforge.open(sys.argv[1]) + +panose = list(font.os2_panose) +print("Panose 4 = {} in {}".format(panose[3], font.fullname)) + +font.close() diff --git a/bin/scripts/name_parser/query_sftn b/bin/scripts/name_parser/query_sftn new file mode 100644 index 0000000..48c3f43 --- /dev/null +++ b/bin/scripts/name_parser/query_sftn @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +# coding=utf8 + +import fontforge +import sys + +def get_sfnt_dict(font): + """Extract SFNT table as nice dict""" + return { k: v for l, k, v in font.sfnt_names } + +if len(sys.argv) < 2 or len(sys.argv) > 3: + print("Usage: {} [] font_name\n".format(sys.argv[0])) + sys.exit(1) + +if len(sys.argv) == 2: + fname = sys.argv[1] + sname = None +else: + fname = sys.argv[2] + sname = sys.argv[1] + +font = fontforge.open(fname) +sfnt = get_sfnt_dict(font) +font.close() + +if sname: + for key in sname.split(','): + if key in sfnt: + print("SFNT {:20.20} is {:80.80}".format(key, '\'' + sfnt[key] + '\'')); + else: + print("SFNT {:20.20} is not set".format(key)); +else: + for k in sfnt: + print("{:20.20} {:80.80}".format(k, sfnt[k])) + diff --git a/bin/scripts/name_parser/query_version b/bin/scripts/name_parser/query_version new file mode 100644 index 0000000..3a9a195 --- /dev/null +++ b/bin/scripts/name_parser/query_version @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# coding=utf8 + +import fontforge +import sys + +def get_sfnt_dict(font): + """Extract SFNT table as nice dict""" + return { k: v for l, k, v in font.sfnt_names } + +if len(sys.argv) != 2: + print("Usage: {} font_name\n".format(sys.argv[0])) + sys.exit(1) + +font = fontforge.open(sys.argv[1]) +sfnt = get_sfnt_dict(font) + +print("Version is '{}'".format(font.version)); +print("CID Version is '{}'".format(font.cidversion)); +print("SFNT Revision is '{}'".format(font.sfntRevision)); +if "Version" in sfnt: + print("SFNT ['Version'] is '{}'".format(sfnt["Version"])); +else: + print("SFNT ['Version'] is not set".format(sys.argv[1])); + +font.close() diff --git a/build-hdmx-for-sarasa.py b/build-hdmx-for-sarasa.py deleted file mode 100644 index 9ad316d..0000000 --- a/build-hdmx-for-sarasa.py +++ /dev/null @@ -1,49 +0,0 @@ -#! /usr/bin/python - -# credit: https://github.com/be5invis/Sarasa-Gothic/issues/108#issuecomment-517240248 - -# usage: -# python build-hdmx-for-sarasa.py your-sarasa-font.ttf - -import sys -import math - -from fontTools.ttLib import TTFont, newTable - - -def main(): - headFlagInstructionsMayAlterAdvanceWidth = 0x0010 - sarasaHintPpemMin = 11 - sarasaHintPpemMax = 48 - - filename = sys.argv[1] - - font = TTFont(filename, recalcBBoxes=False) - - originalFontHead = font["head"] - originalFontHmtx = font["hmtx"] - - originalFontHead.flags |= headFlagInstructionsMayAlterAdvanceWidth - - hdmxTable = newTable("hdmx") - hdmxTable.hdmx = {} - - # build hdmx table for odd and hinted ppems only. - for ppem in range( - math.floor(sarasaHintPpemMin / 2) * 2 + 1, sarasaHintPpemMax + 1, 2 - ): - halfUpm = originalFontHead.unitsPerEm / 2 - halfPpem = math.ceil(ppem / 2) - hdmxTable.hdmx[ppem] = { - name: math.ceil(width / halfUpm) * halfPpem - for name, (width, _) in originalFontHmtx.metrics.items() - } - - font["hdmx"] = hdmxTable - - font.save(filename) - font.close() - - -if __name__ == "__main__": - main() diff --git a/correct-ttf-font-family-name.py b/correct-ttf-font-family-name.py deleted file mode 100644 index 2a5360f..0000000 --- a/correct-ttf-font-family-name.py +++ /dev/null @@ -1,61 +0,0 @@ -#! /usr/bin/python - -# usage: -# python correct-ttf-font-family-name.py filename.ttf - -import sys - -from fontTools.ttLib import TTFont - - -def main(): - filename = sys.argv[1] - - font = TTFont(filename, recalcBBoxes=False) - fontName = font["name"] - - originalFontUniqueID = fontName.getName(3, 1, 0, 0).toUnicode() - originalFontFullname = fontName.getName(4, 1, 0, 0).toUnicode() - originalFontPreferredStyle = fontName.getName(17, 1, 0, 0).toUnicode() - - for entry in fontName.names: - nameID = entry.nameID - platformID = entry.platformID - platEncID = entry.platEncID - langID = entry.langID - - if langID in [1028, 1041, 2052, 3076]: - string = ( - entry.toUnicode() - .replace(" CL", " CL Nerd Font") - .replace(" TC", " TC Nerd Font") - .replace(" J", " J Nerd Font") - .replace(" SC", " SC Nerd Font") - .replace(" HC", " HC Nerd Font") - ) - fontName.setName(string, nameID, platformID, platEncID, langID) - - elif nameID in [1, 16]: - string = originalFontUniqueID.replace( - f" {originalFontPreferredStyle}", " Nerd Font" - ) - fontName.setName(string, nameID, platformID, platEncID, langID) - - elif nameID == 3: - string = originalFontUniqueID.replace( - f" {originalFontPreferredStyle}", - f" Nerd Font {originalFontPreferredStyle}", - ) - fontName.setName(string, nameID, platformID, platEncID, langID) - - elif nameID == 6: - fontName.setName( - originalFontFullname, nameID, platformID, platEncID, langID - ) - - font.save(filename) - font.close() - - -if __name__ == "__main__": - main() diff --git a/font-patcher b/font-patcher index 581eaee..68a9dcc 100755 --- a/font-patcher +++ b/font-patcher @@ -1,14 +1,14 @@ #!/usr/bin/env python # coding=utf8 -# Nerd Fonts Version: 2.3.3 +# Nerd Fonts Version: 3.0.0 # Script version is further down from __future__ import absolute_import, print_function, unicode_literals # Change the script version when you edit this script: -script_version = "3.6.1" +script_version = "4.1.1" -version = "2.3.3" +version = "3.0.0" projectName = "Nerd Fonts" projectNameAbbreviation = "NF" projectNameSingular = projectName[:-1] @@ -22,6 +22,7 @@ import errno import subprocess import json from enum import Enum +import logging try: import configparser except ImportError: @@ -135,6 +136,7 @@ class TableHEADWriter: 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, + 'avgWidth': 2, } where = self.tab_offset + positions[where] self.f.seek(where) @@ -238,10 +240,10 @@ def force_panose_monospaced(font): panose = list(font.os2_panose) if panose[0] == 0: # 0 (1st value) = family kind; 0 = any (default) panose[0] = 2 # make kind latin text and display - print(" Setting Panose 'Family Kind' to 'Latin Text and Display' (was 'Any')") + logger.info("Setting Panose 'Family Kind' to 'Latin Text and Display' (was 'Any')") font.os2_panose = tuple(panose) if panose[0] == 2 and panose[3] != 9: - print(" Setting Panose 'Proportion' to 'Monospaced' (was '{}')".format(panose_proportion_to_text(panose[3]))) + logger.info("Setting Panose 'Proportion' to 'Monospaced' (was '%s')", panose_proportion_to_text(panose[3])) panose[3] = 9 # 3 (4th value) = proportion; 9 = monospaced font.os2_panose = tuple(panose) @@ -282,6 +284,35 @@ def get_btb_metrics(font): win_btb = win_height + win_gap return (hhea_btb, typo_btb, win_btb, win_gap) +def get_old_average_x_width(font): + """ Determine xAvgCharWidth of the OS/2 table """ + # Fontforge can not create fonts with old (i.e. prior to OS/2 version 3) + # table values, but some very old applications do need them sometimes + # https://learn.microsoft.com/en-us/typography/opentype/spec/os2#xavgcharwidth + s = 0 + weights = { + 'a': 64, 'b': 14, 'c': 27, 'd': 35, 'e': 100, 'f': 20, 'g': 14, 'h': 42, 'i': 63, + 'j': 3, 'k': 6, 'l': 35, 'm': 20, 'n': 56, 'o': 56, 'p': 17, 'q': 4, 'r': 49, + 's': 56, 't': 71, 'u': 31, 'v': 10, 'w': 18, 'x': 3, 'y': 18, 'z': 2, 32: 166, + } + for g in weights: + if g not in font: + logger.critical("Can not determine ancient style xAvgCharWidth") + sys.exit(1) + s += font[g].width * weights[g] + return int(s / 1000) + +def create_filename(fonts): + """ Determine filename from font object(s) """ + sfnt = { k: v for l, k, v in fonts[0].sfnt_names } + sfnt_pfam = sfnt.get('Preferred Family', sfnt['Family']) + sfnt_psubfam = sfnt.get('Preferred Styles', sfnt['SubFamily']) + if len(fonts) > 1: + return sfnt_pfam + if len(sfnt_psubfam) > 0: + sfnt_psubfam = '-' + sfnt_psubfam + return (sfnt_pfam + sfnt_psubfam).replace(' ', '') + class font_patcher: def __init__(self, args): @@ -293,9 +324,11 @@ class font_patcher: self.font_dim = None # class 'dict' self.font_extrawide = False self.source_monospaced = None # Later True or False + self.symbolsonly = False self.onlybitmaps = 0 self.essential = set() self.config = configparser.ConfigParser(empty_lines_in_values=False, allow_no_value=True) + self.xavgwidth = [] # list of ints def patch(self, font): self.sourceFont = font @@ -317,7 +350,7 @@ class font_patcher: # For very wide (almost square or wider) fonts we do not want to generate 2 cell wide Powerline glyphs if self.font_dim['height'] * 1.8 < self.font_dim['width'] * 2: - print("Very wide and short font, disabling 2 cell Powerline glyphs") + logger.warning("Very wide and short font, disabling 2 cell Powerline glyphs") self.font_extrawide = True # Prevent opening and closing the fontforge font. Makes things faster when patching @@ -326,8 +359,12 @@ class font_patcher: symfont = None if not os.path.isdir(self.args.glyphdir): - sys.exit("{}: Can not find symbol glyph directory {} " - "(probably you need to download the src/glyphs/ directory?)".format(projectName, self.args.glyphdir)) + logger.critical("Can not find symbol glyph directory %s " + "(probably you need to download the src/glyphs/ directory?)", self.args.glyphdir) + sys.exit(1) + + if self.args.dry_run: + return for patch in self.patch_set: if patch['Enabled']: @@ -337,11 +374,13 @@ class font_patcher: symfont.close() symfont = None if not os.path.isfile(self.args.glyphdir + patch['Filename']): - sys.exit("{}: Can not find symbol source for '{}'\n{:>{}} (i.e. {})".format( - projectName, patch['Name'], '', len(projectName), self.args.glyphdir + patch['Filename'])) + logger.critical("Can not find symbol source for '%s' (i.e. %s)", + patch['Name'], self.args.glyphdir + patch['Filename']) + sys.exit(1) if not os.access(self.args.glyphdir + patch['Filename'], os.R_OK): - sys.exit("{}: Can not open symbol source for '{}'\n{:>{}} (i.e. {})".format( - projectName, patch['Name'], '', len(projectName), self.args.glyphdir + patch['Filename'])) + logger.critical("Can not open symbol source for '%s' (i.e. %s)", + patch['Name'], self.args.glyphdir + patch['Filename']) + sys.exit(1) symfont = fontforge.open(os.path.join(self.args.glyphdir, patch['Filename'])) symfont.encoding = 'UnicodeFull' @@ -383,11 +422,11 @@ class font_patcher: break outfile = os.path.normpath(os.path.join( sanitize_filename(self.args.outputdir, True), - sanitize_filename(sourceFont.familyname) + ".ttc")) + sanitize_filename(create_filename(sourceFonts)) + ".ttc")) sourceFonts[0].generateTtc(outfile, sourceFonts[1:], flags=gen_flags, layer=layer) message = " Generated {} fonts\n \===> '{}'".format(len(sourceFonts), outfile) else: - fontname = sourceFont.fullname + fontname = create_filename(sourceFonts) if not fontname: fontname = sourceFont.cidfontname outfile = os.path.normpath(os.path.join( @@ -395,9 +434,11 @@ class font_patcher: sanitize_filename(fontname) + self.args.extension)) bitmaps = str() if len(self.sourceFont.bitmapSizes): - if not self.args.quiet: - print("Preserving bitmaps {}".format(self.sourceFont.bitmapSizes)) + logger.debug("Preserving bitmaps {}".format(self.sourceFont.bitmapSizes)) bitmaps = str('otf') # otf/ttf, both is bf_ttf + if self.args.dry_run: + logger.debug("=====> Filename '{}'".format(outfile)) + return sourceFont.generate(outfile, bitmap_type=bitmaps, flags=gen_flags) message = " {}\n \===> '{}'".format(self.sourceFont.fullname, outfile) @@ -407,23 +448,34 @@ class font_patcher: source_font = TableHEADWriter(self.args.font) dest_font = TableHEADWriter(outfile) for idx in range(source_font.num_fonts): - if not self.args.quiet: - print("{}: Tweaking {}/{}".format(projectName, idx + 1, source_font.num_fonts)) + logger.debug("Tweaking %d/%d", idx + 1, source_font.num_fonts) + xwidth_s = '' + xwidth = self.xavgwidth[idx] + if isinstance(xwidth, int): + if isinstance(xwidth, bool) and xwidth: + source_font.find_table([b'OS/2'], idx) + xwidth = source_font.getshort('avgWidth') + xwidth_s = ' (copied from source)' + dest_font.find_table([b'OS/2'], idx) + d_xwidth = dest_font.getshort('avgWidth') + if d_xwidth != xwidth: + logger.debug("Changing xAvgCharWidth from %d to %d%s", d_xwidth, xwidth, xwidth_s) + dest_font.putshort(xwidth, 'avgWidth') + dest_font.reset_table_checksum() source_font.find_head_table(idx) dest_font.find_head_table(idx) if source_font.flags & 0x08 == 0 and dest_font.flags & 0x08 != 0: - if not self.args.quiet: - print("Changing flags from 0x{:X} to 0x{:X}".format(dest_font.flags, dest_font.flags & ~0x08)) + logger.debug("Changing flags from 0x%X to 0x%X", 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: - if not self.args.quiet: - print("Changing lowestRecPPEM from {} to {}".format(dest_font.lowppem, source_font.lowppem)) + logger.debug("Changing lowestRecPPEM from %d to %d", 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() + if dest_font.modified: + dest_font.reset_full_checksum() except Exception as error: - print("Can not handle font flags ({})".format(repr(error))) + logger.error("Can not handle font flags (%s)", repr(error)) finally: try: source_font.close() @@ -431,12 +483,13 @@ class font_patcher: except: pass if self.args.is_variable: - print("Warning: Source font is a variable open type font (VF) and the patch results will most likely not be what you want") + logger.critical("Source font is a variable open type font (VF) and the patch results will most likely not be what you want") print(message) if self.args.postprocess: subprocess.call([self.args.postprocess, outfile]) - print("\nPost Processed: {}".format(outfile)) + print("\n") + logger.info("Post Processed: %s", outfile) def setup_name_backup(self, font): @@ -454,11 +507,8 @@ class font_patcher: font.fullname = font.persistent["fullname"] if isinstance(font.persistent["familyname"], str): font.familyname = font.persistent["familyname"] - verboseAdditionalFontNameSuffix = " " + projectNameSingular - if self.args.windows: # attempt to shorten here on the additional name BEFORE trimming later - additionalFontNameSuffix = " " + projectNameAbbreviation - else: - additionalFontNameSuffix = verboseAdditionalFontNameSuffix + verboseAdditionalFontNameSuffix = "" + additionalFontNameSuffix = "" if not self.args.complete: # NOTE not all symbol fonts have appended their suffix here if self.args.fontawesome: @@ -489,17 +539,24 @@ class font_patcher: additionalFontNameSuffix += " WEA" verboseAdditionalFontNameSuffix += " Plus Weather Icons" - # if all source glyphs included simplify the name - else: - additionalFontNameSuffix = " " + projectNameSingular + " Complete" - verboseAdditionalFontNameSuffix = " " + projectNameSingular + " Complete" - - # add mono signifier to end of name + # add mono signifier to beginning of name suffix if self.args.single: - additionalFontNameSuffix += " M" - verboseAdditionalFontNameSuffix += " Mono" + variant_abbrev = "M" + variant_full = " Mono" + elif self.args.nonmono and not self.symbolsonly: + variant_abbrev = "P" + variant_full = " Propo" + else: + variant_abbrev = "" + variant_full = "" - if FontnameParserOK and self.args.makegroups: + ps_suffix = projectNameAbbreviation + variant_abbrev + additionalFontNameSuffix + + # add 'Nerd Font' to beginning of name suffix + verboseAdditionalFontNameSuffix = " " + projectNameSingular + variant_full + verboseAdditionalFontNameSuffix + additionalFontNameSuffix = " " + projectNameSingular + variant_full + additionalFontNameSuffix + + if FontnameParserOK and self.args.makegroups > 0: use_fullname = isinstance(font.fullname, str) # Usually the fullname is better to parse # Use fullname if it is 'equal' to the fontname if font.fullname: @@ -511,12 +568,14 @@ class font_patcher: # 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) + n = FontnameParser(parser_name, logger) if not n.parse_ok: - print("Have only minimal naming information, check resulting name. Maybe omit --makegroups option") + logger.warning("Have only minimal naming information, check resulting name. Maybe specify --makegroups 0") n.drop_for_powerline() - n.enable_short_families(True, "Noto") - n.set_for_windows(self.args.windows) + n.enable_short_families(True, self.args.makegroups in [ 2, 3, 5, 6, ], self.args.makegroups in [ 3, 6, ]) + if not n.set_expect_no_italic(self.args.noitalic): + logger.critical("Detected 'Italic' slant but --has-no-italic specified") + sys.exit(1) # All the following stuff is ignored in makegroups-mode @@ -564,23 +623,7 @@ class font_patcher: if len(subFamily) == 0: subFamily = "Regular" - if self.args.windows: - maxFamilyLength = 31 - maxFontLength = maxFamilyLength - len('-' + subFamily) - familyname += " " + projectNameAbbreviation - if self.args.single: - familyname += "M" - fullname += " Windows Compatible" - - # now make sure less than 32 characters name length - if len(fontname) > maxFontLength: - fontname = fontname[:maxFontLength] - if len(familyname) > maxFamilyLength: - familyname = familyname[:maxFamilyLength] - else: - familyname += " " + projectNameSingular - if self.args.single: - familyname += " Mono" + familyname += " " + projectNameSingular + variant_full # 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. Make sure to @@ -593,6 +636,10 @@ class font_patcher: reservedFontNameReplacements = { 'source' : 'sauce', 'Source' : 'Sauce', + 'Bitstream Vera Sans Mono' : 'Bitstrom Wera', + 'BitstreamVeraSansMono' : 'BitstromWera', + 'bitstream vera sans mono' : 'bitstrom wera', + 'bitstreamverasansmono' : 'bitstromwera', 'hermit' : 'hurmit', 'Hermit' : 'Hurmit', 'hasklig' : 'hasklug', @@ -659,7 +706,7 @@ class font_patcher: fullname = replace_font_name(fullname, additionalFontNameReplacements2) fontname = replace_font_name(fontname, additionalFontNameReplacements2) - if not (FontnameParserOK and self.args.makegroups): + if not (FontnameParserOK and self.args.makegroups > 0): # replace any extra whitespace characters: font.familyname = " ".join(familyname.split()) font.fullname = " ".join(fullname.split()) @@ -670,13 +717,9 @@ class font_patcher: font.appendSFNTName(str('English (US)'), str('Compatible Full'), font.fullname) font.appendSFNTName(str('English (US)'), str('SubFamily'), subFamily) else: - fam_suffix = projectNameSingular if not self.args.windows else projectNameAbbreviation - if self.args.single: - if self.args.windows: - fam_suffix += 'M' - else: - fam_suffix += ' Mono' - n.inject_suffix(verboseAdditionalFontNameSuffix, additionalFontNameSuffix, fam_suffix) + short_family = projectNameAbbreviation + variant_abbrev if self.args.makegroups >= 4 else projectNameSingular + variant_full + # inject_suffix(family, ps_fontname, short_family) + n.inject_suffix(verboseAdditionalFontNameSuffix, ps_suffix, short_family) n.rename_font(font) font.comment = projectInfo @@ -692,6 +735,7 @@ class font_patcher: self.sourceFont.version = str(self.sourceFont.cidversion) + ";" + projectName + " " + version self.sourceFont.sfntRevision = None # Auto-set (refreshed) by fontforge self.sourceFont.appendSFNTName(str('English (US)'), str('Version'), "Version " + self.sourceFont.version) + # The Version SFNT name is later reused by the NameParser for UniqueID # print("Version now is {}".format(sourceFont.version)) @@ -700,17 +744,17 @@ class font_patcher: # the tables have been removed from the repo with >this< commit if self.args.configfile and self.config.read(self.args.configfile): if self.args.removeligatures: - print("Removing ligatures from configfile `Subtables` section") + logger.info("Removing ligatures from configfile `Subtables` section") ligature_subtables = json.loads(self.config.get("Subtables", "ligatures")) for subtable in ligature_subtables: - print("Removing subtable:", subtable) + logger.debug("Removing subtable: %s", subtable) try: self.sourceFont.removeLookupSubtable(subtable) - print("Successfully removed subtable:", subtable) + logger.debug("Successfully removed subtable: %s", subtable) except Exception: - print("Failed to remove subtable:", subtable) + logger.error("Failed to remove subtable: %s", subtable) elif self.args.removeligatures: - print("Unable to read configfile, unable to remove ligatures") + logger.error("Unable to read configfile, unable to remove ligatures") def assert_monospace(self): @@ -722,16 +766,17 @@ class font_patcher: panose_mono = check_panose_monospaced(self.sourceFont) # The following is in fact "width_mono != panose_mono", but only if panose_mono is not 'unknown' if (width_mono and panose_mono == 0) or (not width_mono and panose_mono == 1): - print(" Warning: Monospaced check: Panose assumed to be wrong") - print(" {} and {}".format( + logger.warning("Monospaced check: Panose assumed to be wrong") + logger.warning(" %s and %s", report_advance_widths(self.sourceFont), - panose_check_to_text(panose_mono, self.sourceFont.os2_panose))) + panose_check_to_text(panose_mono, self.sourceFont.os2_panose)) if self.args.single and not width_mono: - print(" Warning: Sourcefont is not monospaced - forcing to monospace not advisable, results might be useless") + logger.warning("Sourcefont is not monospaced - forcing to monospace not advisable, results might be useless") if offending_char is not None: - print(" Offending char: 0x{:X}".format(offending_char)) + logger.warning(" Offending char: %X", offending_char) if self.args.single <= 1: - sys.exit(projectName + ": Font will not be patched! Give --mono (or -s, or --use-single-width-glyphs) twice to force patching") + logger.critical("Font will not be patched! Give --mono (or -s, or --use-single-width-glyphs) twice to force patching") + sys.exit(1) if width_mono: force_panose_monospaced(self.sourceFont) @@ -740,19 +785,20 @@ class font_patcher: """ Creates list of dicts to with instructions on copying glyphs from each symbol font into self.sourceFont """ box_enabled = self.source_monospaced # Box glyph only for monospaced + box_keep = False if box_enabled: self.sourceFont.selection.select(("ranges",), 0x2500, 0x259f) box_glyphs_target = len(list(self.sourceFont.selection)) box_glyphs_current = len(list(self.sourceFont.selection.byGlyphs)) if box_glyphs_target > box_glyphs_current: - # Sourcefont does not have all of these glyphs, do not mix sets - if not self.args.quiet and box_glyphs_current > 0: - print("INFO: {}/{} box drawing glyphs will be replaced".format( - box_glyphs_current, box_glyphs_target)) - box_keep = False + # Sourcefont does not have all of these glyphs, do not mix sets (overwrite existing) + if box_glyphs_current > 0: + logger.debug("%d/%d box drawing glyphs will be replaced", + box_glyphs_current, box_glyphs_target) box_enabled = True else: - box_keep = True # just scale do not copy + # Sourcefont does have all of these glyphs + # box_keep = True # just scale do not copy (need to scale to fit new cell size) box_enabled = False # Cowardly not scaling existing glyphs, although the code would allow this # Stretch 'xz' or 'pa' (preserve aspect ratio) @@ -985,14 +1031,14 @@ class font_patcher: {'Enabled': self.args.fontawesomeextension, 'Name': "Font Awesome Extension", 'Filename': "font-awesome-extension.ttf", 'Exact': False, 'SymStart': 0xE000, 'SymEnd': 0xE0A9, 'SrcStart': 0xE200, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, # Maximize {'Enabled': self.args.powersymbols, 'Name': "Power Symbols", 'Filename': "Unicode_IEC_symbol_font.otf", 'Exact': True, 'SymStart': 0x23FB, 'SymEnd': 0x23FE, 'SrcStart': None, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, # Power, Power On/Off, Power On, Sleep {'Enabled': self.args.powersymbols, 'Name': "Power Symbols", 'Filename': "Unicode_IEC_symbol_font.otf", 'Exact': True, 'SymStart': 0x2B58, 'SymEnd': 0x2B58, 'SrcStart': None, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, # Heavy Circle (aka Power Off) - {'Enabled': self.args.material, 'Name': "Material legacy", 'Filename': "materialdesignicons-webfont.ttf", 'Exact': False, 'SymStart': 0xF001, 'SymEnd': 0xF847, 'SrcStart': 0xF500, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, + {'Enabled': False , 'Name': "Material legacy", 'Filename': "materialdesignicons-webfont.ttf", 'Exact': False, 'SymStart': 0xF001, 'SymEnd': 0xF847, 'SrcStart': 0xF500, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.material, 'Name': "Material", 'Filename': "materialdesign/MaterialDesignIconsDesktop.ttf", 'Exact': True, 'SymStart': 0xF0001,'SymEnd': 0xF1AF0,'SrcStart': None, 'ScaleRules': MDI_SCALE_LIST, '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, 'ScaleRules': WEATH_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.fontlogos, 'Name': "Font Logos", 'Filename': "font-logos.ttf", 'Exact': True, 'SymStart': 0xF300, 'SymEnd': 0xF32F, 'SrcStart': None, 'ScaleRules': None, 'Attributes': SYM_ATTR_DEFAULT}, - {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': False, 'SymStart': 0xF000, 'SymEnd': 0xF105, 'SrcStart': 0xF400, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Magnifying glass - {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': True, 'SymStart': 0x2665, 'SymEnd': 0x2665, 'SrcStart': None, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Heart - {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': True, 'SymStart': 0X26A1, 'SymEnd': 0X26A1, 'SrcStart': None, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Zap - {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons.ttf", 'Exact': False, 'SymStart': 0xF27C, 'SymEnd': 0xF27C, 'SrcStart': 0xF4A9, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Desktop + {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons/octicons.ttf", 'Exact': False, 'SymStart': 0xF000, 'SymEnd': 0xF105, 'SrcStart': 0xF400, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Magnifying glass + {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons/octicons.ttf", 'Exact': True, 'SymStart': 0x2665, 'SymEnd': 0x2665, 'SrcStart': None, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Heart + {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons/octicons.ttf", 'Exact': True, 'SymStart': 0X26A1, 'SymEnd': 0X26A1, 'SrcStart': None, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, # Zap + {'Enabled': self.args.octicons, 'Name': "Octicons", 'Filename': "octicons/octicons.ttf", 'Exact': False, 'SymStart': 0xF27C, 'SymEnd': 0xF305, 'SrcStart': 0xF4A9, 'ScaleRules': OCTI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.codicons, 'Name': "Codicons", 'Filename': "codicons/codicon.ttf", 'Exact': True, 'SymStart': 0xEA60, 'SymEnd': 0xEBEB, 'SrcStart': None, 'ScaleRules': CODI_SCALE_LIST, 'Attributes': SYM_ATTR_DEFAULT}, {'Enabled': self.args.custom, 'Name': "Custom", 'Filename': self.args.custom, 'Exact': True, 'SymStart': 0x0000, 'SymEnd': 0x0000, 'SrcStart': None, 'ScaleRules': None, 'Attributes': CUSTOM_ATTR} ] @@ -1067,11 +1113,22 @@ class font_patcher: our_btb = typo_btb if use_typo else win_btb if our_btb == hhea_btb: metrics = Metric.TYPO if use_typo else Metric.WIN # conforming font + elif abs(our_btb - hhea_btb) / our_btb < 0.03: + logger.info("Font vertical metrics slightly off (%.1f%)", (our_btb - hhea_btb) / our_btb * 100.0) + metrics = Metric.TYPO if use_typo else Metric.WIN else: - # We trust the WIN metric more, see experiments in #1056 - print("{}: WARNING Font vertical metrics inconsistent (HHEA {} / TYPO {} / WIN {}), using WIN".format(projectName, hhea_btb, typo_btb, win_btb)) - our_btb = win_btb - metrics = Metric.WIN + # Try the other metric + our_btb = typo_btb if not use_typo else win_btb + if our_btb == hhea_btb: + logger.warning("Font vertical metrics probably wrong USE TYPO METRICS, assume opposite (i.e. %s)", 'True' if not use_typo else 'False') + use_typo = not use_typo + self.sourceFont.os2_use_typo_metrics = 1 if use_typo else 0 + metrics = Metric.TYPO if use_typo else Metric.WIN + else: + # We trust the WIN metric more, see experiments in #1056 + logger.warning("Font vertical metrics inconsistent (HHEA %d / TYPO %d / WIN %d), using WIN", hhea_btb, typo_btb, win_btb) + our_btb = win_btb + metrics = Metric.WIN # print("FINI hhea {} typo {} win {} use {} {} {}".format(hhea_btb, typo_btb, win_btb, use_typo, our_btb != hhea_btb, self.sourceFont.fontname)) @@ -1094,6 +1151,7 @@ class font_patcher: if self.font_dim['height'] == 0: # This can only happen if the input font is empty # Assume we are using our prepared templates + self.symbolsonly = True self.font_dim = { 'xmin' : 0, 'ymin' : -self.sourceFont.descent, @@ -1104,7 +1162,8 @@ class font_patcher: } our_btb = self.sourceFont.descent + self.sourceFont.ascent elif self.font_dim['height'] < 0: - sys.exit("{}: Can not detect sane font height".format(projectName)) + logger.critical("Can not detect sane font height") + sys.exit(1) # Make all metrics equal self.sourceFont.os2_typolinegap = 0 @@ -1118,12 +1177,13 @@ class font_patcher: self.sourceFont.os2_use_typo_metrics = 1 (check_hhea_btb, check_typo_btb, check_win_btb, _) = get_btb_metrics(self.sourceFont) if check_hhea_btb != check_typo_btb or check_typo_btb != check_win_btb or check_win_btb != our_btb: - sys.exit("{}: Error in baseline to baseline code detected".format(projectName)) + logger.critical("Error in baseline to baseline code detected") + sys.exit(1) # Step 2 # Find the biggest char width and advance width # 0x00-0x17f is the Latin Extended-A range - warned1 = self.args.quiet or self.args.nonmono # Do not warn if quiet or proportional target + warned1 = self.args.nonmono # Do not warn if proportional target warned2 = warned1 for glyph in range(0x21, 0x17f): if glyph in range(0x7F, 0xBF) or glyph in [ @@ -1141,22 +1201,25 @@ class font_patcher: if self.font_dim['width'] < self.sourceFont[glyph].width: self.font_dim['width'] = self.sourceFont[glyph].width if not warned1 and glyph > 0x7a: # NOT 'basic' glyph, which includes a-zA-Z - print("Warning: Extended glyphs wider than basic glyphs, results might be useless\n {}".format( - report_advance_widths(self.sourceFont))) + logger.debug("Extended glyphs wider than basic glyphs, results might be useless\n %s", + report_advance_widths(self.sourceFont)) warned1 = True # print("New MAXWIDTH-A {:X} {} -> {} {}".format(glyph, self.sourceFont[glyph].width, self.font_dim['width'], xmax)) if xmax > self.font_dim['xmax']: self.font_dim['xmax'] = xmax if not warned2 and glyph > 0x7a: # NOT 'basic' glyph, which includes a-zA-Z - print("Info: Extended glyphs wider bounding box than basic glyphs") + logger.debug("Extended glyphs wider bounding box than basic glyphs") warned2 = True # print("New MAXWIDTH-B {:X} {} -> {} {}".format(glyph, self.sourceFont[glyph].width, self.font_dim['width'], xmax)) if self.font_dim['width'] < self.font_dim['xmax']: - if not self.args.quiet: - print("Warning: Font has negative right side bearing in extended glyphs") + logger.debug("Font has negative right side bearing in extended glyphs") self.font_dim['xmax'] = self.font_dim['width'] # In fact 'xmax' is never used # print("FINAL", self.font_dim) + self.xavgwidth.append(self.args.xavgwidth) + if isinstance(self.xavgwidth[-1], int) and self.xavgwidth[-1] == 0: + self.xavgwidth[-1] = get_old_average_x_width(self.sourceFont) + def get_target_width(self, stretch): """ Get the target width (1 or 2 'cell') for a given stretch parameter """ @@ -1249,7 +1312,7 @@ class font_patcher: if sym_glyph.altuni: possible_codes += [ v for v, s, r in sym_glyph.altuni if v > currentSourceFontGlyph ] if len(possible_codes) == 0: - print(" Can not determine codepoint of {:X}. Skipping...".format(sym_glyph.unicode)) + logger.warning("Can not determine codepoint of %X. Skipping...", sym_glyph.unicode) continue currentSourceFontGlyph = min(possible_codes) else: @@ -1272,9 +1335,8 @@ class font_patcher: # check if a glyph already exists in this location if careful or 'careful' in sym_attr['params'] or currentSourceFontGlyph in self.essential: if currentSourceFontGlyph in self.sourceFont: - if not self.args.quiet: - careful_type = 'essential' if currentSourceFontGlyph in self.essential else 'existing' - print(" Found {} Glyph at {:X}. Skipping...".format(careful_type, currentSourceFontGlyph)) + careful_type = 'essential' if currentSourceFontGlyph in self.essential else 'existing' + logger.debug("Found %s Glyph at %X. Skipping...", careful_type, currentSourceFontGlyph) # We don't want to touch anything so move to next Glyph continue else: @@ -1422,8 +1484,8 @@ class font_patcher: if self.args.single: (xmin, _, xmax, _) = self.sourceFont[currentSourceFontGlyph].boundingBox() if int(xmax - xmin) > self.font_dim['width'] * (1 + (overlap or 0)): - print("\n Warning: Scaled glyph U+{:X} wider than one monospace width ({} / {} (overlap {}))".format( - currentSourceFontGlyph, int(xmax - xmin), self.font_dim['width'], overlap)) + logger.warning("Scaled glyph %X wider than one monospace width (%d / %d (overlap %f))", + currentSourceFontGlyph, int(xmax - xmin), self.font_dim['width'], overlap) # end for @@ -1564,7 +1626,7 @@ def half_gap(gap, top): gap_top = int(gap / 2) gap_bottom = gap - gap_top if top: - print("Redistributing line gap of {} ({} top and {} bottom)".format(gap, gap_top, gap_bottom)) + logger.info("Redistributing line gap of %d (%d top and %d bottom)", gap, gap_top, gap_bottom) return gap_top return gap_bottom @@ -1689,8 +1751,8 @@ def check_fontforge_min_version(): # versions tested: 20150612, 20150824 if actualVersion < minimumVersion: - sys.stderr.write("{}: You seem to be using an unsupported (old) version of fontforge: {}\n".format(projectName, actualVersion)) - sys.stderr.write("{}: Please use at least version: {}\n".format(projectName, minimumVersion)) + logger.critical("You seem to be using an unsupported (old) version of fontforge: %d", actualVersion) + logger.critical("Please use at least version: %d", minimumVersion) sys.exit(1) def check_version_with_git(version): @@ -1738,7 +1800,6 @@ def setup_arguments(): parser.add_argument('-s', '--mono', '--use-single-width-glyphs', dest='single', default=False, action='count', help='Whether to generate the glyphs as single-width not double-width (default is double-width)') parser.add_argument('-l', '--adjust-line-height', dest='adjustLineHeight', default=False, action='store_true', help='Whether to adjust line heights (attempt to center powerline separators more evenly)') parser.add_argument('-q', '--quiet', '--shutup', dest='quiet', default=False, action='store_true', help='Do not generate verbose output') - parser.add_argument('-w', '--windows', dest='windows', default=False, action='store_true', help='Limit the internal font name to 31 characters (for Windows compatibility)') parser.add_argument('-c', '--complete', dest='complete', default=False, action='store_true', help='Add all available Glyphs') parser.add_argument('--careful', dest='careful', default=False, action='store_true', help='Do not overwrite existing glyphs if detected') parser.add_argument('--removeligs', '--removeligatures', dest='removeligatures', default=False, action='store_true', help='Removes ligatures specificed in JSON configuration file') @@ -1748,15 +1809,34 @@ def setup_arguments(): 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)') + parser.add_argument('--makegroups', dest='makegroups', default=1, type=int, nargs='?', help='Use alternative method to name patched fonts (recommended)', const=1, choices=range(0, 6 + 1)) + # --makegroup has an additional undocumented numeric specifier. '--makegroup' is in fact '--makegroup 1'. + # Original font name: Hugo Sans Mono ExtraCondensed Light Italic + # NF Fam agg. + # 0 turned off, use old naming scheme [-] [-] [-] + # 1 HugoSansMono Nerd Font ExtraCondensed Light Italic [ ] [ ] [ ] + # 2 HugoSansMono Nerd Font ExtCn Light Italic [ ] [X] [ ] + # 3 HugoSansMono Nerd Font XCn Lt It [ ] [X] [X] + # 4 HugoSansMono NF ExtraCondensed Light Italic [X] [ ] [ ] + # 5 HugoSansMono NF ExtCn Light Italic [X] [X] [ ] + # 6 HugoSansMono NF XCn Lt It [X] [X] [X] + parser.add_argument('--variable-width-glyphs', dest='nonmono', default=False, action='store_true', help='Do not adjust advance width (no "overhang")') + parser.add_argument('--has-no-italic', dest='noitalic', default=False, action='store_true', help='Font family does not have Italic (but Oblique)') # 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('--progressbars', dest='progressbars', action='store_true', help='Show percentage completion progress bars per Glyph Set (default)') 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') + parser.add_argument('--debug', dest='debugmode', default=False, action='store_true', help='Verbose mode') + parser.add_argument('--dry', dest='dry_run', default=False, action='store_true', help='Do neither patch nor store the font, to check naming') + parser.add_argument('--xavgcharwidth', dest='xavgwidth', default=None, type=int, nargs='?', help='Adjust xAvgCharWidth (optional: concrete value)', const=True) + # --xavgcharwidth for compatibility with old applications like notepad and non-latin fonts + # Possible values with examples: + # - copy from sourcefont (default) + # 0 - calculate from font according to OS/2-version-2 + # 500 - set to 500 # symbol fonts to include arguments sym_font_group = parser.add_argument_group('Symbol Fonts') @@ -1774,8 +1854,9 @@ def setup_arguments(): args = parser.parse_args() - if args.makegroups and not FontnameParserOK: - sys.exit("{}: FontnameParser module missing (bin/scripts/name_parser/Fontname*), can not --makegroups".format(projectName)) + if args.makegroups > 0 and not FontnameParserOK: + logger.critical("FontnameParser module missing (bin/scripts/name_parser/Fontname*), specify --makegroups 0") + sys.exit(1) # if you add a new font, set it to True here inside the if condition if args.complete: @@ -1808,24 +1889,23 @@ def setup_arguments(): font_complete = False args.complete = font_complete - if args.alsowindows: - args.windows = False - if args.nonmono and args.single: - print("Warning: Specified contradicting --variable-width-glyphs and --use-single-width-glyph. Ignoring --variable-width-glyphs.") + logging.warning("Specified contradicting --variable-width-glyphs and --use-single-width-glyph. Ignoring --variable-width-glyphs.") args.nonmono = False make_sure_path_exists(args.outputdir) if not os.path.isfile(args.font): - sys.exit("{}: Font file does not exist: {}".format(projectName, args.font)) + logging.critical("Font file does not exist: %s", args.font) + sys.exit(1) if not os.access(args.font, os.R_OK): - sys.exit("{}: Can not open font file for reading: {}".format(projectName, args.font)) + logging.critical("Can not open font file for reading: %s", args.font) + sys.exit(1) is_ttc = len(fontforge.fontsInFile(args.font)) > 1 try: source_font_test = TableHEADWriter(args.font) args.is_variable = source_font_test.find_table([b'avar', b'cvar', b'fvar', b'gvarb', b'HVAR', b'MVAR', b'VVAR'], 0) if args.is_variable: - print(" Warning: Source font is a variable open type font (VF), opening might fail...") + logging.warning("Source font is a variable open type font (VF), opening might fail...") except: args.is_variable = False finally: @@ -1840,10 +1920,20 @@ def setup_arguments(): args.extension = '.' + args.extension if re.match("\.ttc$", args.extension, re.IGNORECASE): if not is_ttc: - sys.exit(projectName + ": Can not create True Type Collections from single font files") + logging.critical("Can not create True Type Collections from single font files") + sys.exit(1) else: if is_ttc: - sys.exit(projectName + ": Can not create single font files from True Type Collections") + logging.critical("Can not create single font files from True Type Collections") + sys.exit(1) + + if isinstance(args.xavgwidth, int) and not isinstance(args.xavgwidth, bool): + if args.xavgwidth < 0: + logging.critical("--xavgcharwidth takes no negative numbers") + sys.exit(2) + if args.xavgwidth > 16384: + logging.critical("--xavgcharwidth takes only numbers up to 16384") + sys.exit(2) return args @@ -1851,24 +1941,43 @@ def setup_arguments(): def main(): global version git_version = check_version_with_git(version) - print("{} Patcher v{} ({}) (ff {}) executing".format( - projectName, git_version if git_version else version, script_version, fontforge.version())) + allversions = "Patcher v{} ({}) (ff {})".format( + git_version if git_version else version, script_version, fontforge.version()) + print("{} {}".format(projectName, allversions)) if git_version: version = git_version check_fontforge_min_version() args = setup_arguments() + + global logger + logger = logging.getLogger(os.path.basename(args.font)) + logger.setLevel(logging.DEBUG) + f_handler = logging.FileHandler('font-patcher-log.txt') + f_handler.setFormatter(logging.Formatter('%(levelname)s: %(name)s %(message)s')) + logger.addHandler(f_handler) + logger.debug(allversions) + logger.debug("Options %s", repr(sys.argv[1:])) + c_handler = logging.StreamHandler(stream=sys.stdout) + c_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + if not args.debugmode: + c_handler.setLevel(logging.INFO) + logger.addHandler(c_handler) + logger.debug("Naming mode %d", args.makegroups) + patcher = font_patcher(args) sourceFonts = [] all_fonts = fontforge.fontsInFile(args.font) for i, subfont in enumerate(all_fonts): if len(all_fonts) > 1: - print("\n{}: Processing {} ({}/{})".format(projectName, subfont, i + 1, len(all_fonts))) + print("\n") + logger.info("Processing %s (%d/%d)", subfont, i + 1, len(all_fonts)) try: sourceFonts.append(fontforge.open("{}({})".format(args.font, subfont), 1)) # 1 = ("fstypepermitted",)) except Exception: - sys.exit("{}: Can not open font '{}', try to open with fontforge interactively to get more information".format( - projectName, subfont)) + logger.critical("Can not open font '%s', try to open with fontforge interactively to get more information", + subfont) + sys.exit(1) patcher.patch(sourceFonts[-1]) @@ -1877,13 +1986,6 @@ def main(): patcher.setup_font_names(f) patcher.generate(sourceFonts) - # This mainly helps to improve CI runtime - if patcher.args.alsowindows: - patcher.args.windows = True - for f in sourceFonts: - patcher.setup_font_names(f) - patcher.generate(sourceFonts) - for f in sourceFonts: f.close() diff --git a/original/.gitignore b/original/.gitignore index f7ad452..4d7a873 100644 --- a/original/.gitignore +++ b/original/.gitignore @@ -1,3 +1,2 @@ *.ttf -*.ttx *.zip diff --git a/patch_Iosevka.sh b/patch_Iosevka.sh index 1ce4d55..e173b64 100755 --- a/patch_Iosevka.sh +++ b/patch_Iosevka.sh @@ -9,11 +9,6 @@ if ! command -v fontforge >/dev/null; then exit 1 fi -# if ! command -v ttx >/dev/null; then -# printf "\033[1;31mfonttools\033[0m is not installed.\n" -# exit 1 -# fi - if [[ $# -eq 0 ]]; then versions=$(curl -H "Accept: application/vnd.github.v3+json" -s https://api.github.com/repos/be5invis/Iosevka/releases | jq -r '.[] | .tag_name' | sed -e 's/^v//g') version=$(echo "${versions}" | fzf --no-multi --prompt "Release: ") @@ -32,28 +27,16 @@ variants=( zipfile="original/ttf-iosevka-term-${version}.zip" if [ ! -f "${zipfile}" ]; then printf "\033[1;34mDownloading Iosevka Term version \033[1;31m%s\033[1;34m zip file ...\033[0m\n" "${version}" - curl -fSL https://github.com/be5invis/Iosevka/releases/download/v${version}/ttf-iosevka-term-${version}.zip -o ${zipfile} + curl -fSL "https://github.com/be5invis/Iosevka/releases/download/v${version}/ttf-iosevka-term-${version}.zip" -o "${zipfile}" fi printf "\033[1;34mUnzipping the downloaded archive ...\033[0m\n" -unzip ${zipfile} -d ./original +unzip "${zipfile}" -d ./original for variant in "${variants[@]}"; do printf "\033[1;34mPatching Iosevka term \033[1;31m%s\033[1;34m ...\033[0m\n" "${variant}" # Run the font-patcher script - fontforge -script ./font-patcher --quiet --no-progressbars --careful --complete ./original/iosevka-term-${variant}.ttf - mv -f ./*Complete.ttf ./patched/iosevka-term-${variant}-nerd-font.ttf - - # Correct xAvgCharWidth - # ttx -t "OS/2" ./original/iosevka-term-${variant}.ttf - # ttx -t "OS/2" ./patched/iosevka-term-${variant}-nerd-font.ttf - # original_x_avg_char_width=$(grep xAvgCharWidth ./original/iosevka-term-${variant}.ttx | cut -d '"' -f 2) - # sed -i "s/xAvgCharWidth value=\"[0-9]\+\"/xAvgCharWidth value=\"${original_x_avg_char_width}\"/g" ./patched/iosevka-term-${variant}-nerd-font.ttx - # mv -f ./patched/iosevka-term-${variant}-nerd-font.ttf ./patched/iosevka-term-${variant}-nerd-font.original.ttf - # ttx -o ./patched/iosevka-term-${variant}-nerd-font.ttf -m ./patched/iosevka-term-${variant}-nerd-font.original.ttf ./patched/iosevka-term-${variant}-nerd-font.ttx - - # Build hdmx table and correct TTF font family name - #python3 ./build-hdmx-for-sarasa.py ./patched/iosevka-term-${variant}-nerd-font.ttf - #python3 ./correct-ttf-font-family-name.py ./patched/iosevka-term-${variant}-nerd-font.ttf + fontforge -script ./font-patcher --careful --complete ./original/"iosevka-term-${variant}.ttf" + mv -fv ./IosevkaTermNerdFont-*.ttf ./patched/"iosevka-term-${variant}-nerd-font.ttf" done diff --git a/patched/.gitignore b/patched/.gitignore index ba6e845..d259f05 100644 --- a/patched/.gitignore +++ b/patched/.gitignore @@ -1,2 +1 @@ *.ttf -*.ttx diff --git a/src/glyphs/octicons.ttf b/src/glyphs/octicons.ttf deleted file mode 100644 index ff0dda1..0000000 Binary files a/src/glyphs/octicons.ttf and /dev/null differ diff --git a/src/glyphs/octicons/octicons.ttf b/src/glyphs/octicons/octicons.ttf new file mode 100644 index 0000000..f864314 Binary files /dev/null and b/src/glyphs/octicons/octicons.ttf differ diff --git a/update.sh b/update.sh index e5ce8f9..ce080d7 100755 --- a/update.sh +++ b/update.sh @@ -4,16 +4,17 @@ echo "Downloading 'font-patcher' script..." curl -fsSL https://github.com/ryanoasis/nerd-fonts/raw/master/font-patcher -o ./font-patcher chmod 755 ./font-patcher -for path in codicons font-awesome powerline-symbols weather-icons; do +for path in codicons font-awesome materialdesign octicons powerline-symbols weather-icons; do mkdir -p ./src/glyphs/"$path" done +mkdir -p ./bin/scripts/name_parser -echo "Downloading glyph fonts..." glyphs=( "codicons/codicon.ttf" "font-awesome/FontAwesome.otf" "materialdesign/MaterialDesignIconsDesktop.ttf" "materialdesign/MaterialDesignIconsDesktop_orig.ttf" + "octicons/octicons.ttf" "powerline-symbols/PowerlineSymbols.otf" "weather-icons/weathericons-regular-webfont.ttf" "Pomicons.otf" @@ -24,16 +25,33 @@ glyphs=( "font-awesome-extension.ttf" "font-logos.ttf" "materialdesignicons-webfont.ttf" - "octicons.ttf" "original-source.otf" ) -upstream_src_glyphs_url="https://github.com/ryanoasis/nerd-fonts/raw/master/src/glyphs" +name_parser=( + "FontnameParser.py" + "FontnameTools.py" + "query_monospace" + "query_names" + "query_panose" + "query_sftn" + "query_version" +) +upstream_src_glyphs_url="https://github.com/ryanoasis/nerd-fonts/raw/master/src/glyphs" +upstream_name_parser_url="https://github.com/ryanoasis/nerd-fonts/raw/master/bin/scripts/name_parser" + +echo "Downloading glyph fonts..." for glyph in "${glyphs[@]}"; do # replace all `whitespace` characters with `%20` percent_encoded_uri="${upstream_src_glyphs_url}/${glyph//\ /%20}" - curl -fSL ${percent_encoded_uri} -o "src/glyphs/${glyph}" + curl -fSL "${percent_encoded_uri}" -o "src/glyphs/${glyph}" done find ./src/glyphs/ -type f -exec chmod 644 '{}' \; + +echo "Downloading helper scripts for font-patcher ..." +for file in "${name_parser[@]}"; do + curl -fSL "${upstream_name_parser_url}/${file}" -o "bin/scripts/name_parser/${file}" +done +find ./bin/scripts/name_parser/ -type f -exec chmod 644 '{}' \;