-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbake_font.py
More file actions
286 lines (251 loc) · 10.2 KB
/
Copy pathbake_font.py
File metadata and controls
286 lines (251 loc) · 10.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
#!/usr/bin/env python3
"""Bake Maratype.otf into a bitmap font the R36S can draw without freetype.
The device has no PIL and no font stack: runner.py draws text as filled rects,
so every glyph is pre-reduced here to a list of horizontal spans
(dy, x0, run) - the same run-length trick widget.py's 5x7 font uses, which
keeps drawing at a handful of Screen.rect calls per glyph.
Usage: py widget/tools/bake_font.py [--preview]
Output: widget/device/art/maratype.fnt (+ art/fontcheck.png with --preview)
"""
import os
import struct
import sys
from PIL import Image, ImageDraw, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "Maratype.otf")
OUT = os.path.join(HERE, "..", "device", "art", "maratype.fnt")
# micro / body / label / readout / hero
SIZES = (10, 14, 20, 32, 56)
CHARS = "".join(chr(c) for c in range(32, 127))
MAGIC = b"MTF1"
THRESH = 110 # 8-bit coverage -> on/off. Low keeps thin strokes at 10px alive.
def spans_of(bitmap, w, h):
"""1-bit bitmap -> [(dy, x0, run), ...], plus the tight bbox."""
spans = []
minx, miny, maxx, maxy = w, h, -1, -1
px = bitmap.load()
for y in range(h):
x = 0
while x < w:
if px[x, y] >= THRESH:
x0 = x
while x < w and px[x, y] >= THRESH:
x += 1
spans.append((y, x0, x - x0))
minx, maxx = min(minx, x0), max(maxx, x - 1)
miny, maxy = min(miny, y), max(maxy, y)
else:
x += 1
if maxx < 0:
return [], (0, 0, 0, 0)
return spans, (minx, miny, maxx + 1, maxy + 1)
def sq_spans(x0, y0, s, ring):
"""Filled or hollow square as spans."""
out = []
if s <= 4 or not ring:
return [(y0 + i, x0, s) for i in range(s)]
t = max(1, s // 4)
for i in range(s):
if i < t or i >= s - t:
out.append((y0 + i, x0, s))
else:
out.append((y0 + i, x0, t))
out.append((y0 + i, x0 + s - t, t))
return out
def stroke_of(glyphs, h):
"""The font's stem weight.
Measured as the width of a vertical stem ('I', else 'L'), not the height of
the hyphen: at 14px the hyphen is 1px tall while the stems are 2px wide, so
the hyphen under-reports and the synthesized diagonals came out a stroke
lighter than the letters beside them."""
for ch in "IL":
g = glyphs.get(ch)
if g and g[3]:
runs = [r for _d, _x, r in g[3]]
if runs:
return max(1, min(runs))
hy = glyphs.get("-")
if hy and hy[3]:
return max(1, len(set(d for d, _x, _r in hy[3])))
return max(1, h // 7)
def synth_percent(glyphs, px):
"""Maratype ships no '%', which a usage readout cannot do without.
Drawn from scratch on the DIGIT box, not the font's slash: that slash runs
the full ascender, so reusing it made a percent taller than the numbers it
followed, with the counters flung to the extremes."""
ref = glyphs.get("0")
if not ref or not ref[3]:
return
_rx, ryoff, radv, rspans = ref
h = max(d for d, _x, _r in rspans) + 1
w = max(4, int(round(h * 0.66)))
t = stroke_of(glyphs, h)
s = max(2, int(round(h * 0.38)))
spans = []
for i in range(h): # diagonal, bottom-left to top-right
x = int(round((1.0 - i / float(h - 1)) * (w - t)))
spans.append((i, x, t))
spans += sq_spans(0, 0, s, s > 4)
spans += sq_spans(w - s, h - s, s, s > 4)
glyphs["%"] = (1, ryoff, w + max(2, radv // 5), sorted(spans))
def _pair(spans, i, a, b, t):
"""Two strokes on one row, merged where they touch."""
lo, hi = min(a, b), max(a, b)
if hi - lo <= t:
spans.append((i, lo, hi - lo + t))
else:
spans.append((i, lo, t))
spans.append((i, hi, t))
def synth_diagonals(glyphs, px):
"""Redraw X, V and K as true diagonals.
Maratype builds its diagonals as squared forms whose distinguishing
notches are a single pixel at UI sizes: X collapses to H, V to U. That is
the typeface's design rather than a baking fault, but it renders EXTRACT as
'EHTRACT', SALVAGE as 'SALUAGE' and BLACKOUT as 'BLACHOUT' - unacceptable
for the words this interface is built out of. Stroke weight, advance and
cap height are all taken from the font, so the replacements sit in the same
rhythm as their neighbours.
Maratype is caps-only (lowercase codepoints map to the same glyphs), so the
lowercase slots get copies."""
ref = glyphs.get("0")
if not ref or not ref[3]:
return
_rx, ryoff, _ra, rspans = ref
h = max(d for d, _x, _r in rspans) + 1
t = stroke_of(glyphs, h)
if h < 3:
return
for ch in "XVK":
old = glyphs.get(ch)
if not old or not old[3]:
continue
w = max(3, max(x + r for _d, x, r in old[3]))
# A diagonal needs room to travel. Full stem weight in a 5px-wide,
# 7px-tall box (10px face) just fills the box, so the stroke is capped
# by the space actually available.
st = max(1, min(t, (w - 1) // 2, max(1, h // 4)))
spans = []
if ch == "X":
for i in range(h):
p = i / float(h - 1)
_pair(spans, i, int(round(p * (w - st))),
int(round((1.0 - p) * (w - st))), st)
elif ch == "V":
mid = (w - st) / 2.0
for i in range(h):
p = i / float(h - 1)
_pair(spans, i, int(round(p * mid)),
int(round((w - st) - p * mid)), st)
else: # K: upright stem plus an arm that folds at the middle
arm = st if w >= 3.5 * st else max(1, st - 1)
reach = max(1, w - st - arm)
half = max(1, (h - 1) // 2)
for i in range(h):
spans.append((i, 0, st))
if i <= half:
p = i / float(half)
ax = int(round(st + (1.0 - p) * reach))
else:
p = (i - half) / float(max(1, h - 1 - half))
ax = int(round(st + p * reach))
if ax >= st:
spans.append((i, ax, arm))
glyphs[ch] = (old[0], ryoff, old[2], sorted(spans))
low = ch.lower()
if low in glyphs:
glyphs[low] = glyphs[ch]
def synth_chevron(glyphs, px, ch):
"""'<' and '>' are missing too, and the log ticker wants them."""
ref = glyphs.get("0")
if not ref or not ref[3]:
return
_rx, ryoff, _ra, rspans = ref
h = max(d for d, _x, _r in rspans) + 1
wd = max(3, h // 2)
t = max(1, h // 7)
half = max(1, h // 2)
spans = []
for i in range(h):
p = (i / half) if i < half else ((h - 1 - i) / half)
x = int(round(p * (wd - t)))
if ch == "<":
x = wd - t - x
spans.append((i, max(0, x), t))
glyphs[ch] = (1, ryoff, wd + max(2, px // 6), spans)
def bake_size(px):
font = ImageFont.truetype(SRC, px)
ascent, descent = font.getmetrics()
line_h = ascent + descent
pad = px # room for glyphs that overshoot the em box
glyphs = {}
for ch in CHARS:
adv = int(round(font.getlength(ch)))
if ch == " ":
glyphs[ch] = (0, 0, adv, [])
continue
img = Image.new("L", (px * 2 + pad * 2, line_h + pad * 2), 0)
ImageDraw.Draw(img).text((pad, pad), ch, 255, font=font)
spans, (x0, y0, x1, y1) = spans_of(img, img.width, img.height)
if not spans:
glyphs[ch] = (0, 0, adv, [])
continue
# re-origin: spans relative to the glyph's own top-left, with the
# offset from the text origin (pad, pad) carried separately
rel = [(dy - y0, sx - x0, run) for dy, sx, run in spans]
glyphs[ch] = (x0 - pad, y0 - pad, adv, rel)
synth_percent(glyphs, px)
synth_diagonals(glyphs, px)
for ch in "<>":
synth_chevron(glyphs, px, ch)
# forced-mono advance for tabular readouts: widest of digits + uppercase
mono = max(glyphs[c][2] for c in "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ")
return line_h, ascent, mono, glyphs
def pack():
out = bytearray(MAGIC)
out.append(len(SIZES))
for px in SIZES:
line_h, ascent, mono, glyphs = bake_size(px)
out += struct.pack("<BBBBBH", px, line_h, ascent, mono,
glyphs[" "][2], len(glyphs))
for ch in sorted(glyphs):
xoff, yoff, adv, spans = glyphs[ch]
out += struct.pack("<BbbBH", ord(ch), max(-128, min(127, xoff)),
max(-128, min(127, yoff)), adv, len(spans))
for dy, x0, run in spans:
out += struct.pack("<BBB", dy, x0, run)
print(" %2dpx line %2d ascent %2d mono %2d spans %5d"
% (px, line_h, ascent, mono,
sum(len(g[3]) for g in glyphs.values())))
return bytes(out)
def preview(path):
"""Re-render from the BAKED spans - this is what the device will show, not
what PIL would draw. Catches threshold/offset mistakes the source render
would happily hide."""
sys.path.insert(0, os.path.join(HERE, "..", "device"))
from runner import Font # noqa: E402 (device-side decoder, reused as-is)
f = Font(path)
line = "EXTRACT BLACKOUT 0123456789 100% <>[]-:/ x1.5"
hpx = sum(f.faces[s][0] + 10 for s in SIZES) + 40
img = Image.new("RGB", (1500, hpx), (6, 8, 6))
d = ImageDraw.Draw(img)
y = 8
for px in SIZES:
line_h = f.faces[px][0]
for cx, cy, w, h in f.spans(6, y, "%d " % px + line, px):
d.rectangle([cx, cy, cx + w - 1, cy + h - 1], (198, 255, 26))
y += line_h + 10
# tabular check: mono advance should column-align these
for i, s in enumerate(("100%", " 61%", " 7%")):
for cx, cy, w, h in f.spans(1100, 8 + i * 40, s, 32, mono=True):
d.rectangle([cx, cy, cx + w - 1, cy + h - 1], (60, 220, 235))
# build artifact, not shipped: art/ is copied to the device verbatim
img.save(os.path.join(HERE, "fontcheck.png"))
print("preview: widget/tools/fontcheck.png")
if __name__ == "__main__":
print("baking %s" % os.path.basename(SRC))
blob = pack()
with open(OUT, "wb") as fh:
fh.write(blob)
print("wrote %s (%d bytes)" % (os.path.normpath(OUT), len(blob)))
if "--preview" in sys.argv:
preview(OUT)