#!/usr/bin/env python3 """Parser and renderer for Borland Graphics Interface (BGI) Stroked Font (.CHR) files.""" from __future__ import annotations import argparse from dataclasses import dataclass from pathlib import Path import re import struct from typing import Sequence @dataclass(frozen=True) class StrokeCommand: op: str # "MOVE", "LINE", "SCAN", "END" x: int y: int @dataclass(frozen=True) class Glyph: code: int char_cp437: str offset: int width: int commands: list[StrokeCommand] @property def polylines(self) -> list[list[tuple[int, int]]]: """Convert MOVE/LINE commands into a list of connected polylines.""" lines: list[list[tuple[int, int]]] = [] current: list[tuple[int, int]] = [] for cmd in self.commands: if cmd.op == "MOVE": if len(current) > 1: lines.append(current) current = [(cmd.x, cmd.y)] elif cmd.op == "LINE": if not current: current = [(0, 0)] current.append((cmd.x, cmd.y)) elif cmd.op == "END": break if len(current) > 1: lines.append(current) return lines @dataclass(frozen=True) class BGIFont: magic: bytes description: str header_size: int font_name: str font_data_size: int font_version: tuple[int, int] bgi_version: tuple[int, int] header_flag: str num_chars: int first_char: int stroke_offset: int scan_flag: int org_to_cap: int org_to_base: int org_to_dec: int glyphs: dict[int, Glyph] def load_bytes(path: Path) -> bytes: """Load binary data from either a raw .CHR file or an xxd hex dump.""" raw = path.read_bytes() if raw.startswith(b"PK\x08\x08"): return raw text = raw.decode("utf-8", errors="replace") out = bytearray() for line in text.splitlines(): line = line.strip() if not line or ":" not in line: continue after_colon = line.split(":", 1)[1] # Standard 32-byte-per-line or 16-byte-per-line xxd dump: # Stop at two consecutive spaces (which separate hex from ASCII preview) hex_part = re.split(r" |\xa0 | \xa0", after_colon, maxsplit=1)[0] for token in hex_part.split(): if re.fullmatch(r"[0-9a-fA-F]+", token): out.extend(bytes.fromhex(token)) return bytes(out) def decode_signed_7bit(byte_val: int) -> int: """Decode lower 7 bits of a stroke byte as signed 7-bit two's complement.""" v = byte_val & 0x7F return v - 128 if (v & 0x40) else v def parse_bgi_font(data: bytes) -> BGIFont: """Parse a BGI Stroked Font (.CHR) binary buffer.""" if len(data) < 0x90 or data[:4] != b"PK\x08\x08": raise ValueError("Invalid BGI font magic (expected 'PK\\x08\\x08').") eof_idx = data.find(b"\x1a", 4) if eof_idx == -1: raise ValueError("Missing DOS EOF marker (0x1A) in header.") description = data[4:eof_idx].decode("ascii", errors="replace") # Binary file header immediately follows 0x1A header_size, raw_name, font_data_size, f_maj, f_min, b_maj, b_min = ( struct.unpack_from("> 7) << 1) | (b2 >> 7) x = decode_signed_7bit(b1) y = decode_signed_7bit(b2) if op_code == 0: cmds.append(StrokeCommand("END", x, y)) break elif op_code == 1: cmds.append(StrokeCommand("SCAN", x, y)) elif op_code == 2: cmds.append(StrokeCommand("MOVE", x, y)) else: cmds.append(StrokeCommand("LINE", x, y)) cp437_ch = bytes([code]).decode("cp437") if code < 256 else chr(code) glyphs[code] = Glyph( code=code, char_cp437=cp437_ch, offset=g_off, width=widths[i], commands=cmds, ) return BGIFont( magic=data[:4], description=description, header_size=header_size, font_name=font_name, font_data_size=font_data_size, font_version=(f_maj, f_min), bgi_version=(b_maj, b_min), header_flag=sig_byte.decode("ascii", errors="replace"), num_chars=num_chars, first_char=first_char, stroke_offset=stroke_offset, scan_flag=scan_flag, org_to_cap=org_to_cap, org_to_base=org_to_base, org_to_dec=org_to_dec, glyphs=glyphs, ) def _draw_line( canvas: set[tuple[int, int]], x0: int, y0: int, x1: int, y1: int ) -> None: """Bresenham's line algorithm on integer grid.""" dx = abs(x1 - x0) sx = 1 if x0 < x1 else -1 dy = -abs(y1 - y0) sy = 1 if y0 < y1 else -1 err = dx + dy while True: canvas.add((x0, y0)) if x0 == x1 and y0 == y1: break e2 = 2 * err if e2 >= dy: err += dy x0 += sx if e2 <= dx: err += dx y0 += sy def rasterize_text( font: BGIFont, text: str, scale: int = 2, spacing: int = 0 ) -> tuple[set[tuple[int, int]], int, int, int]: """Rasterize a string into pixel coordinates (x, y) where y increases upward.""" pixels: set[tuple[int, int]] = set() cursor_x = 0 for ch in text: # Map Unicode char to CP437 byte if possible, else ASCII code try: code = ch.encode("cp437")[0] except UnicodeEncodeError: code = ord(ch) glyph = font.glyphs.get(code) if glyph is None: cursor_x += 6 * scale continue for poly in glyph.polylines: for (x0, y0), (x1, y1) in zip(poly, poly[1:]): _draw_line( pixels, (cursor_x + x0) * scale, y0 * scale, (cursor_x + x1) * scale, y1 * scale, ) cursor_x += glyph.width + spacing min_y = font.org_to_dec * scale - 1 max_y = (font.org_to_cap + 2) * scale total_w = max(cursor_x * scale, 1) return pixels, total_w, min_y, max_y def render_braille(font: BGIFont, text: str, scale: int = 2) -> str: """Render text using Unicode Braille (2x4 subpixels per character cell).""" pixels, width, min_y, max_y = rasterize_text(font, text, scale=scale) height = max_y - min_y + 1 # Braille dot bit positions for (dx, dy) in 2x4 block: dot_map = { (0, 0): 0x01, (0, 1): 0x02, (0, 2): 0x04, (1, 0): 0x08, (1, 1): 0x10, (1, 2): 0x20, (0, 3): 0x40, (1, 3): 0x80, } rows: list[str] = [] for row_top in range(max_y, min_y - 1, -4): line_chars: list[str] = [] for col_left in range(0, width + 2, 2): mask = 0 for dx in (0, 1): for dy in (0, 1, 2, 3): px = col_left + dx py = row_top - dy if (px, py) in pixels: mask |= dot_map[(dx, dy)] line_chars.append(chr(0x2800 + mask)) rows.append("".join(line_chars).rstrip("\u2800")) # Strip completely empty leading/trailing rows while rows and not rows[0]: rows.pop(0) while rows and not rows[-1]: rows.pop() return "\n".join(rows) def render_ascii(font: BGIFont, text: str, scale: int = 1) -> str: """Render text on a 1:1 ASCII character grid.""" pixels, width, min_y, max_y = rasterize_text(font, text, scale=scale) rows: list[str] = [] for y in range(max_y, min_y - 1, -1): row = "".join("#" if (x, y) in pixels else ("·" if y == 0 else " ") for x in range(width + 1)) if "#" in row or (font.org_to_dec * scale <= y <= font.org_to_cap * scale): rows.append(f"{y:2d} | {row.rstrip()}") return "\n".join(rows) def main(argv: Sequence[str] | None = None) -> None: parser = argparse.ArgumentParser(description="Parse and preview BGI .CHR stroked font files.") parser.add_argument("input", type=Path, nargs="?", default=Path("litt.xxd")) parser.add_argument("--save-chr", type=Path, help="Save reconstructed binary .CHR file") parser.add_argument("--text", type=str, default="Hello, Borland 1989!", help="Preview text to render") args = parser.parse_args(argv) raw_bytes = load_bytes(args.input) if args.save_chr: args.save_chr.write_bytes(raw_bytes) print(f"Saved binary font ({len(raw_bytes)} bytes) to {args.save_chr}") font = parse_bgi_font(raw_bytes) print("=" * 72) print(f"BGI Stroked Font Summary ({args.input.name}, {len(raw_bytes)} bytes)") print("=" * 72) print(f" Magic Signature : {font.magic!r} ('PK' + 2x Backspace)") print(f" Banner Text : {font.description.replace(chr(13)+chr(10), ' | ').strip()}") print(f" Font Name : {font.font_name!r} (SMALL_FONT in )") print(f" Header Size : {font.header_size} bytes (0x{font.header_size:04x})") print(f" Font Data Size : {font.font_data_size} bytes (0x{font.font_data_size:04x}) -> Total {font.header_size + font.font_data_size} bytes") print(f" Versions : Font v{font.font_version[0]}.{font.font_version[1]}, BGI v{font.bgi_version[0]}.{font.bgi_version[1]}") print(f" Descriptor Sig : {font.header_flag!r} (0x{ord(font.header_flag):02x})") print(f" Character Range : {font.num_chars} chars, 0x{font.first_char:02x} ('{chr(font.first_char)}') .. 0x{font.first_char + font.num_chars - 1:02x} (CP437)") print(f" Stroke Offset : 0x{font.stroke_offset:04x} (file offset 0x{font.header_size + font.stroke_offset:04x})") print(f" Vertical Metrics: Cap=+{font.org_to_cap}, Base={font.org_to_base}, Descender={font.org_to_dec}") print("=" * 72) samples = [ args.text, "ABCDEFGHIJKLM", "NOPQRSTUVWXYZ", "abcdefghijklm", "nopqrstuvwxyz", "0123456789 !#$%&()*+,-./:;<=>?@", "Çüéâäàåçêëèïî αßΓπΣσµτΦΘΩδ∞±÷√²", ] for sample in samples: print(f"\nPreview: {sample}") print(render_braille(font, sample, scale=2)) print("\n1:1 Grid Inspector for 'BGI 1.1' (showing baseline at y=0):") print(render_ascii(font, "BGI 1.1", scale=1)) if __name__ == "__main__": main()