keyboard stuff
1# Copyright 2021 Nick Brassel (@tzarc)
2# SPDX-License-Identifier: GPL-2.0-or-later
3
4# Quantum Font File "QFF" Font File Format.
5# See https://docs.qmk.fm/#/quantum_painter_qff for more information.
6
7from pathlib import Path
8from typing import Dict, Any
9from colorsys import rgb_to_hsv
10from PIL import Image, ImageDraw, ImageFont, ImageChops
11from PIL._binary import o8, o16le as o16, o32le as o32
12from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1
13from milc.attrdict import AttrDict
14import qmk.painter
15
16
17def o24(i):
18 return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
19
20
21########################################################################################################################
22
23
24class QFFGlyphInfo(AttrDict):
25 def __init__(self, *args, **kwargs):
26 super().__init__()
27
28 for n, value in enumerate(args):
29 self[f'arg:{n}'] = value
30
31 for key, value in kwargs.items():
32 self[key] = value
33
34 def write(self, fp, include_code_point):
35 if include_code_point is True:
36 fp.write(o24(ord(self.code_point)))
37
38 value = ((self.data_offset << 6) & 0xFFFFC0) | (self.w & 0x3F)
39 fp.write(o24(value))
40
41
42########################################################################################################################
43
44
45class QFFFontDescriptor:
46 type_id = 0x00
47 length = 20
48 magic = 0x464651
49
50 def __init__(self):
51 self.header = QGFBlockHeader()
52 self.header.type_id = QFFFontDescriptor.type_id
53 self.header.length = QFFFontDescriptor.length
54 self.version = 1
55 self.total_file_size = 0
56 self.line_height = 0
57 self.has_ascii_table = False
58 self.unicode_glyph_count = 0
59 self.format = 0xFF
60 self.flags = 0
61 self.compression = 0xFF
62 self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader
63
64 def write(self, fp):
65 self.header.write(fp)
66 fp.write(
67 b'' # start off with empty bytes...
68 + o24(QFFFontDescriptor.magic) # magic
69 + o8(self.version) # version
70 + o32(self.total_file_size) # file size
71 + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size
72 + o8(self.line_height) # line height
73 + o8(1 if self.has_ascii_table is True else 0) # whether or not we have an ascii table present
74 + o16(self.unicode_glyph_count & 0xFFFF) # number of unicode glyphs present
75 + o8(self.format) # format
76 + o8(self.flags) # flags
77 + o8(self.compression) # compression
78 + o8(self.transparency_index) # transparency index
79 )
80
81 @property
82 def is_transparent(self):
83 return (self.flags & 0x01) == 0x01
84
85 @is_transparent.setter
86 def is_transparent(self, val):
87 if val:
88 self.flags |= 0x01
89 else:
90 self.flags &= ~0x01
91
92
93########################################################################################################################
94
95
96class QFFAsciiGlyphTableV1:
97 type_id = 0x01
98 length = 95 * 3 # We have 95 glyphs: [0x20...0x7E]
99
100 def __init__(self):
101 self.header = QGFBlockHeader()
102 self.header.type_id = QFFAsciiGlyphTableV1.type_id
103 self.header.length = QFFAsciiGlyphTableV1.length
104
105 # Each glyph is key=code_point, value=QFFGlyphInfo
106 self.glyphs = {}
107
108 def add_glyph(self, glyph: QFFGlyphInfo):
109 self.glyphs[ord(glyph.code_point)] = glyph
110
111 def write(self, fp):
112 self.header.write(fp)
113
114 for n in range(0x20, 0x7F):
115 self.glyphs[n].write(fp, False)
116
117
118########################################################################################################################
119
120
121class QFFUnicodeGlyphTableV1:
122 type_id = 0x02
123
124 def __init__(self):
125 self.header = QGFBlockHeader()
126 self.header.type_id = QFFUnicodeGlyphTableV1.type_id
127 self.header.length = 0
128
129 # Each glyph is key=code_point, value=QFFGlyphInfo
130 self.glyphs = {}
131
132 def add_glyph(self, glyph: QFFGlyphInfo):
133 self.glyphs[ord(glyph.code_point)] = glyph
134
135 def write(self, fp):
136 self.header.length = len(self.glyphs.keys()) * 6
137 self.header.write(fp)
138
139 for n in sorted(self.glyphs.keys()):
140 self.glyphs[n].write(fp, True)
141
142
143########################################################################################################################
144
145
146class QFFFontDataDescriptorV1:
147 type_id = 0x04
148
149 def __init__(self):
150 self.header = QGFBlockHeader()
151 self.header.type_id = QFFFontDataDescriptorV1.type_id
152 self.data = []
153
154 def write(self, fp):
155 self.header.length = len(self.data)
156 self.header.write(fp)
157 fp.write(bytes(self.data))
158
159
160########################################################################################################################
161
162
163def _generate_font_glyphs_list(use_ascii, unicode_glyphs):
164 # The set of glyphs that we want to generate images for
165 glyphs = {}
166
167 # Add ascii charset if requested
168 if use_ascii is True:
169 for c in range(0x20, 0x7F): # does not include 0x7F!
170 glyphs[chr(c)] = True
171
172 # Append any extra unicode glyphs
173 unicode_glyphs = list(unicode_glyphs)
174 for c in unicode_glyphs:
175 glyphs[c] = True
176
177 return sorted(glyphs.keys())
178
179
180class QFFFont:
181 def __init__(self, logger):
182 self.logger = logger
183 self.image = None
184 self.glyph_data = {}
185 self.glyph_height = 0
186 return
187
188 def _extract_glyphs(self, format):
189 total_data_size = 0
190 total_rle_data_size = 0
191
192 converted_img = qmk.painter.convert_requested_format(self.image, format)
193 (self.palette, _) = qmk.painter.convert_image_bytes(converted_img, format)
194
195 # Work out how many bytes used for RLE vs. non-RLE
196 for _, glyph_entry in self.glyph_data.items():
197 glyph_img = converted_img.crop((glyph_entry.x, 1, glyph_entry.x + glyph_entry.w, 1 + self.glyph_height))
198 (_, this_glyph_image_bytes) = qmk.painter.convert_image_bytes(glyph_img, format)
199 this_glyph_rle_bytes = qmk.painter.compress_bytes_qmk_rle(this_glyph_image_bytes)
200 total_data_size += len(this_glyph_image_bytes)
201 total_rle_data_size += len(this_glyph_rle_bytes)
202 glyph_entry['image_uncompressed_bytes'] = this_glyph_image_bytes
203 glyph_entry['image_compressed_bytes'] = this_glyph_rle_bytes
204
205 return (total_data_size, total_rle_data_size)
206
207 def _parse_image(self, img, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
208 # Clear out any existing font metadata
209 self.image = None
210 # Each glyph is key=code_point, value={ x: ?, w: ? }
211 self.glyph_data = {}
212 self.glyph_height = 0
213
214 # Work out the list of glyphs required
215 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
216
217 # Work out the geometry
218 (width, height) = img.size
219
220 # Work out the glyph offsets/widths
221 glyph_pixel_offsets = []
222 glyph_pixel_widths = []
223 pixels = img.load()
224
225 # Run through the markers and work out where each glyph starts/stops
226 glyph_split_color = pixels[0, 0] # top left pixel is the marker color we're going to use to split each glyph
227 glyph_pixel_offsets.append(0)
228 last_offset = 0
229 for x in range(1, width):
230 if pixels[x, 0] == glyph_split_color:
231 glyph_pixel_offsets.append(x)
232 glyph_pixel_widths.append(x - last_offset)
233 last_offset = x
234 glyph_pixel_widths.append(width - last_offset)
235
236 # Make sure the number of glyphs we're attempting to generate matches the input image
237 if len(glyph_pixel_offsets) != len(glyphs):
238 self.logger.error('The number of glyphs to generate doesn\'t match the number of detected glyphs in the input image.')
239 return
240
241 # Set up the required metadata for each glyph
242 for n in range(0, len(glyph_pixel_offsets)):
243 self.glyph_data[glyphs[n]] = QFFGlyphInfo(code_point=glyphs[n], x=glyph_pixel_offsets[n], w=glyph_pixel_widths[n])
244
245 # Parsing was successful, keep the image in this instance
246 self.image = img
247 self.glyph_height = height - 1 # subtract the line with the markers
248
249 def generate_image(self, ttf_file: Path, font_size: int, include_ascii_glyphs: bool = True, unicode_glyphs: str = '', include_before_left: bool = False, use_aa: bool = True):
250 # Load the font
251 font = ImageFont.truetype(str(ttf_file), int(font_size))
252 # Work out the max font size
253 max_font_size = font.font.ascent + abs(font.font.descent)
254 # Work out the list of glyphs required
255 glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs)
256
257 baseline_offset = 9999999
258 total_glyph_width = 0
259 max_glyph_height = -1
260
261 # Measure each glyph to determine the overall baseline offset required
262 for glyph in glyphs:
263 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
264 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
265 glyph_height = font.getbbox(glyph, anchor='la')[3]
266 if max_glyph_height < glyph_height:
267 max_glyph_height = glyph_height
268 total_glyph_width += glyph_width
269 if baseline_offset > ls_t:
270 baseline_offset = ls_t
271
272 # Create the output image
273 img = Image.new("RGB", (total_glyph_width + 1, max_font_size * 2 + 1), (0, 0, 0, 255))
274 cur_x_pos = 0
275
276 # Loop through each glyph...
277 for glyph in glyphs:
278 # Work out this glyph's bounding box
279 (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls')
280 glyph_width = (ls_r - ls_l) if include_before_left else (ls_r)
281 glyph_height = ls_b - ls_t
282 x_offset = -ls_l
283 y_offset = ls_t - baseline_offset
284
285 # Draw each glyph to its own image so we don't get anti-aliasing applied to the final image when straddling edges
286 glyph_img = Image.new("RGB", (glyph_width, max_font_size), (0, 0, 0, 255))
287 glyph_draw = ImageDraw.Draw(glyph_img)
288 if not use_aa:
289 glyph_draw.fontmode = "1"
290 glyph_draw.text((x_offset, y_offset), glyph, font=font, anchor='lt')
291
292 # Place the glyph-specific image in the correct location overall
293 img.paste(glyph_img, (cur_x_pos, 1))
294
295 # Set up the marker for start of each glyph
296 pixels = img.load()
297 pixels[cur_x_pos, 0] = (255, 0, 255)
298
299 # Increment for the next glyph's position
300 cur_x_pos += glyph_width
301
302 # Add the ending marker so that the difference/crop works
303 pixels = img.load()
304 pixels[cur_x_pos, 0] = (255, 0, 255)
305
306 # Determine the usable font area
307 dummy_img = Image.new("RGB", (total_glyph_width + 1, max_font_size + 1), (0, 0, 0, 255))
308 bbox = ImageChops.difference(img, dummy_img).getbbox()
309 bbox = (bbox[0], bbox[1], bbox[2] - 1, bbox[3]) # remove the unused end-marker
310
311 # Crop and re-parse the resulting image to ensure we're generating the correct format
312 self._parse_image(img.crop(bbox), include_ascii_glyphs, unicode_glyphs)
313
314 def save_to_image(self, img_file: Path):
315 # Drop out if there's no image loaded
316 if self.image is None:
317 self.logger.error('No image is loaded.')
318 return
319
320 # Save the image to the supplied file
321 self.image.save(str(img_file))
322
323 def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
324 # Load and parse the supplied image file
325 self._parse_image(Image.open(str(img_file)), include_ascii_glyphs, unicode_glyphs)
326 return
327
328 def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp):
329 # Drop out if there's no image loaded
330 if self.image is None:
331 self.logger.error('No image is loaded.')
332 return
333
334 # Work out if we want to use RLE at all, skipping it if it's not any smaller (it's applied per-glyph)
335 (total_data_size, total_rle_data_size) = self._extract_glyphs(format)
336 if use_rle:
337 use_rle = (total_rle_data_size < total_data_size)
338
339 # For each glyph, work out which image data we want to use and append it to the image buffer, recording the byte-wise offset
340 img_buffer = bytes()
341 for _, glyph_entry in self.glyph_data.items():
342 glyph_entry['data_offset'] = len(img_buffer)
343 glyph_img_bytes = glyph_entry.image_compressed_bytes if use_rle else glyph_entry.image_uncompressed_bytes
344 img_buffer += bytes(glyph_img_bytes)
345
346 font_descriptor = QFFFontDescriptor()
347 ascii_table = QFFAsciiGlyphTableV1()
348 unicode_table = QFFUnicodeGlyphTableV1()
349 data_descriptor = QFFFontDataDescriptorV1()
350 data_descriptor.data = img_buffer
351
352 # Check if we have all the ASCII glyphs present
353 include_ascii_glyphs = all([chr(n) in self.glyph_data for n in range(0x20, 0x7F)])
354
355 # Helper for populating the blocks
356 for code_point, glyph_entry in self.glyph_data.items():
357 if ord(code_point) >= 0x20 and ord(code_point) <= 0x7E and include_ascii_glyphs:
358 ascii_table.add_glyph(glyph_entry)
359 else:
360 unicode_table.add_glyph(glyph_entry)
361
362 # Configure the font descriptor
363 font_descriptor.line_height = self.glyph_height
364 font_descriptor.has_ascii_table = include_ascii_glyphs
365 font_descriptor.unicode_glyph_count = len(unicode_table.glyphs.keys())
366 font_descriptor.is_transparent = False
367 font_descriptor.format = format['image_format_byte']
368 font_descriptor.compression = 0x01 if use_rle else 0x00
369
370 # Write a dummy font descriptor -- we'll have to come back and write it properly once we've rendered out everything else
371 font_descriptor_location = fp.tell()
372 font_descriptor.write(fp)
373
374 # Write out the ASCII table if required
375 if font_descriptor.has_ascii_table:
376 ascii_table.write(fp)
377
378 # Write out the unicode table if required
379 if font_descriptor.unicode_glyph_count > 0:
380 unicode_table.write(fp)
381
382 # Write out the palette if required
383 if format['has_palette']:
384 palette_descriptor = QGFFramePaletteDescriptorV1()
385
386 # Helper to convert from RGB888 to the QMK "dialect" of HSV888
387 def rgb888_to_qmk_hsv888(e):
388 hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0)
389 return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0))
390
391 # Convert all palette entries to HSV888 and write to the output
392 palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, self.palette))
393 palette_descriptor.write(fp)
394
395 # Write out the image data
396 data_descriptor.write(fp)
397
398 # Now fix up the overall font descriptor, then write it in the correct location
399 font_descriptor.total_file_size = fp.tell()
400 fp.seek(font_descriptor_location, 0)
401 font_descriptor.write(fp)