"""Regenerates the BMFont sprite sheets in this folder from their .bmfc files. Each Fonts\\ folder holds a -ttf.7z of its TrueType files and one .bmfc per face and size, such as LiberationSans-Bold-16px.bmfc. The faces of one size share a single page, -_0.png, with each face's own .fnt pointing into it, so mixed regular and bold text draws from one texture. The .ttf files stay packed to keep the tree small. This unpacks them under Junk, runs BMFont there, lays each size's pages out as one and copies the results back. Usage: python MakeFonts.py every size except the frozen ones python MakeFonts.py LiberationSans every size of one family python MakeFonts.py LiberationSans-16px all the faces at one size, which always rebuild together python MakeFonts.py --check regenerate into Junk only, and report which sizes would change python MakeFonts.py --fit first set each sheet's page size in its .bmfc to the smallest that fits python MakeFonts.py --check --fit report any sheet whose page could be smaller, and change nothing python MakeFonts.py --unpack Philosopher unpack the fonts and stop, to open a .bmfc in the BMFont window A frozen size is rebuilt only when named. Needs Pillow to lay out a size with more than one face. See ..\BMFont\README for the tool itself. """ import hashlib import math import os import re import shutil import struct import subprocess import sys FONTS = os.path.dirname(os.path.abspath(__file__)) BMFONT = os.path.join(FONTS, '..', 'BMFont', 'bmfont64.exe') SEVEN_ZIP = os.path.join(FONTS, '..', '7-Zip', '7zr.exe') JUNK = os.path.join(FONTS, '..', '..', 'Junk', 'Fonts') # Sizes whose checked-in pixels must not change, because GDI on another machine would rasterize a few glyphs a pixel # differently. See Philosopher\README. FROZEN = {'Philosopher-32px'} STAMP = '.archive-sha256' # A sheet takes a second or two, so this only catches a hang. RUN_TIMEOUT_SECONDS = 120 # A page too small for its glyphs can make BMFont hang, so a fit that runs this long is a miss. FIT_TIMEOUT_SECONDS = 15 # The sides a page can have. Powers of two, so the page suits any texture hardware. PAGE_SIDES = (32, 64, 128, 256, 512) # The first face of a size sits at the top left of its page, and the others follow in name order. FIRST_FACE = 'Regular' SHEET_NAME = re.compile(r'^(?P.+)-(?P[A-Za-z]+)-(?P\d+px)$') BLOCK_COMMON = 2 BLOCK_PAGES = 3 BLOCK_CHARS = 4 CHAR_RECORD_BYTES = 20 def sha256Of(path): return hashlib.sha256(open(path, 'rb').read()).hexdigest() def archiveOf(root, family): return os.path.join(root, family, family + '-ttf.7z') def familiesIn(root): """The folders that hold a font archive, by name.""" return sorted(name for name in os.listdir(root) if os.path.exists(archiveOf(root, name))) def groupsOf(root, family): """Maps each size, such as Philosopher-18px, to the sheets that share its page, such as Philosopher-Regular-18px.""" groups = {} for name in sorted(os.listdir(os.path.join(root, family))): if not name.endswith('.bmfc'): continue sheet = name[:-len('.bmfc')] match = SHEET_NAME.match(sheet) if not match or match.group('family') != family: raise SystemExit(name + ' is not named ' + family + '--px.bmfc') groups.setdefault(family + '-' + match.group('size'), []).append(sheet) for group, sheets in groups.items(): sheets.sort(key=lambda sheet: (SHEET_NAME.match(sheet).group('face') != FIRST_FACE, sheet)) return groups def unpack(family): """Extracts the family's fonts under Junk, unless that's already done for this archive. Returns the folder.""" archive = archiveOf(FONTS, family) stage = os.path.join(JUNK, family) stamp = os.path.join(stage, STAMP) hashed = sha256Of(archive) if os.path.exists(stamp) and open(stamp).read() == hashed: return stage if os.path.exists(stage): shutil.rmtree(stage) os.makedirs(stage) subprocess.run([SEVEN_ZIP, 'x', '-y', '-o' + stage, archive], check=True, stdout=subprocess.DEVNULL) open(stamp, 'w').write(hashed) return stage def blocksOf(data): """Yields each block of a binary .fnt as (type, body).""" if data[:4] != b'BMF\x03': raise SystemExit('Not a binary BMFont file') pos = 4 while pos < len(data): size = struct.unpack_from(' 0xFFFF or y + top > 0xFFFF: raise SystemExit(fntPath + ' does not fit a page this big') struct.pack_into(' %dx%d' % ('smaller ' if check else 'fit ', sheet, *current, *smallest)) if not check: open(path, 'wb').write(withPageSize(raw, smallest).encode('utf-8')) return True def build(family, group, sheets): """Generates the sheets of one size and joins them into one page. Returns the built files, keyed by name.""" stage = os.path.join(unpack(family), 'built') if os.path.exists(stage): shutil.rmtree(stage) os.makedirs(stage) generated = [generate(family, sheet) for sheet in sheets] pageName = group + '_0.png' pagePath = os.path.join(stage, pageName) if len(generated) == 1: shutil.copy(generated[0][1], pagePath) corners = [(0, 0)] pageSize = struct.unpack('>II', open(pagePath, 'rb').read()[16:24]) else: corners, pageSize = layOutPages([page for _, page in generated], pagePath) returnMe = {pageName: pagePath} for sheet, (fnt, _), (left, top) in zip(sheets, generated, corners): path = os.path.join(stage, sheet + '.fnt') open(path, 'wb').write(retarget(fnt, pageName, left, top, pageSize)) returnMe[sheet + '.fnt'] = path return returnMe def sameFile(built, kept): """Whether the file is unchanged. A page counts by its pixels, since the PNG encoding is the library's.""" if not os.path.exists(kept): return False if open(built, 'rb').read() == open(kept, 'rb').read(): return True if not built.endswith('.png'): return False from PIL import Image left = Image.open(built).convert('RGBA') right = Image.open(kept).convert('RGBA') return left.size == right.size and left.tobytes() == right.tobytes() def selected(names, family, group, sheets): return not names or family in names or group in names or any(sheet in names for sheet in sheets) def run(names, check, fitFirst): returnMe = 0 for family in familiesIn(FONTS): for group, sheets in groupsOf(FONTS, family).items(): if not selected(names, family, group, sheets): continue if group in FROZEN and group not in names and not any(sheet in names for sheet in sheets): print('frozen ' + group) continue if fitFirst: tooBig = [fit(family, sheet, check) for sheet in sheets] if check: returnMe = returnMe or int(any(tooBig)) continue built = build(family, group, sheets) for sheet in sheets: gaps = glyphGaps(os.path.join(FONTS, family, sheet + '.bmfc'), built[sheet + '.fnt']) if gaps: print('MISSING ' + sheet + ' no glyph for ' + ' '.join('U+%04X' % codepoint for codepoint in gaps)) returnMe = 1 kept = {name: os.path.join(FONTS, family, name) for name in built} changed = [name for name in built if not sameFile(built[name], kept[name])] if check: print(('same ' if not changed else 'differs ') + group + ' ' + ' '.join(changed)) returnMe = returnMe or int(bool(changed)) continue for name in changed: shutil.copy(built[name], kept[name]) print(('rebuilt ' if changed else 'same ') + group + ' ' + ' '.join(changed)) return returnMe def main(arguments): check = '--check' in arguments fitFirst = '--fit' in arguments unpackOnly = '--unpack' in arguments names = [argument for argument in arguments if not argument.startswith('--')] families = familiesIn(FONTS) known = set(families) for family in families: for group, sheets in groupsOf(FONTS, family).items(): known.add(group) known.update(sheets) for name in names: if name not in known: raise SystemExit('No family, size or sheet named ' + name + '. Families: ' + ', '.join(families)) if unpackOnly: for family in [name for name in names if name in families] or families: print(unpack(family)) return 0 try: return run(names, check, fitFirst) except RuntimeError as problem: raise SystemExit(str(problem)) if __name__ == '__main__': sys.exit(main(sys.argv[1:]))