From 0f94edc48d803d406b17802abbe5342e532be48c Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 28 Jan 2026 12:46:59 -0700 Subject: [PATCH] Make TTF fonts download-on-demand instead of bundled - Fonts downloaded from int10h.org on first use - Cached in platform-appropriate directory (~/.cache/mcdosbox-x/fonts) - Add fonts_download() MCP tool for explicit pre-download - Wheel size reduced from 473KB to 56KB (88% smaller) - 48 tools now registered --- pyproject.toml | 4 +- src/mcdosbox_x/fonts.py | 202 +++++++++++++++++++++++------- src/mcdosbox_x/server.py | 1 + src/mcdosbox_x/tools/__init__.py | 12 +- src/mcdosbox_x/tools/execution.py | 51 ++++++-- 5 files changed, 212 insertions(+), 58 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ab8f4ca..b9a5c17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,11 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/mcdosbox_x"] +exclude = ["src/mcdosbox_x/fonts/*.ttf", "src/mcdosbox_x/fonts/LICENSE.TXT"] [tool.hatch.build.targets.sdist] -include = ["src/mcdosbox_x", "src/mcdosbox_x/fonts"] +include = ["src/mcdosbox_x"] +exclude = ["src/mcdosbox_x/fonts/*.ttf"] [tool.ruff] line-length = 100 diff --git a/src/mcdosbox_x/fonts.py b/src/mcdosbox_x/fonts.py index a9e13a4..09f25b5 100644 --- a/src/mcdosbox_x/fonts.py +++ b/src/mcdosbox_x/fonts.py @@ -1,35 +1,147 @@ """Font utilities for DOSBox-X TTF output. -This module provides access to bundled IBM PC fonts from -The Ultimate Oldschool PC Font Pack by VileR (int10h.org). +Fonts are downloaded on-demand from The Ultimate Oldschool PC Font Pack +by VileR (int10h.org) and cached locally. Fonts are licensed under CC BY-SA 4.0. """ +import logging +import platform +import zipfile +from io import BytesIO from pathlib import Path +from urllib.request import urlopen -# Font directory location -FONTS_DIR = Path(__file__).parent / "fonts" +logger = logging.getLogger(__name__) -# Available bundled fonts (name -> filename mapping) -BUNDLED_FONTS = { +# Font pack download URL (official source) +FONT_PACK_URL = ( + "https://int10h.org/oldschool-pc-fonts/download/oldschool_pc_font_pack_v2.2_linux.zip" +) + +# Available fonts (name -> path within zip) +AVAILABLE_FONTS = { # Strict CP437 encoding - "Px437_IBM_VGA_8x16": "Px437_IBM_VGA_8x16.ttf", - "Px437_IBM_VGA_9x16": "Px437_IBM_VGA_9x16.ttf", - "Px437_IBM_EGA_8x14": "Px437_IBM_EGA_8x14.ttf", - "Px437_IBM_CGA": "Px437_IBM_CGA.ttf", - "Px437_IBM_MDA": "Px437_IBM_MDA.ttf", + "Px437_IBM_VGA_8x16": "Px437/Px437_IBM_VGA_8x16.ttf", + "Px437_IBM_VGA_9x16": "Px437/Px437_IBM_VGA_9x16.ttf", + "Px437_IBM_EGA_8x14": "Px437/Px437_IBM_EGA_8x14.ttf", + "Px437_IBM_CGA": "Px437/Px437_IBM_CGA.ttf", + "Px437_IBM_MDA": "Px437/Px437_IBM_MDA.ttf", # Extended Unicode (CP437 + additional characters) - "PxPlus_IBM_VGA_8x16": "PxPlus_IBM_VGA_8x16.ttf", - "PxPlus_IBM_VGA_9x16": "PxPlus_IBM_VGA_9x16.ttf", + "PxPlus_IBM_VGA_8x16": "PxPlus/PxPlus_IBM_VGA_8x16.ttf", + "PxPlus_IBM_VGA_9x16": "PxPlus/PxPlus_IBM_VGA_9x16.ttf", } # Default font for general use DEFAULT_FONT = "Px437_IBM_VGA_9x16" +def _get_cache_dir() -> Path: + """Get platform-appropriate cache directory for fonts.""" + system = platform.system() + + if system == "Linux": + # XDG Base Directory spec + xdg_cache = Path.home() / ".cache" + return xdg_cache / "mcdosbox-x" / "fonts" + elif system == "Darwin": + return Path.home() / "Library" / "Caches" / "mcdosbox-x" / "fonts" + elif system == "Windows": + local_app_data = Path.home() / "AppData" / "Local" + return local_app_data / "mcdosbox-x" / "fonts" + else: + # Fallback + return Path.home() / ".mcdosbox-x" / "fonts" + + +# Font cache directory +FONTS_DIR = _get_cache_dir() + +# Docker container path for mounted fonts +DOCKER_FONTS_PATH = "/fonts" + + +def fonts_installed() -> bool: + """Check if fonts have been downloaded.""" + if not FONTS_DIR.exists(): + return False + # Check if at least one font exists + return any((FONTS_DIR / f"{font_name}.ttf").exists() for font_name in AVAILABLE_FONTS) + + +def download_fonts(force: bool = False) -> dict: + """Download IBM PC fonts from int10h.org. + + Downloads the Ultimate Oldschool PC Font Pack and extracts + the subset of fonts used by mcdosbox-x. + + Args: + force: Re-download even if fonts exist + + Returns: + Status dict with downloaded fonts and cache location + """ + if fonts_installed() and not force: + return { + "success": True, + "message": "Fonts already installed", + "cache_dir": str(FONTS_DIR), + "fonts": list(AVAILABLE_FONTS.keys()), + } + + logger.info(f"Downloading fonts from {FONT_PACK_URL}") + + try: + # Download the font pack + with urlopen(FONT_PACK_URL, timeout=30) as response: + zip_data = BytesIO(response.read()) + + # Create cache directory + FONTS_DIR.mkdir(parents=True, exist_ok=True) + + # Extract selected fonts + extracted = [] + with zipfile.ZipFile(zip_data) as zf: + for font_name, zip_path in AVAILABLE_FONTS.items(): + # Find the file in the zip (may have prefix) + for name in zf.namelist(): + if name.endswith(zip_path): + # Extract to cache with simple name + dest = FONTS_DIR / f"{font_name}.ttf" + dest.write_bytes(zf.read(name)) + extracted.append(font_name) + logger.info(f"Extracted {font_name}") + break + + # Write license file + license_text = """The Ultimate Oldschool PC Font Pack +by VileR (https://int10h.org/oldschool-pc-fonts/) + +Licensed under Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) +https://creativecommons.org/licenses/by-sa/4.0/ +""" + (FONTS_DIR / "LICENSE.txt").write_text(license_text) + + return { + "success": True, + "message": f"Downloaded {len(extracted)} fonts", + "cache_dir": str(FONTS_DIR), + "fonts": extracted, + "license": "CC BY-SA 4.0", + "source": "https://int10h.org/oldschool-pc-fonts/", + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "hint": "Check network connection. Fonts can be manually placed in: " + str(FONTS_DIR), + } + + def get_font_path(font_name: str) -> Path | None: - """Get the full path to a bundled font. + """Get the full path to a cached font. Args: font_name: Font name without .ttf extension @@ -37,50 +149,42 @@ def get_font_path(font_name: str) -> Path | None: Returns: Path to font file, or None if not found - - Example: - >>> path = get_font_path("Px437_IBM_VGA_9x16") - >>> str(path) - '/path/to/mcdosbox_x/fonts/Px437_IBM_VGA_9x16.ttf' """ - filename = BUNDLED_FONTS.get(font_name) - if filename: - path = FONTS_DIR / filename - if path.exists(): - return path + if font_name not in AVAILABLE_FONTS: + return None + + path = FONTS_DIR / f"{font_name}.ttf" + if path.exists(): + return path return None def list_fonts() -> list[str]: - """List all available bundled font names. + """List all available font names. Returns: - List of font names that can be passed to get_font_path() - - Example: - >>> list_fonts() - ['Px437_IBM_CGA', 'Px437_IBM_EGA_8x14', ...] + List of font names that can be used with DOSBox-X TTF output """ - return sorted(BUNDLED_FONTS.keys()) + return sorted(AVAILABLE_FONTS.keys()) -def is_bundled_font(font_name: str) -> bool: - """Check if a font name refers to a bundled font. +def is_available_font(font_name: str) -> bool: + """Check if a font name is a known mcdosbox-x font. Args: font_name: Font name to check Returns: - True if font is bundled with dosbox-mcp + True if font is available (may need download) """ - return font_name in BUNDLED_FONTS + return font_name in AVAILABLE_FONTS def resolve_font(font_name: str | None, container_path: str | None = None) -> str | None: - """Resolve a font name to a full path if bundled, or return as-is. + """Resolve a font name to a full path, downloading if needed. This allows users to specify either: - - A bundled font name (e.g., "Px437_IBM_VGA_9x16") + - A known font name (e.g., "Px437_IBM_VGA_9x16") - auto-downloads if missing - A system font name (e.g., "Consolas") - A full path to a TTF file @@ -90,25 +194,29 @@ def resolve_font(font_name: str | None, container_path: str | None = None) -> st (e.g., "/fonts" for Docker) Returns: - Full path to bundled font, or original value if not bundled + Full path to font, or original value if not a known font """ if font_name is None: return None - # Check if it's a bundled font - filename = BUNDLED_FONTS.get(font_name) - if filename: + # Check if it's a known font + if font_name in AVAILABLE_FONTS: + # Auto-download if not installed + if not fonts_installed(): + logger.info("Fonts not installed, downloading...") + result = download_fonts() + if not result.get("success"): + logger.warning(f"Font download failed: {result.get('error')}") + return font_name # Fall back to system font + if container_path: # Return container-relative path - return f"{container_path}/{filename}" + return f"{container_path}/{font_name}.ttf" + # Return host path - path = FONTS_DIR / filename + path = FONTS_DIR / f"{font_name}.ttf" if path.exists(): return str(path) # Return as-is (system font or path) return font_name - - -# Docker container path for mounted fonts -DOCKER_FONTS_PATH = "/fonts" diff --git a/src/mcdosbox_x/server.py b/src/mcdosbox_x/server.py index 2601aef..1753b05 100644 --- a/src/mcdosbox_x/server.py +++ b/src/mcdosbox_x/server.py @@ -106,6 +106,7 @@ _TOOLS = [ tools.query_status, # Fonts tools.fonts_list, + tools.fonts_download, # Logging tools.logging_status, tools.logging_enable, diff --git a/src/mcdosbox_x/tools/__init__.py b/src/mcdosbox_x/tools/__init__.py index 26c2211..c80ef04 100644 --- a/src/mcdosbox_x/tools/__init__.py +++ b/src/mcdosbox_x/tools/__init__.py @@ -12,7 +12,16 @@ Tools are organized by function: from .breakpoints import breakpoint_delete, breakpoint_list, breakpoint_set from .control import loadstate, memdump, pause, query_status, reset, resume, savestate -from .execution import attach, continue_execution, fonts_list, launch, quit, step, step_over +from .execution import ( + attach, + continue_execution, + fonts_download, + fonts_list, + launch, + quit, + step, + step_over, +) from .inspection import ( disassemble, memory_read, @@ -56,6 +65,7 @@ __all__ = [ "step_over", "quit", "fonts_list", + "fonts_download", # Breakpoints "breakpoint_set", "breakpoint_list", diff --git a/src/mcdosbox_x/tools/execution.py b/src/mcdosbox_x/tools/execution.py index 9d8fb1e..1b54fce 100644 --- a/src/mcdosbox_x/tools/execution.py +++ b/src/mcdosbox_x/tools/execution.py @@ -3,7 +3,15 @@ import time from ..dosbox import DOSBoxConfig -from ..fonts import BUNDLED_FONTS, FONTS_DIR, list_fonts +from ..fonts import ( + FONTS_DIR, + fonts_installed, + get_font_path, + list_fonts, +) +from ..fonts import ( + download_fonts as _download_fonts, +) from ..gdb_client import GDBError from ..state import client, manager from ..utils import format_address @@ -290,36 +298,61 @@ def quit() -> dict: def fonts_list() -> dict: - """List available bundled TrueType fonts for DOSBox-X TTF output. + """List available TrueType fonts for DOSBox-X TTF output. These fonts from The Ultimate Oldschool PC Font Pack provide - authentic DOS-era rendering. Pass the font name to launch() - with output="ttf". + authentic DOS-era rendering. Fonts are downloaded on first use. + + Use fonts_download() to pre-download fonts, or they will be + auto-downloaded when you specify a font in launch(). Returns: - Dictionary with available fonts and usage hints + Dictionary with available fonts and installation status Example: fonts_list() + fonts_download() # Optional: pre-download launch(binary_path="/dos/RIPTERM.EXE", ttf_font="Px437_IBM_VGA_9x16") """ + installed = fonts_installed() fonts = [] for name in list_fonts(): info = _FONT_INFO.get(name, {}) - font_file = BUNDLED_FONTS.get(name) - available = (FONTS_DIR / font_file).exists() if font_file else False + path = get_font_path(name) fonts.append( { "name": name, "description": info.get("description", ""), "best_for": info.get("best_for", ""), - "available": available, + "installed": path is not None, } ) return { "fonts": fonts, + "fonts_installed": installed, + "cache_dir": str(FONTS_DIR), "recommended": "Px437_IBM_VGA_9x16", - "usage_hint": 'Pass font name to launch(ttf_font="Px437_IBM_VGA_9x16")', + "usage_hint": "Fonts auto-download on use. Use fonts_download() to pre-install.", "license": "CC BY-SA 4.0 - The Ultimate Oldschool PC Font Pack by VileR", + "source": "https://int10h.org/oldschool-pc-fonts/", } + + +def fonts_download(force: bool = False) -> dict: + """Download IBM PC TrueType fonts for DOSBox-X TTF output. + + Downloads fonts from The Ultimate Oldschool PC Font Pack (int10h.org) + and caches them locally. Fonts are also auto-downloaded when needed. + + Args: + force: Re-download even if fonts already exist + + Returns: + Status dict with download result and cache location + + Example: + fonts_download() # Download fonts + fonts_list() # See installed fonts + """ + return _download_fonts(force=force)