#!/usr/bin/env python3 """ Generate a bitmap font atlas from a TrueType font for use in the xorg viewer. Output: --out-header C header with pixel data (uint8_t array) and glyph metrics --out-png Optional PNG for visual inspection The atlas texture is grayscale (1 byte/pixel, GL_R8). Each glyph's alpha channel is stored directly — the renderer blends using this value. Usage: gen_font_atlas.py [--font PATH] [--size PT] [--out-header PATH] [--out-png PATH] """ import sys import os import argparse from PIL import Image, ImageFont, ImageDraw GLYPH_FIRST = 32 GLYPH_LAST = 255 FONT_SEARCH_PATHS = [ '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/dejavu/DejaVuSans.ttf', '/usr/share/fonts/TTF/DejaVuSans.ttf', '/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf', '/usr/local/share/fonts/truetype/dejavu/DejaVuSans.ttf', ] def find_font(): for p in FONT_SEARCH_PATHS: if os.path.exists(p): return p return None # --------------------------------------------------------------------------- # Simple per-column skyline bin packer # --------------------------------------------------------------------------- class Skyline: """ Skyline bin packer using a per-column height array. For each candidate x position, the placement y is max(sky[x:x+w]). O(atlas_width * n_glyphs) — fast enough for small atlases. """ def __init__(self, width, height): self.w = width self.h = height self.sky = [0] * width def place(self, gw, gh): """Find best-fit position for a glyph of size (gw, gh). Returns (x, y) or None.""" best_y = None best_x = None for x in range(self.w - gw + 1): y = max(self.sky[x:x + gw]) if y + gh > self.h: continue if best_y is None or y < best_y: best_y = y best_x = x if best_x is None: return None for i in range(best_x, best_x + gw): self.sky[i] = best_y + gh return (best_x, best_y) # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('--font', default=None, help='Path to TrueType font file (default: search for DejaVu Sans)') ap.add_argument('--size', type=int, default=16, help='Font size in points (default: 16)') ap.add_argument('--out-header', default='font_atlas.h', help='Output C header path (default: font_atlas.h)') ap.add_argument('--out-png', default=None, help='Optional output PNG for visual inspection') args = ap.parse_args() font_path = args.font or find_font() if not font_path: sys.exit('error: DejaVu Sans not found; pass --font PATH') print(f'font: {font_path}', file=sys.stderr) print(f'size: {args.size}pt', file=sys.stderr) font = ImageFont.truetype(font_path, args.size) # ------------------------------------------------------------------ # Collect glyph metrics and bitmaps # ------------------------------------------------------------------ glyphs = {} for cp in range(GLYPH_FIRST, GLYPH_LAST + 1): ch = chr(cp) # getbbox returns (left, top, right, bottom) in local glyph coords. # top is negative for glyphs that extend above the origin. bbox = font.getbbox(ch) adv = int(font.getlength(ch) + 0.5) if bbox is None: glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0, 'advance': adv, 'bearing_x': 0, 'bearing_y': 0} continue l, t, r, b = bbox gw = r - l gh = b - t if gw <= 0 or gh <= 0: # Non-printing (e.g. space) — store advance only glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0, 'advance': adv, 'bearing_x': int(l), 'bearing_y': int(-t)} continue img = Image.new('L', (gw, gh), 0) draw = ImageDraw.Draw(img) draw.text((-l, -t), ch, font=font, fill=255) glyphs[cp] = { 'bitmap': img, 'w': gw, 'h': gh, 'advance': adv, 'bearing_x': int(l), # horizontal distance from pen to left edge 'bearing_y': int(-t), # ascent: pixels above baseline (positive) } renderable = [cp for cp, g in glyphs.items() if g['bitmap'] is not None] print(f'glyphs: {len(renderable)} renderable, ' f'{len(glyphs) - len(renderable)} non-printing', file=sys.stderr) total_area = sum(glyphs[cp]['w'] * glyphs[cp]['h'] for cp in renderable) # ------------------------------------------------------------------ # Pack into atlas — try increasing sizes until everything fits # ------------------------------------------------------------------ # Sort by descending height for better skyline packing sorted_cps = sorted(renderable, key=lambda cp: (-glyphs[cp]['h'], -glyphs[cp]['w'])) PAD = 1 # 1-pixel gap between glyphs to avoid bilinear bleed placements = {} atlas_w = atlas_h = 0 for aw, ah in [(256, 256), (512, 256), (512, 512), (1024, 512)]: packer = Skyline(aw, ah) places = {} failed = False for cp in sorted_cps: g = glyphs[cp] pos = packer.place(g['w'] + PAD, g['h'] + PAD) if pos is None: failed = True break places[cp] = pos if not failed: placements = places atlas_w, atlas_h = aw, ah util = 100 * total_area // (aw * ah) print(f'atlas: {aw}x{ah} ({util}% utilisation)', file=sys.stderr) break else: sys.exit('error: could not fit all glyphs into any supported atlas size') # ------------------------------------------------------------------ # Render atlas image # ------------------------------------------------------------------ atlas = Image.new('L', (atlas_w, atlas_h), 0) for cp, (px, py) in placements.items(): atlas.paste(glyphs[cp]['bitmap'], (px, py)) if args.out_png: os.makedirs(os.path.dirname(os.path.abspath(args.out_png)), exist_ok=True) atlas.save(args.out_png) print(f'png: {args.out_png}', file=sys.stderr) # ------------------------------------------------------------------ # Write C header # ------------------------------------------------------------------ os.makedirs(os.path.dirname(os.path.abspath(args.out_header)), exist_ok=True) pixels = list(atlas.tobytes()) with open(args.out_header, 'w') as f: f.write('/* Auto-generated by gen_font_atlas.py — do not edit. */\n') f.write('#pragma once\n\n') f.write('#include \n\n') f.write(f'#define FONT_ATLAS_W {atlas_w}\n') f.write(f'#define FONT_ATLAS_H {atlas_h}\n\n') f.write('typedef struct {\n') f.write('\tint x, y; /* top-left in atlas */\n') f.write('\tint w, h; /* bitmap size in pixels */\n') f.write('\tint advance; /* horizontal pen advance */\n') f.write('\tint bearing_x; /* left-side bearing */\n') f.write('\tint bearing_y; /* ascent above baseline */\n') f.write('} Font_Glyph;\n\n') # Glyph table — indexed directly by codepoint 0..255 f.write('static const Font_Glyph font_glyphs[256] = {\n') for cp in range(256): if cp in glyphs: g = glyphs[cp] px, py = placements.get(cp, (0, 0)) f.write(f'\t[{cp}] = {{ {px}, {py}, {g["w"]}, {g["h"]}, ' f'{g["advance"]}, {g["bearing_x"]}, {g["bearing_y"]} }},\n') else: f.write(f'\t[{cp}] = {{ 0, 0, 0, 0, 0, 0, 0 }},\n') f.write('};\n\n') # Pixel data — grayscale, row-major f.write(f'static const uint8_t font_atlas_pixels[{atlas_w * atlas_h}] = {{\n') for i in range(0, len(pixels), 16): chunk = pixels[i:i + 16] f.write('\t' + ', '.join(str(b) for b in chunk) + ',\n') f.write('};\n') print(f'header: {args.out_header}', file=sys.stderr) if __name__ == '__main__': main()