Convert from Homebrew Formulae to Casks for font installation

Formulae can't install to ~/Library/Fonts/ due to Homebrew sandboxing.
Casks have a built-in `font` artifact that handles this automatically.

- Replace Formula/ with Casks/ directory
- Rewrite generator to produce cask files instead of formulae
- Add .ttc (TrueType Collection) support
- Update all tests for cask format
- Update CLI and documentation
- Fonts with no installable files (TTF/OTF/TTC) are skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Troutman 2026-03-07 21:38:59 -06:00
parent cb1918c30f
commit 76743cdc4d
No known key found for this signature in database
273 changed files with 2509 additions and 10048 deletions

View file

@ -1,68 +1,44 @@
#!/usr/bin/env python3
"""Generate Homebrew Cask files for all fonts in font_files/."""
import re
from pathlib import Path
def formula_name_to_class(formula_name: str) -> str:
"""Convert formula name (e.g. 'acrylic-hand', 'graham_hand') to valid Ruby PascalCase."""
# Split on hyphens and underscores, capitalize each segment, join (no separators)
parts = re.split(r"[-_]+", formula_name)
return "".join(p.capitalize() for p in parts if p)
WEB_EXTENSIONS = (".woff", ".woff2", ".eot", ".svg")
class HomebrewFormulaGenerator:
class HomebrewCaskGenerator:
def __init__(self, fonts_dir):
self.fonts_dir = Path(fonts_dir)
self.formula_dir = self.fonts_dir.parent / "Formula"
self.formula_dir.mkdir(exist_ok=True)
self.web_extensions = (".woff", ".woff2", ".eot", ".svg")
self.casks_dir = self.fonts_dir.parent / "Casks"
self.casks_dir.mkdir(exist_ok=True)
def _font_has_files(self, font_dir: Path) -> tuple[bool, bool, bool, bool]:
"""Return (has_ttf, has_otf, has_web, has_other) for the font folder."""
has_ttf = any((font_dir / "ttf").glob("*.ttf"))
has_otf = any((font_dir / "otf").glob("*.otf"))
def _collect_font_files(self, font_dir: Path) -> list[str]:
"""Return list of font file paths relative to extracted archive root for the font artifact."""
font_name = font_dir.name
files = []
ttf_dir = font_dir / "ttf"
for f in sorted(ttf_dir.iterdir()):
if f.is_file() and f.suffix.lower() in (".ttf", ".ttc"):
files.append(f"font_files/{font_name}/ttf/{f.name}")
for otf in sorted((font_dir / "otf").glob("*.otf")):
files.append(f"font_files/{font_name}/otf/{otf.name}")
return files
def _collect_all_info(self, font_dir: Path) -> dict:
"""Collect metadata about a font directory."""
font_name = font_dir.name
formula_name = font_name.replace("font-", "", 1)
font_files = self._collect_font_files(font_dir)
# Determine what types are present
has_ttf = any(f.endswith(".ttf") or f.endswith(".ttc") for f in font_files)
has_otf = any(f.endswith(".otf") for f in font_files)
web_dir = font_dir / "web"
has_web = any(
f.suffix.lower() in self.web_extensions for f in web_dir.glob("*") if f.is_file()
f.suffix.lower() in WEB_EXTENSIONS for f in web_dir.glob("*") if f.is_file()
)
other_dir = font_dir / "other_files"
has_other = any(other_dir.iterdir()) if other_dir.exists() else False
return (has_ttf, has_otf, has_web, has_other)
def _generate_test_block(
self, formula_name: str, has_ttf: bool, has_otf: bool, has_web: bool, has_other: bool
) -> str:
"""Generate test block that asserts at least one install path has files for this formula."""
assertions = []
if has_ttf:
assertions.append('assert (share/"fonts/truetype").glob("*.ttf").any?, "No TTF fonts installed"')
if has_otf:
assertions.append('assert (share/"fonts/opentype").glob("*.otf").any?, "No OTF fonts installed"')
if has_web:
assertions.append(
'assert (share/"fonts/webfonts").glob("*").any?, "No web fonts installed"'
)
if has_other:
assertions.append(
f'assert_predicate share/"{formula_name}", :directory?, "Other files dir missing"'
)
if not assertions:
assertions.append(
f'assert_predicate share/"{formula_name}", :directory?, "Formula share dir missing"'
)
return "\n ".join(assertions)
def generate_formula_content(
self,
font_name: str,
formula_name: str,
has_ttf: bool,
has_otf: bool,
has_web: bool,
has_other: bool,
) -> str:
"""Generate the Ruby formula content for a font."""
class_name = formula_name_to_class(formula_name)
extensions = []
if has_ttf:
extensions.append("TTF")
@ -71,110 +47,71 @@ class HomebrewFormulaGenerator:
if has_web:
extensions.append("web")
ext_comment = " ".join(extensions) if extensions else "other_files only"
test_block = self._generate_test_block(
formula_name, has_ttf, has_otf, has_web, has_other
)
return f"""# typed: false
# frozen_string_literal: true
# This file was generated by the font folder cleanup script
return {
"font_name": font_name,
"formula_name": formula_name,
"font_files": font_files,
"ext_comment": ext_comment,
}
def generate_cask_content(self, info: dict) -> str:
"""Generate the Ruby cask content for a font."""
font_name = info["font_name"]
formula_name = info["formula_name"]
font_files = info["font_files"]
ext_comment = info["ext_comment"]
# Build font artifact lines
font_lines = "\n".join(f' font "{f}"' for f in font_files)
# Human-readable name: replace hyphens/underscores with spaces, title case
display_name = re.sub(r"[-_]+", " ", formula_name).title()
return f"""# This file was generated by the font folder cleanup script
# Do not edit this file directly
# Installs: {ext_comment}
class Font{class_name} < Formula
desc "Font: {formula_name}"
homepage "http://clancy.genet-godzilla.ts.net:8085/Fonts/homebrew-fonts"
url "http://clancy.genet-godzilla.ts.net:8085/Fonts/homebrew-fonts/archive/main.tar.gz"
cask "{font_name}" do
version "1.0.0"
sha256 :no_check
def install
# Create font directories
(share/"fonts").mkpath
(share/"fonts/truetype").mkpath
(share/"fonts/opentype").mkpath
(share/"fonts/webfonts").mkpath
url "http://clancy.genet-godzilla.ts.net:8085/Fonts/homebrew-fonts/archive/main.tar.gz"
name "{display_name}"
homepage "http://clancy.genet-godzilla.ts.net:8085/Fonts/homebrew-fonts"
# Install TTF fonts
Dir.glob("font_files/{font_name}/ttf/*.ttf").each do |font|
system "cp", font, share/"fonts/truetype"
end
# Install OTF fonts
Dir.glob("font_files/{font_name}/otf/*.otf").each do |font|
system "cp", font, share/"fonts/opentype"
end
# Install web fonts
Dir.glob("font_files/{font_name}/web/*.{{woff,woff2,eot,svg}}").each do |font|
system "cp", font, share/"fonts/webfonts"
end
# Install documentation and other files
(share/"{formula_name}").mkpath
Dir.glob("font_files/{font_name}/other_files/*").each do |file|
system "cp", "-r", file, share/"{formula_name}"
end
end
def post_install
user_fonts = Pathname.new(File.expand_path("~/Library/Fonts"))
user_fonts.mkpath
Dir.glob(share/"fonts/truetype/*.ttf").each do |f|
target = user_fonts/File.basename(f)
FileUtils.rm_f(target)
FileUtils.ln_sf(f, target)
end
Dir.glob(share/"fonts/opentype/*.otf").each do |f|
target = user_fonts/File.basename(f)
FileUtils.rm_f(target)
FileUtils.ln_sf(f, target)
end
end
def caveats
<<~EOS
Fonts have been copied to ~/Library/Fonts/ and should appear in Font Book.
Web fonts and other files are available in:
#{{share}}/fonts/webfonts
#{{share}}/{formula_name}
EOS
end
test do
{test_block}
end
{font_lines}
end
"""
def generate_formulas(self):
"""Generate Homebrew formulas for all font folders."""
def generate_casks(self):
"""Generate Homebrew casks for all font folders."""
for font_dir in sorted(self.fonts_dir.glob("font-*")):
if not font_dir.is_dir():
continue
font_name = font_dir.name
formula_name = font_name.replace("font-", "", 1)
has_ttf, has_otf, has_web, has_other = self._font_has_files(font_dir)
formula_content = self.generate_formula_content(
font_name, formula_name, has_ttf, has_otf, has_web, has_other
)
formula_path = self.formula_dir / f"font-{formula_name}.rb"
formula_path.write_text(formula_content)
print(f"Generated formula for font-{formula_name}")
info = self._collect_all_info(font_dir)
if not info["font_files"]:
print(f"Skipping {font_dir.name}: no TTF or OTF files")
continue
cask_content = self.generate_cask_content(info)
cask_path = self.casks_dir / f"{font_dir.name}.rb"
cask_path.write_text(cask_content)
print(f"Generated cask for {font_dir.name}")
def main():
# Get the absolute path to the font_files directory
script_dir = Path(__file__).parent
fonts_dir = script_dir.parent / 'font_files'
fonts_dir = script_dir.parent / "font_files"
if not fonts_dir.exists():
print(f"Error: Font directory not found: {fonts_dir}")
return
generator = HomebrewFormulaGenerator(fonts_dir)
generator.generate_formulas()
generator = HomebrewCaskGenerator(fonts_dir)
generator.generate_casks()
if __name__ == "__main__":
main()
main()