Skip to content

Instantly share code, notes, and snippets.

@schwartzie
Created May 5, 2024 16:48
Show Gist options
  • Save schwartzie/eb0c001849f17341e546158376f62ac4 to your computer and use it in GitHub Desktop.
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.
#! /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