- tools/gen_font_atlas: Python/Pillow build tool — skyline packs DejaVu Sans glyphs 32-255 into a grayscale atlas, emits build/gen/font_atlas.h with pixel data and Font_Glyph[256] metrics table - xorg: bitmap font atlas text overlay rendering (GL_R8 atlas texture, alpha-blended glyph quads, dark background rect per overlay) - xorg: add xorg_viewer_set_overlay_text / clear_overlays API - xorg: add xorg_viewer_handle_events for streaming use (events only, no redundant render) - xorg_cli: show today's date as white text overlay - v4l2_view_cli: new tool — V4L2 capture with format auto-selection (highest FPS then largest resolution), MJPEG/YUYV, measured FPS overlay - docs: update README, planning, architecture to reflect current status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
8.4 KiB
Python
230 lines
8.4 KiB
Python
#!/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 <stdint.h>\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()
|