Created
May 5, 2024 16:48
-
-
Save schwartzie/eb0c001849f17341e546158376f62ac4 to your computer and use it in GitHub Desktop.
Walks a directory tree and converts any fonts found in MacOS resource forks to modern OTFs and TTFs. Preserves the original directory structure.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#! /usr/bin/env python3 | |
import os | |
import sys | |
import logging | |
import subprocess | |
import io | |
import shutil | |
##################################################### | |
# Walks a directory tree and converts any fonts found in MacOS resource forks | |
# to modern OTFs and TTFs. Preserves the original directory structure. | |
# Inspired by: | |
# - https://stackoverflow.com/a/64561713/455641 | |
# - https://stackoverflow.com/a/77364080/455641 | |
# Source directory containing fonts in resource forks | |
SRC_DIR = sys.argv[1] | |
# Destination directory where extracted fonts will go. | |
# Will use same directory tree as in SRC_DIR | |
DST_BASE_DIR = sys.argv[2] | |
# Set to True to move converted files from DST_BASE_DIR to SRC_DIR when done | |
MOVE_TO_SRC_WHEN_DONE = False | |
# Path to Fondu binary for extracting font data from resource forks | |
# Documentation: https://fondu.sourceforge.net/index.html | |
# How to compile for M1: https://stackoverflow.com/a/77364080/455641 | |
FONDU = '/usr/local/bin/fondu' | |
# Path to fontforge binary for generating OTFs from extracted | |
# Adobe Type 1 fonts (.pfb). | |
# Install with `brew install fontforge` | |
# If a suitable Adobe Font Metric (.afm) file can be matched to the Type 1 | |
# font, this can be included in the generation process: | |
# - https://graphicdesign.stackexchange.com/a/2780 | |
# - https://askubuntu.com/a/1287478 | |
FONTFORGE = '/opt/homebrew/bin/fontforge' | |
# Set preferred log level | |
LOG_LEVEL = logging.info | |
##################################################### | |
logging.basicConfig( | |
level=LOG_LEVEL, | |
format='[%(levelname)s] %(message)s', | |
) | |
logger = logging.getLogger() | |
BDF = '.bdf' # Glyph Bitmap Distribution Format (BDF) | |
PFB = '.pfb' # Adobe Type 1 Font (PostScript) | |
AFM = '.afm' # Adobe Font Metric | |
TTF = '.ttf' # TrueType Font | |
OTF = '.otf' # OpenType Font | |
def mkdir(path, mode=0o755): | |
"""Wrap os.makedirs with a check if the dir already exists to avoid | |
errors""" | |
if not os.path.isdir(path): | |
os.makedirs(path, mode=mode, exist_ok=True) | |
def get_path_resource_fork(path): | |
"""Append resource fork path to the provided path""" | |
return os.path.join(path, '..namedfork/rsrc') | |
def run_proc(cmd, **kwargs): | |
"""Runs a process and yields a generator to simplify iterating over lines | |
in the output""" | |
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, **kwargs) | |
yield from consume_proc_output(proc.stdout) | |
def consume_proc_output(stream): | |
"""Generator iterating over lines in a byte stream""" | |
if isinstance(stream, bytes): | |
stream = io.BytesIO(stream) | |
while True: | |
line = stream.readline() | |
if not line: | |
break | |
yield line.decode('utf-8').rstrip() | |
def extract_fonts_from_file(working_dir, file_name): | |
"""Attempt to extract fonts from a given file with fondu""" | |
fondu_proc = subprocess.run( | |
[FONDU, '-force', '-show', '-afm', '-trackps', file_name], | |
cwd=working_dir, capture_output=True | |
) | |
if fondu_proc.returncode != 0: | |
logger.warning(f'Could not extract {file_name}') | |
return None | |
extracted_files = [] | |
for line in consume_proc_output(fondu_proc.stderr): | |
if not line.startswith('Creating '): | |
logger.debug('fondu error: %s', line) | |
continue | |
converted_file = line.split(' ', 2)[1] | |
# Some PFBs are outputted as "Untitled-1.pfb" | |
# rename if that's the case here | |
if converted_file.startswith('Untitled-'): | |
old_name = converted_file | |
converted_file = file_name + os.path.splitext(old_name)[1] | |
os.rename(os.path.join(dst_dir, old_name), | |
os.path.join(dst_dir, converted_file)) | |
extracted_files.append((file_name, converted_file)) | |
return extracted_files | |
def find_afm(src_dir, dst_dir, file_name, converted_file): | |
"""Attempt to find an AFM file for the supplied PFB""" | |
for name in [file_name, converted_file]: | |
base_name = os.path.splitext(name)[0] | |
converted_afm = os.path.join(dst_dir, base_name + AFM) | |
if os.path.exists(converted_afm): | |
return converted_afm | |
candidates = [f for f in run_proc([ | |
'find', src_dir, '-type', 'f', '-iname', base_name + AFM | |
])] | |
if len(candidates) >= 1: | |
if len(candidates) > 1: | |
logging.debug('multiple AFMs for %s/%s', src_dir, file_name) | |
return candidates[0] | |
return None | |
def pfb_to_otf(pfb, otf, afm=None): | |
"""Generate an OTF from a PFB, optionally using an AFM""" | |
cmd = [FONTFORGE, '-lang=ff', '-c', | |
('Open($1); MergeFeature($3); Generate($2)' | |
if afm else 'Open($1); Generate($2)'), | |
pfb, otf] + ([afm] if afm else []) | |
ff_proc = subprocess.run(cmd, capture_output=True) | |
if ff_proc.returncode != 0: | |
logger.error(f'ff_proc {ff_proc}') | |
return ff_proc.returncode == 0 | |
# Find empty files in SRC_DIR, grouping them by directory | |
# This assumes that fonts are in subdirectories by family that we'll want | |
# to keep as a unit | |
dir_map = {} | |
for src_path in run_proc(['find', SRC_DIR, '-type', 'f', '-empty']): | |
src_dir = os.path.dirname(src_path) | |
file_name = os.path.basename(src_path) | |
if src_dir not in dir_map: | |
dir_map[src_dir] = [] | |
dir_map[src_dir].append(file_name) | |
# Iterate over directories in SRC_DIR with empty files to attempt font | |
# extraction on them | |
for src_dir, file_names in dir_map.items(): | |
logger.debug(f'Processing dir {src_dir}') | |
dst_dir = os.path.join(DST_BASE_DIR, os.path.relpath(src_dir, SRC_DIR)) | |
mkdir(dst_dir) | |
fondu_files = [] | |
# Iterate over each of the empty files by name | |
for file_name in file_names: | |
src_path = os.path.join(src_dir, file_name) | |
dst_path = os.path.join(dst_dir, file_name) | |
# Check if resource fork is accessible, and copy to dst_path if so | |
src_rsrc_path = get_path_resource_fork(src_path) | |
if not os.path.exists(src_rsrc_path): | |
logger.info(f'Cannot read resource fork from {src_path}') | |
continue | |
shutil.copy(src_rsrc_path, dst_path) | |
# Process resource forks with fondu | |
extracted = extract_fonts_from_file(dst_dir, file_name) | |
if extracted: | |
fondu_files += extracted | |
# Remove copy of resource fork | |
os.remove(dst_path) | |
# Sort extracted files by extension: when we iterate over these next, | |
# we want the AFMs last since we need them for processing the PFBs but | |
# don't need to keep them beyond that. | |
fondu_files.sort(key=lambda p: os.path.splitext(p[1])[1], reverse=True) | |
# Iterate over extracted files - we get a tuple of the original file name | |
# and the name of the extracted file | |
for (file_name, converted_file) in fondu_files: | |
ext = os.path.splitext(converted_file)[1] | |
dst_path = os.path.join(dst_dir, converted_file) | |
logger.debug('fondu out: %s', converted_file) | |
if ext in [AFM, BDF]: | |
# Delete AFMs and BDFs since they're not really useful to us | |
if os.path.exists(dst_path): | |
os.remove(dst_path) | |
continue | |
elif ext == PFB: | |
# Convert PFBs to OTFs - the whole reason we're here! | |
# Try to find an AFM that's a good fit if possible | |
afm_file = find_afm(src_dir, dst_dir, file_name, converted_file) | |
otf_path = os.path.splitext(dst_path)[0] + OTF | |
if pfb_to_otf(dst_path, otf_path, afm_file): | |
logger.debug('generated %s %s', file_name, otf_path) | |
if os.path.exists(dst_path): | |
# remove PFB if we've successfully converted it | |
os.remove(dst_path) | |
converted_file = os.path.splitext(converted_file)[0] + OTF | |
dst_path = os.path.join(dst_dir, converted_file) | |
else: | |
logger.warning('error generating otf %s %s', | |
file_name, dst_path) | |
if not MOVE_TO_SRC_WHEN_DONE: | |
continue | |
# if MOVE_TO_SRC_WHEN_DONE is True, we'll relocate the extracted fonts | |
# from DST_BASE_DIR to SRC_DIR | |
src_path = os.path.join(src_dir, converted_file) | |
if os.path.exists(src_path): | |
split_name = os.path.splitext(converted_file) | |
src_path = os.path.join(src_dir, | |
split_name[0] + '-Fixed' + split_name[1]) | |
if os.path.exists(dst_path): | |
# A previous extracted file might have had the same name | |
logger.debug('moving converted: %s => %s', dst_path, src_path) | |
os.rename(dst_path, src_path) | |
# Clean up DST_BASE_DIR | |
subprocess.run(['find', DST_BASE_DIR, '-type', 'f', '-empty', '-delete']) | |
subprocess.run(['find', DST_BASE_DIR, '-type', 'd', '-empty', '-delete']) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment