383 lines
13 KiB
Python
Executable File
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
|