firmware/misc/q1font/render.py
2024-04-29 09:25:53 -04:00

383 lines
13 KiB
Python
Executable File

#!/usr/bin/env python
#
# (c) Copyright 2023 by Coinkite Inc. This file is covered by license found in COPYING-CC.
#
# render.py - Build compressed data for font rendering on the Q1.
#
import os, sys, pdb, math, zlib
from PIL import Image, ImageDraw, ImageFont, ImageColor
from collections import Counter
from struct import pack, unpack
FONT = 'iosevka-extrabold.ttf'
FONT_SIZE = 18
# each character will be in a cell this size of pixels
# - yields final screen size, in chars of: 53x12
CELL_SIZE = (9, 22)
CELL_W, CELL_H = CELL_SIZE
DBL_CELL_SIZE = (CELL_W*2, CELL_H)
print(f'Each char: {CELL_W} x {CELL_H} pixels')
print(f'Screen: {320//CELL_W} x {240//CELL_H} chars')
# quantization for unique grey levels, system-wide
# - needs to be power of two, so divides bytes nicely
# - 4 not enough, 8 decent, 16 even better
NUM_GREYS = 16
# specially-named symbols=keys
KEY_NFC = '\x0e' # ctrl-N
KEY_QR = '\x11' # ctrl-Q
KEY_TAB = '\t' # tab = ctrl-I
KEY_F1 = '\x0f'
KEY_F2 = '\x12'
KEY_F3 = '\x13'
KEY_F4 = '\x14'
KEY_F5 = '\x16'
KEY_F6 = '\x17'
KEYS_FUNCTION = KEY_F1 + KEY_F2 + KEY_F3 + KEY_F4 + KEY_F5 + KEY_F6
KEYCAP_SYMBOLS = [ KEY_NFC, KEY_QR, KEY_TAB ] + list(KEYS_FUNCTION)
# we override these w/ our own art, but the chars involved are still these
# - dashed version for bottom/right sides of things
LINEDRAW_SYMBOLS = [ '', '', '', '', '', '', '','' ] # 'heavy' versions
CHARSET = [chr(x) for x in range(32,127)] \
+ ['', '', '', '', '',
'', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '', '', '', '',
'', '©', '', '',
'', '',
] + KEYCAP_SYMBOLS + LINEDRAW_SYMBOLS
# these are be better as double-wide chars
DBL_WIDTH = ['', '', '','', '', '',
'', '', '', '', '',
] + KEYCAP_SYMBOLS
NUM_CHARS = len(CHARSET)
# use a different glyph for these unicode values
# - useful for multi-codepoint sequences, which we want to encode as single char
REMAPS = {
KEY_NFC: 'NFC',
KEY_QR: 'QR',
#KEY_TAB: '➔',
#KEY_TAB: '➜',
#KEY_TAB: '↦',
KEY_TAB: '➡︎',
}
for n, fn in enumerate(KEYS_FUNCTION):
REMAPS[fn] = f'F{n+1}'
# find hidden zero-width junk
assert all(len(ch) == 1 for ch in CHARSET), [ch for ch in CHARSET if len(ch) > 1]
MEM_PER_CHAR = int(round((math.log2(NUM_GREYS) * CELL_W * CELL_H) / 8, 0))
print(f'Font has {NUM_CHARS} chars')
print(f"Per char, memory: {MEM_PER_CHAR} bytes")
print(f"Total font memory: {NUM_CHARS * MEM_PER_CHAR // 1024} KiBytes")
# NOTE: compressing data per-char only saves 50% and requires 1k of ram to decompress
# plus lots of overhead, so don't do that.
def remap_colour(c, amt):
# take colour (RGB tuple) and give it intensity (amt) and then return RGB565
amt /= 255.0
r = int(c[0] * amt * 0x1f)
g = int(c[1] * amt * 0x3f)
b = int(c[2] * amt * 0x1f)
return (r<<11) | (g << 5) | b
def device_endian(col):
# take a RGB565 value and swap endian for real device
# XXX unneeded?
return unpack('<H', pack('>H', col))[0]
def make_palette(shades, col, darken=1.0):
# make bytes representing a NUM_GREYS palette to map back to a RGB565 colour
assert len(col) == 3, 'want RGB'
assert col[0] > 20, 'expect 8-bit RGB values'
col = [i/255.0 for i in col]
assert max(col) <= 1.0
assert 0 <= min(col)
assert len(shades) == NUM_GREYS
vals = [remap_colour(col, s*darken) for s in shades]
txt = ', '.join('0x%04x'% i for i in vals)
return vals, txt, pack('>%dH' % NUM_GREYS, *vals)
def draw_linedrawing(ch, img, draw):
# Draw box-drawing chars
# PROBLEM: cell is odd width, so no way to pixel-align veritcals
# SOLUTION: leftwards on left of box, and rightwards on right side... two vertical lines
# - same for top/bottom lines; except in that case we wanted to move inwards
lw = 2 # line width
w, h = CELL_W, CELL_H
mw = (w // 2) - (lw//2)
mh1 = (h // 2) - lw + 3+4 # top
mh2 = (h // 2) - lw - 5 # bot
if ch == '':
print(f"{mw=} {mh1=} {mh2=} CELL={CELL_W}x{CELL_H}")
# erase old attempt from font
draw.rectangle( (0,0, w, h), fill=0)
# order of points matters (but it shouldn't)
# - cannot do subpixel here, must be integer
if ch == '': # for left side of box
draw.line( (mw-1, 0, mw-1, h), width=lw, fill=255)
elif ch == '': # for right side of box
draw.line( (mw+2, 0, mw+2, h), width=lw, fill=255)
elif ch == '': # top lines
draw.line( (0, mh1, w, mh1), width=lw, fill=255)
elif ch == '': # bottom lines
draw.line( (0, mh2, w, mh2), width=lw, fill=255)
elif ch == '':
draw.line( [(0, mh1), (mw+2, mh1), (mw+2, h)], width=lw, fill=255, joint='curve')
elif ch == '':
draw.line( [(mw, h), (mw, mh1), (w, mh1)], width=lw, fill=255, joint='curve')
elif ch == '':
draw.line( [(mw+2, 0), (mw+2, mh2+1), (0, mh2+1)], width=lw, fill=255, joint='curve')
elif ch == '':
draw.line( [(mw-1, 0), (mw-1, mh2), (w, mh2)], width=lw, fill=255, joint='curve')
else:
raise ValueError(ch)
def doit(out_fname='font_iosevka.py', cls_name='FontIosevka'):
font = ImageFont.truetype(FONT, FONT_SIZE)
keycap_font = ImageFont.truetype(FONT, FONT_SIZE-7) # see KEY_NFC
left, top, right, bottom = font.getbbox("|")
char_h = bottom - top
left, top, right, bottom = font.getbbox("M")
char_w = right - left
assert char_h <= CELL_H
assert char_w <= CELL_W
# want this one to fit in cell -- the worst descender?
left, top, right, bottom = font.getbbox("j")
y_offset = CELL_H - bottom - 1
NUM_COL = 24
samples = Image.new('L', (((CELL_W + 1) * NUM_COL) + 1,
((CELL_H+1) * (NUM_CHARS//NUM_COL + 4))), 255)
cells = Image.new('L', (CELL_W*NUM_CHARS*2, CELL_H), 0)
data = {}
pos = {}
out_x = 0
n = 0
for ch in CHARSET:
# render each one
is_dbl = (ch in DBL_WIDTH)
img = Image.new('L', CELL_SIZE if not is_dbl else DBL_CELL_SIZE)
draw = ImageDraw.Draw(img)
x_shift = 0
left, top, right, bottom = font.getbbox(ch)
if (right-left > CELL_W) and not is_dbl:
# char is too wide: some will be lost
if ch in '←↦':
# keep left edge of these
x_shift = -left
elif ch in '':
# keep right edge of these
x_shift = (CELL_W - (right-left))
else:
# center it
x_shift = (CELL_W - (right-left)) / 2.0
# Vertical tweaks
this_y = 0
if ch == '':
# this one up a little, so arrow is more mid-line-ish
# - looks awesome for random keyboard PIN entry mode
this_y = -4
if ch == '':
# bullet; needs perfect alignment inside full-cell box cursor
#x_shift += 4.6 # right side of perfect (FOR DOUBLE WIDE)
this_y = 1 # perfect
if ch == KEY_TAB:
# special code for tab keycap image
draw.text((x_shift, y_offset + 0), '➡︎', 'white', font)
draw.rectangle( ( 2,4, 2+2, CELL_H-7), 'white')
draw.rectangle( ( 1,4, 1+1, CELL_H-7), 'black')
#img.save('tab-key.png')
draw.rounded_rectangle( ( 0,0, (CELL_W*2)-1, CELL_H-1), 4, outline='white')
#img.save('tab-key-framed.png')
elif ch in KEYCAP_SYMBOLS:
if ch == KEY_NFC:
x_shift += 1
elif ch == KEY_QR:
x_shift += 3
else:
x_shift += 3
this_y += 5
draw.text((x_shift, y_offset + this_y), REMAPS.get(ch, ch), 'white', keycap_font)
# add a border
draw.rounded_rectangle( ( 0,0, (CELL_W*2)-1, CELL_H-1), 4, outline='white')
else:
draw.text((x_shift, y_offset + this_y), REMAPS.get(ch, ch), 'white', font)
if ch in LINEDRAW_SYMBOLS:
# replace line draw stuff w/ own art (altho normal works)
draw_linedrawing(ch, img, draw)
# check
actual = img.getcolors()
if ch not in '':
assert len(actual) >= 2, f'blank char? {ch}'
# build sample
if is_dbl and (n % NUM_COL) == NUM_COL-1:
n += 1
samples.paste(img, box=(
((n % NUM_COL) * (CELL_W+1)) + 1,
((n // NUM_COL) * (CELL_H+1)) +1))
n += (1 if not is_dbl else 2)
# track actual pixels we'll use
cells.paste(img, box=(out_x, 0))
assert ch not in pos, repr(ch)
pos[ch] = out_x
out_x += img.width
data[ch] = img
#if ch in 'iM_0': img.show()
#if ch in 'Aj': img.show()
x, y = 0, (samples.height-CELL_H)
for ch in 'Lazy dog jumpsX123':
samples.paste(data[ch], box=(x, y))
x += CELL_W
x, y = (samples.width-(CELL_W*4)), (samples.height-(CELL_H*3))
for n, ch in enumerate('┏━┓┃ ┇┗━┛'):
if n in { 3, 6}:
y += CELL_H
x -= CELL_W * 3
samples.paste(data[ch], box=(x, y))
x += CELL_W
# quantize the same, so they all share palette
# - resulting pallette is not obvious: kinda an exponential curve between white/black
#samples.show() # before quant
q_s = samples.quantize(colors=NUM_GREYS, method=Image.Quantize.MAXCOVERAGE).convert('L')
#q_s.show() # after quant
q_s.save('sample.png')
cells = cells.quantize(colors=NUM_GREYS, method=Image.Quantize.MAXCOVERAGE)
#cells.convert('L').show()
#colours = list(col for (cnt, col) in cells.getcolors())
#print(f'Shades: {colours}')
shades = cells.getpalette('RGB')
assert len(set(shades[3*NUM_GREYS:])) in {0, 1} # unused positions in 8-bit /256 value pal
shades = shades[0:3*NUM_GREYS]
assert shades[0::3] == shades[1::3] == shades[2::3], 'not all greyscale?'
# remap palette so it's in order by luma
by_luma = sorted([(n, gl) for n, gl in enumerate(shades[0::3])], key=lambda x:x[1])
mapping = list(n for n,gl in by_luma)
# apply new palette
cells = cells.remap_palette(mapping)
nsh = cells.getpalette('RGB')[0::3]
assert sorted(nsh) == nsh # error here means wrong/no virtual env
print(f'Shades: {nsh}')
shades = nsh
# remainder of file only tries to handle this case.
assert NUM_GREYS == 16
# slice into chars, packed and encoded by the palette
results = []
for n, ch in enumerate(CHARSET):
is_dbl = (ch in DBL_WIDTH)
x = pos[ch]
w = CELL_W*2 if is_dbl else CELL_W
here = cells.crop( (x, 0, x+w, CELL_H) ).tobytes()
assert len(here) == w * CELL_H
# pack into nibbles
assert all(px < 16 for px in here)
here = bytes((a<<4)|b for a,b in zip(here[0::2], here[1::2]))
assert len(here) in (MEM_PER_CHAR, 2*MEM_PER_CHAR)
if ch == ' ':
# space should be blank = all zeros
assert set(here) == {0}
results.append((ch, here))
BRAND_TEXT_COLOUR = (255, 176, 0) # amber phospher colour #ffb000
pal_nums, pal_vals, text_pal = make_palette(shades, BRAND_TEXT_COLOUR)
_, pal_vals_inv, text_pal_inv = make_palette([255-i for i in shades], BRAND_TEXT_COLOUR)
pal_dark_nums, _, text_pal_dark = make_palette(shades, BRAND_TEXT_COLOUR, 0.66)
# the "background" colour for the scroll bar
scroll_dark = remap_colour(BRAND_TEXT_COLOUR, 0.33)
with open(out_fname, 'w') as fp:
tmpl = open('template.py').read()
fp.write(tmpl)
fp.write(f'''
#FONT_SHADES = {shades}
TEXT_PALETTES = [
{text_pal}, #normal
{text_pal_inv}, # inverted
{text_pal_dark}, # darker
]
# same, but w/o byte swapping, packing (useful for simulator)
#TEXT_PALETTE = [{pal_vals}]
COL_TEXT = const(0x{pal_nums[15]:04x}) # text foreground colour
COL_DARK_TEXT = const(0x{pal_dark_nums[15]:04x}) # "dark" pallette text foreground colour
COL_SCROLL_DARK = const(0x{scroll_dark:04x}) # "dark" colour for scrollbar
CELL_W = const({CELL_W})
CELL_H = const({CELL_H})
BYTES_PER_CHAR = const({MEM_PER_CHAR})
#SPECIAL_CHARS = {[c for c in CHARSET if ord(c) >= 128]}
class {cls_name}:
DOUBLE_WIDE = {DBL_WIDTH}
@classmethod
def lookup(cls, cp):
# lookup glyph data for a single codepoint, or return None
px = cls._data.get(cp)
if not px: return None
return GlyphInfo(len(px) * 2 // {CELL_H}, {CELL_H}, px)
_data = ''' + '{\n')
for ch, raw in results:
fp.write(f' {ch!r}: {raw},\n')
fp.write(' }\n')
if __name__ == '__main__':
doit()
# EOF