diff --git a/.github/workflows/font-patcher.yml b/.github/workflows/font-patcher.yml index fdfdff91b5..61c94cf9c9 100644 --- a/.github/workflows/font-patcher.yml +++ b/.github/workflows/font-patcher.yml @@ -47,6 +47,16 @@ jobs: sudo ninja sudo ninja install + - name: Compile showttf + run: | + cd "$GITHUB_WORKSPACE/fontforge-${{matrix.FontForgeRelease.version}}/contrib/fonttools" + mkdir build + cd build + cmake -Wno-dev -GNinja .. + ninja showttf + echo "SHOWTTF=$(realpath showttf)" >> $GITHUB_ENV + cd ../../.. + - name: Setup additional dependencies run: | pip install fonttools --quiet @@ -83,6 +93,14 @@ jobs: echo "FONT_FAMILY was ${{ env.FONT_FAMILY }}" [[ "${{ env.FONT_FAMILY }}" == "Hack Nerd Font" ]] && echo "Font Family matches expected" || exit 1 + - name: Spot check font properties + run: | + ${{ env.SHOWTTF }} -c "$GITHUB_WORKSPACE/temp/Hack Regular Nerd Font Complete.ttf" | grep 'File Checksum.*diff=0\s*$' && echo "TTF checksum ok" || exit 1 + ORIG_MINPPEM=$(${{ env.SHOWTTF }} -c "src/unpatched-fonts/Hack/Regular/Hack-Regular.ttf" | grep 'lowestppem=' ) + PATCH_MINPPEM=$(${{ env.SHOWTTF }} -c "$GITHUB_WORKSPACE/temp/Hack Regular Nerd Font Complete.ttf" | grep 'lowestppem=' ) + echo "${ORIG_MINPPEM} == ${PATCH_MINPPEM}" + [[ ${ORIG_MINPPEM} == ${PATCH_MINPPEM} ]] && echo "lowestRecPPEM matches" || exit 1 + - name: Patcher monospaced run: | mkdir -p $GITHUB_WORKSPACE/temp/ diff --git a/font-patcher b/font-patcher index 9440b2a521..5063a20a65 100755 --- a/font-patcher +++ b/font-patcher @@ -37,6 +37,114 @@ except ImportError: ) +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): self.args = None # class 'argparse.Namespace' @@ -123,15 +231,40 @@ class font_patcher: # 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.fontname, 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.fullname, 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):