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
This commit is contained in:
Ryan Malloy 2026-01-28 12:46:59 -07:00
parent 51b0863ba4
commit 0f94edc48d
5 changed files with 212 additions and 58 deletions

View File

@ -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

View File

@ -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"

View File

@ -106,6 +106,7 @@ _TOOLS = [
tools.query_status,
# Fonts
tools.fonts_list,
tools.fonts_download,
# Logging
tools.logging_status,
tools.logging_enable,

View File

@ -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",

View File

@ -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)