at master 16 kB view raw
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)