Files
picopal/tools/generate_fonts.py

116 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""Generate a separate XBM file for every supported PNG font atlas."""
import re
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
FONT_DIR = ROOT / "font"
def make_full_ascii_atlas(source: Image.Image, name: str) -> Image.Image:
"""Return a 16x16 atlas whose character slots match byte values 0..255."""
if name == "IBM_BIOS_8x8":
if source.size != (177, 67):
raise ValueError(f"{name}: expected 177x67, got {source.size}")
# Printable ASCII in 11x11 gridded cells with a centered 8x8 glyph.
atlas = Image.new("L", (128, 128), 255)
for glyph in range(96):
source_x = (glyph % 16) * 11 + 2
source_y = (glyph // 16) * 11 + 2
character = 32 + glyph
dest_x = (character % 16) * 8
dest_y = (character // 16) * 8
atlas.paste(source.crop((source_x, source_y, source_x + 8, source_y + 8)),
(dest_x, dest_y))
return atlas
if name == "IBM_VGA_8x16":
if source.size != (128, 90):
raise ValueError(f"{name}: expected 128x90, got {source.size}")
# The source contains printable ASCII 32..127 in 16 columns and six
# rows of 8x15 glyphs. Store them in 8x16 cells, padding the bottom.
atlas = Image.new("L", (128, 256), 255)
for glyph in range(96):
source_x = (glyph % 16) * 8
source_y = (glyph // 16) * 15
character = 32 + glyph
dest_x = (character % 16) * 8
dest_y = (character // 16) * 16
atlas.paste(source.crop((source_x, source_y, source_x + 8, source_y + 15)),
(dest_x, dest_y))
return atlas
if name == "ucs_6x13":
if source.size != (145, 97):
raise ValueError(f"{name}: expected 145x97, got {source.size}")
# Printable ASCII 32..127 is arranged in a 16x6 grid. Each 9x16
# source cell has a one-pixel grid line and one pixel of inner padding
# around its 6x13 glyph.
atlas = Image.new("L", (96, 208), 255)
for glyph in range(96):
source_x = (glyph % 16) * 9 + 2
source_y = (glyph // 16) * 16 + 2
character = 32 + glyph
dest_x = (character % 16) * 6
dest_y = (character // 16) * 13
atlas.paste(source.crop((source_x, source_y, source_x + 6, source_y + 13)),
(dest_x, dest_y))
return atlas
# The MBF images are already complete 16x16 character atlases.
if source.width % 16 or source.height % 16:
raise ValueError(f"{name}: atlas dimensions must be divisible by 16")
return source
def write_xbm(image: Image.Image, destination: Path) -> None:
image = image.convert("L")
row_bytes = (image.width + 7) // 8
data = []
for y in range(image.height):
for byte_x in range(row_bytes):
value = 0
for bit in range(8):
x = byte_x * 8 + bit
# Keep the existing renderer convention: white/background is
# one, while black/glyph pixels are zero. XBM is LSB-first.
if x >= image.width or image.getpixel((x, y)) >= 128:
value |= 1 << bit
data.append(value)
symbol = re.sub(r"[^a-zA-Z0-9_]", "_", destination.stem)
lines = [
f"#define font_width {image.width}",
f"#define font_height {image.height}",
f"static const unsigned char font_bits[] = {{",
]
for offset in range(0, len(data), 12):
values = ", ".join(f"0x{value:02X}" for value in data[offset:offset + 12])
suffix = "," if offset + 12 < len(data) else ""
lines.append(f" {values}{suffix}")
lines.extend(["};", ""])
destination.write_text("\n".join(lines), encoding="ascii")
print(f"generated {destination.relative_to(ROOT)} ({symbol})")
def main() -> None:
sources = sorted(FONT_DIR.glob("*.png"))
if not sources:
raise SystemExit("no PNG font atlases found")
for source_path in sources:
source = Image.open(source_path).convert("L")
atlas = make_full_ascii_atlas(source, source_path.stem)
write_xbm(atlas, source_path.with_suffix(".xbm"))
if __name__ == "__main__":
main()