#!/usr/bin/env python3 """ iglyph - bundle .ttf/.otf files into a single iOS/iPadOS .mobileconfig The Apple .mobileconfig spec has long supported multi-font payloads in a single profile. iGlyph just... does that. Pure stdlib. The App Store options (iFont, Fontastic) bundle too, but charge for it. Usage: python3 iglyph.py ./my-fonts/ python3 iglyph.py ./fonts/ -o brand.mobileconfig --name "lark.cx Fonts" python3 iglyph.py ./fonts/ --list python3 iglyph.py ./fonts/ --serve # default port 18888 python3 iglyph.py ./fonts/ --serve 8080 --timeout 30 """ from __future__ import annotations import argparse import hashlib import http.server import plistlib import re import socket import socketserver import struct import sys import threading import time import uuid from pathlib import Path # ======================================================================= # Font validation and PostScript-name extraction (stdlib only) # ======================================================================= # Magic bytes at offset 0 for supported font formats _TTF_MAGIC = b"\x00\x01\x00\x00" _OTF_MAGIC = b"OTTO" _TRUE_MAGIC = b"true" # legacy Apple TTF _TTC_MAGIC = b"ttcf" # font collection container (covers .ttc and .otc) _WOFF_MAGIC = b"wOFF" _WOFF2_MAGIC = b"wOF2" _VALID_MAGICS = {_TTF_MAGIC, _OTF_MAGIC, _TRUE_MAGIC} class FontError(ValueError): """Raised when a font file is invalid or unsupported""" def validate_font_magic(path: Path) -> None: """Check the first 4 bytes look like a TTF or OTF Raises FontError with a helpful message for known-bad formats """ if path.stat().st_size < 12: raise FontError(f"{path}: file too small to be a valid font") head = path.read_bytes()[:4] if head in _VALID_MAGICS: return if head == _TTC_MAGIC: raise FontError( f"{path}: this is a font collection (.ttc/.otc). " f"iOS doesn't support these; split it into individual .ttf/.otf " f"files with fontTools before bundling." ) if head == _WOFF_MAGIC or head == _WOFF2_MAGIC: raise FontError( f"{path}: this is a web font (WOFF/WOFF2), not a TTF/OTF. " f"iOS won't install it. Convert it to TTF/OTF first." ) raise FontError( f"{path}: doesn't look like a valid font " f"(magic bytes: {head!r}, expected TTF or OTF)" ) def extract_postscript_name(path: Path) -> str | None: """Parse the `name` table from a TTF/OTF and return the PostScript name Returns None if the name table can't be parsed or nameID 6 isn't present. Pure stdlib - no fontTools dependency. See: learn.microsoft.com/en-us/typography/opentype/spec/name """ try: data = path.read_bytes() # sfnt header: u32 version, u16 numTables, u16 searchRange, # u16 entrySelector, u16 rangeShift if len(data) < 12: return None num_tables = struct.unpack(">H", data[4:6])[0] # Table directory: 16 bytes per entry - tag (4), checksum (4), # offset (4), length (4) name_offset = None name_length = None for i in range(num_tables): entry_start = 12 + i * 16 if entry_start + 16 > len(data): return None tag = data[entry_start : entry_start + 4] if tag == b"name": name_offset = struct.unpack( ">I", data[entry_start + 8 : entry_start + 12] )[0] name_length = struct.unpack( ">I", data[entry_start + 12 : entry_start + 16] )[0] break if name_offset is None or name_length is None: return None if name_offset + name_length > len(data): return None # name table header: u16 format, u16 count, u16 stringOffset if name_length < 6: return None fmt, count, string_offset = struct.unpack( ">HHH", data[name_offset : name_offset + 6] ) # Each name record is 12 bytes: # u16 platformID, u16 encodingID, u16 languageID, u16 nameID, # u16 length, u16 stringOffset (from start of storage area) record_size = 12 records_start = name_offset + 6 storage_start = name_offset + string_offset # Prefer Windows Unicode (3, 1), fall back to Macintosh Roman (1, 0) candidates: list[ tuple[int, int, int, str] ] = [] # (priority, plat, enc, decoded) for i in range(count): rec_start = records_start + i * record_size if rec_start + record_size > len(data): break platform_id, encoding_id, _lang_id, name_id, length, offset = struct.unpack( ">HHHHHH", data[rec_start : rec_start + record_size] ) if name_id != 6: # 6 = PostScript name continue # Priority: Windows Unicode = 0 (best), Mac Roman = 1, others = 2 if platform_id == 3 and encoding_id == 1: priority = 0 elif platform_id == 1 and encoding_id == 0: priority = 1 else: priority = 2 str_start = storage_start + offset str_end = str_start + length if str_end > len(data): continue raw = data[str_start:str_end] try: if platform_id == 3: # Windows: UTF-16BE decoded = raw.decode("utf-16-be") elif platform_id == 1: # Mac: MacRoman decoded = raw.decode("mac-roman") else: decoded = raw.decode("utf-8", errors="replace") candidates.append((priority, platform_id, encoding_id, decoded)) except (UnicodeDecodeError, LookupError): continue if not candidates: return None candidates.sort(key=lambda c: c[0]) return candidates[0][3] except (OSError, struct.error): return None # ======================================================================= # Profile generation # ======================================================================= def discover_fonts(inputs: list[Path]) -> list[Path]: """Recursively find .ttf and .otf files in the given inputs. Case-insensitive extension matching. Resolved paths used for dedup but user-visible paths preserved in the return (so error messages match what the user typed). """ found: list[Path] = [] for entry in inputs: if not entry.exists(): raise FontError(f"input not found: {entry}") if entry.is_file(): if entry.suffix.lower() in {".ttf", ".otf"}: found.append(entry) else: raise FontError( f"{entry}: unsupported extension (only .ttf and .otf are accepted)" ) elif entry.is_dir(): for f in entry.rglob("*"): if f.is_file() and f.suffix.lower() in {".ttf", ".otf"}: found.append(f) # Deduplicate by resolved path while preserving user-visible paths seen: set[Path] = set() unique: list[Path] = [] for f in found: resolved = f.resolve() if resolved not in seen: seen.add(resolved) unique.append(f) return unique def _slugify(text: str) -> str: """Lowercase, hyphenate, keep only [a-z0-9-]. For identifier derivation.""" s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-") return s or "bundle" def _build_description( annotated: list[tuple[Path, str | None]], max_listed: int = 40, ) -> str: """Build a description listing fonts Shows the PS name where available. Falls back to the filename stem. Truncates with a "+N more" tail if the list exceeds max_listed """ count = len(annotated) names: list[str] = [] for path, ps in annotated: names.append(ps if ps else path.stem) if count <= max_listed: listed = ", ".join(names) else: head = ", ".join(names[:max_listed]) listed = f"{head}, +{count - max_listed} more" return f"Installs {count} font(s): {listed}" def build_font_payload(font_path: Path, profile_uuid: str) -> dict: """Build a single com.apple.font payload dict for one font file""" payload_uuid = str(uuid.uuid4()).upper() return { "PayloadType": "com.apple.font", "PayloadVersion": 1, "PayloadIdentifier": f"{profile_uuid}.font.{payload_uuid}", "PayloadUUID": payload_uuid, "PayloadDisplayName": font_path.stem, "PayloadDescription": f"Installs {font_path.name}", "Name": font_path.name, "Font": font_path.read_bytes(), } def build_profile( annotated: list[tuple[Path, str | None]], name: str, identifier: str, organization: str, ) -> bytes: """Build a complete .mobileconfig (XML plist) as bytes""" fonts = [f for f, _ in annotated] profile_uuid = str(uuid.uuid4()).upper() payloads = [build_font_payload(f, profile_uuid) for f in fonts] description = _build_description(annotated) profile = { "PayloadType": "Configuration", "PayloadVersion": 1, "PayloadIdentifier": identifier, "PayloadUUID": profile_uuid, "PayloadDisplayName": name, "PayloadDescription": description, "PayloadOrganization": organization, "PayloadScope": "User", "PayloadRemovalDisallowed": False, "PayloadContent": payloads, } return plistlib.dumps(profile, fmt=plistlib.FMT_XML, sort_keys=False) def _file_sha256(path: Path) -> str: """Compute SHA-256 of a file's contents, chunked to handle large fonts.""" h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def derive_identifier(name: str, annotated: list[tuple[Path, str | None]]) -> str: """Derive a unique reverse-DNS identifier from the profile name + content. Hashes actual font bytes so that replacing a file with a different one that has the same PS name produces a new identifier (and thus coexists on the device rather than silently replacing). Sorted by PS name (or filename fallback) so identifier is independent of input ordering. """ slug = _slugify(name) parts = [ f"{ps or path.name}:{_file_sha256(path)}" for path, ps in sorted(annotated, key=lambda x: (x[1] or x[0].name).lower()) ] content_hash = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()[:8] return f"cx.lark.iglyph.{slug}.{content_hash}" # ======================================================================= # Validation pipeline (magic bytes + PS-name collision detection) # ======================================================================= def validate_and_report( fonts: list[Path], ) -> tuple[list[Path], list[tuple[Path, str | None]]]: """Validate all fonts, warn on PS-name collisions, return validated list. Returns (valid_fonts, [(path, ps_name), ...]). Raises FontError on hard validation failures (bad magic bytes, missing files). PS-name collisions are warned to stderr but not blocking; iOS silently drops duplicates. User can proceed if they want. """ if not fonts: raise FontError("no .ttf or .otf files found in the input") annotated: list[tuple[Path, str | None]] = [] for f in fonts: validate_font_magic(f) ps_name = extract_postscript_name(f) annotated.append((f, ps_name)) # Detect PS-name collisions and warn (non-fatal) by_ps: dict[str, list[Path]] = {} for path, ps in annotated: if ps is None: continue by_ps.setdefault(ps, []).append(path) collisions = {ps: paths for ps, paths in by_ps.items() if len(paths) > 1} if collisions: lines = [ "warning: PostScript-name collisions detected (iOS will silently keep one of each):" ] for ps, paths in collisions.items(): lines.append(f" {ps!r}:") for p in paths: lines.append(f" - {p}") lines.append( " proceeding anyway - remove duplicates if you want deterministic behavior" ) print("\n".join(lines), file=sys.stderr) return [f for f, _ in annotated], annotated # ======================================================================= # Network delivery # ======================================================================= def get_local_ips() -> list[str]: """Enumerate all non-loopback IPv4 addresses for this host.""" ips: list[str] = [] try: hostname = socket.gethostname() for info in socket.getaddrinfo(hostname, None, socket.AF_INET): ip = info[4][0] if not ip.startswith("127.") and ip not in ips: ips.append(ip) except socket.gaierror: pass # Fallback: connect to a public IP and read back our local address if not ips: try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.connect(("8.8.8.8", 80)) ip = s.getsockname()[0] if not ip.startswith("127."): ips.append(ip) except OSError: pass return ips or ["127.0.0.1"] class MobileConfigHandler(http.server.BaseHTTPRequestHandler): """Serves a single .mobileconfig file with the correct MIME type. Responds to any GET (or HEAD). Path doesn't matter. The Content- Disposition header carries the correct filename. User can hit just `http://192.168.1.47:18888/` from their phone without typing 'iGlyph.mobileconfig' on a touchscreen """ profile_bytes: bytes = b"" profile_name: str = "iGlyph.mobileconfig" on_download: "threading.Event | None" = None def _send_profile_headers(self) -> None: self.send_response(200) self.send_header("Content-Type", "application/x-apple-aspen-config") self.send_header("Content-Length", str(len(self.profile_bytes))) self.send_header( "Content-Disposition", f'attachment; filename="{self.profile_name}"', ) self.end_headers() def do_HEAD(self) -> None: # noqa: N802 self._send_profile_headers() def do_GET(self) -> None: # noqa: N802 self._send_profile_headers() self.wfile.write(self.profile_bytes) if self.on_download is not None: self.on_download.set() def log_message(self, fmt: str, *args: object) -> None: ts = time.strftime("%H:%M:%S") sys.stderr.write(f"[{ts}] {self.address_string()} {fmt % args}\n") class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): """ThreadingTCPServer with SO_REUSEADDR so re-runs after Ctrl-C don't hit 'address already in use' during the TIME_WAIT window.""" allow_reuse_address = True def serve_profile( profile_bytes: bytes, profile_name: str, port: int, timeout_minutes: int, ) -> None: """Spin up an HTTP server, print URLs, exit on download or timeout""" MobileConfigHandler.profile_bytes = profile_bytes MobileConfigHandler.profile_name = profile_name download_event = threading.Event() MobileConfigHandler.on_download = download_event # ThreadingTCPServer so the response actually completes before shutdown server = ReusableThreadingTCPServer(("0.0.0.0", port), MobileConfigHandler) server.daemon_threads = True actual_port = server.server_address[1] ips = get_local_ips() print(f"\nserving {profile_name} ({len(profile_bytes):,} bytes) on all interfaces") print("open one of these on your iPhone/iPad (Safari):") for ip in ips: print(f" http://{ip}:{actual_port}/") print( f"\nwaiting for download " f"(timeout: {timeout_minutes}m, exits on first download, Ctrl-C to cancel)" ) server_thread = threading.Thread(target=server.serve_forever, daemon=True) server_thread.start() try: triggered = download_event.wait(timeout=timeout_minutes * 60) if triggered: time.sleep(0.5) # let the response finish writing print("download served - exiting cleanly") else: print(f"\ntimeout reached ({timeout_minutes}m), no download - exiting") sys.exit(1) except KeyboardInterrupt: print("\ncancelled by user") sys.exit(130) finally: server.shutdown() server.server_close() # ======================================================================= # CLI # ======================================================================= def format_listing(annotated: list[tuple[Path, str | None]]) -> str: """Pretty-print the font listing for --list / --dry-run""" lines = [f"would bundle {len(annotated)} font(s):"] total_bytes = 0 for path, ps in annotated: size = path.stat().st_size total_bytes += size ps_display = ps if ps else "" lines.append(f" {path.name:40s} {size:>8,} B PS: {ps_display}") lines.append(f"total payload: {total_bytes:,} bytes") return "\n".join(lines) def _port_number(value: str) -> int: """argparse type: accept 0-65535, reject everything else.""" try: port = int(value) except ValueError: raise argparse.ArgumentTypeError(f"port must be an integer, got {value!r}") if not 0 <= port <= 65535: raise argparse.ArgumentTypeError(f"port must be 0-65535, got {port}") return port def _positive_int(value: str) -> int: """argparse type: accept positive integers only.""" try: n = int(value) except ValueError: raise argparse.ArgumentTypeError(f"must be a positive integer, got {value!r}") if n <= 0: raise argparse.ArgumentTypeError(f"must be > 0, got {n}") return n def main() -> int: p = argparse.ArgumentParser( prog="iglyph", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument( "inputs", nargs="+", type=Path, metavar="INPUT", help="font files or directories containing .ttf/.otf files", ) p.add_argument( "-o", "--output", type=Path, default=Path("iGlyph.mobileconfig"), help="output .mobileconfig path (default: iGlyph.mobileconfig)", ) p.add_argument( "-l", "--list", "-d", "--dry-run", dest="list_only", action="store_true", help="list what would be bundled without writing anything", ) p.add_argument( "--name", default="iGlyph Fonts", help="profile display name (default: 'iGlyph Fonts')", ) p.add_argument( "--identifier", default=None, help=( "reverse-DNS profile identifier (default: auto-derived from " "--name + content hash, so distinct font bundles coexist on device)" ), ) p.add_argument( "--organization", default="iGlyph", help="organization name shown on install (default: 'iGlyph')", ) p.add_argument( "--serve", nargs="?", const=18888, default=None, type=_port_number, metavar="PORT", help="serve the profile over HTTP (default port 18888)", ) p.add_argument( "--timeout", type=_positive_int, default=10, metavar="MINUTES", help="serve timeout in minutes (default: 10)", ) args = p.parse_args() # Discover and validate try: fonts = discover_fonts(args.inputs) valid_fonts, annotated = validate_and_report(fonts) except FontError as e: print(f"error: {e}", file=sys.stderr) return 1 # List mode exits here if args.list_only: print(format_listing(annotated)) return 0 # Build profile identifier = args.identifier or derive_identifier(args.name, annotated) blob = build_profile( annotated, name=args.name, identifier=identifier, organization=args.organization, ) # Write output, with cleaner errors than a raw OSError traceback try: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_bytes(blob) except OSError as e: print(f"error: could not write {args.output}: {e}", file=sys.stderr) return 1 print(f"wrote {args.output} ({len(blob):,} bytes, {len(valid_fonts)} font(s))") # Serve mode if args.serve is not None: serve_profile( profile_bytes=blob, profile_name=args.output.name, port=args.serve, timeout_minutes=args.timeout, ) return 0 # Otherwise, print install hints print("\nfastest path:") print(f" python3 iglyph.py {' '.join(str(i) for i in args.inputs)} --serve") print("\nmanual path:") print(f" 1. AirDrop {args.output} to the device") print(" 2. Open it -> a Settings prompt appears") print(" 3. Settings -> General -> VPN & Device Management -> install") print(" 4. fonts appear in Settings -> General -> Fonts and in font pickers") return 0 if __name__ == "__main__": sys.exit(main())