# Copyright 2021 Nick Brassel (@tzarc) # SPDX-License-Identifier: GPL-2.0-or-later # Quantum Font File "QFF" Font File Format. # See https://docs.qmk.fm/#/quantum_painter_qff for more information. from pathlib import Path from typing import Dict, Any from colorsys import rgb_to_hsv from PIL import Image, ImageDraw, ImageFont, ImageChops from PIL._binary import o8, o16le as o16, o32le as o32 from qmk.painter_qgf import QGFBlockHeader, QGFFramePaletteDescriptorV1 from milc.attrdict import AttrDict import qmk.painter def o24(i): return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) ######################################################################################################################## class QFFGlyphInfo(AttrDict): def __init__(self, *args, **kwargs): super().__init__() for n, value in enumerate(args): self[f'arg:{n}'] = value for key, value in kwargs.items(): self[key] = value def write(self, fp, include_code_point): if include_code_point is True: fp.write(o24(ord(self.code_point))) value = ((self.data_offset << 6) & 0xFFFFC0) | (self.w & 0x3F) fp.write(o24(value)) ######################################################################################################################## class QFFFontDescriptor: type_id = 0x00 length = 20 magic = 0x464651 def __init__(self): self.header = QGFBlockHeader() self.header.type_id = QFFFontDescriptor.type_id self.header.length = QFFFontDescriptor.length self.version = 1 self.total_file_size = 0 self.line_height = 0 self.has_ascii_table = False self.unicode_glyph_count = 0 self.format = 0xFF self.flags = 0 self.compression = 0xFF self.transparency_index = 0xFF # TODO: Work out how to retrieve the transparent palette entry from the PIL gif loader def write(self, fp): self.header.write(fp) fp.write( b'' # start off with empty bytes... + o24(QFFFontDescriptor.magic) # magic + o8(self.version) # version + o32(self.total_file_size) # file size + o32((~self.total_file_size) & 0xFFFFFFFF) # negated file size + o8(self.line_height) # line height + o8(1 if self.has_ascii_table is True else 0) # whether or not we have an ascii table present + o16(self.unicode_glyph_count & 0xFFFF) # number of unicode glyphs present + o8(self.format) # format + o8(self.flags) # flags + o8(self.compression) # compression + o8(self.transparency_index) # transparency index ) @property def is_transparent(self): return (self.flags & 0x01) == 0x01 @is_transparent.setter def is_transparent(self, val): if val: self.flags |= 0x01 else: self.flags &= ~0x01 ######################################################################################################################## class QFFAsciiGlyphTableV1: type_id = 0x01 length = 95 * 3 # We have 95 glyphs: [0x20...0x7E] def __init__(self): self.header = QGFBlockHeader() self.header.type_id = QFFAsciiGlyphTableV1.type_id self.header.length = QFFAsciiGlyphTableV1.length # Each glyph is key=code_point, value=QFFGlyphInfo self.glyphs = {} def add_glyph(self, glyph: QFFGlyphInfo): self.glyphs[ord(glyph.code_point)] = glyph def write(self, fp): self.header.write(fp) for n in range(0x20, 0x7F): self.glyphs[n].write(fp, False) ######################################################################################################################## class QFFUnicodeGlyphTableV1: type_id = 0x02 def __init__(self): self.header = QGFBlockHeader() self.header.type_id = QFFUnicodeGlyphTableV1.type_id self.header.length = 0 # Each glyph is key=code_point, value=QFFGlyphInfo self.glyphs = {} def add_glyph(self, glyph: QFFGlyphInfo): self.glyphs[ord(glyph.code_point)] = glyph def write(self, fp): self.header.length = len(self.glyphs.keys()) * 6 self.header.write(fp) for n in sorted(self.glyphs.keys()): self.glyphs[n].write(fp, True) ######################################################################################################################## class QFFFontDataDescriptorV1: type_id = 0x04 def __init__(self): self.header = QGFBlockHeader() self.header.type_id = QFFFontDataDescriptorV1.type_id self.data = [] def write(self, fp): self.header.length = len(self.data) self.header.write(fp) fp.write(bytes(self.data)) ######################################################################################################################## def _generate_font_glyphs_list(use_ascii, unicode_glyphs): # The set of glyphs that we want to generate images for glyphs = {} # Add ascii charset if requested if use_ascii is True: for c in range(0x20, 0x7F): # does not include 0x7F! glyphs[chr(c)] = True # Append any extra unicode glyphs unicode_glyphs = list(unicode_glyphs) for c in unicode_glyphs: glyphs[c] = True return sorted(glyphs.keys()) class QFFFont: def __init__(self, logger): self.logger = logger self.image = None self.glyph_data = {} self.glyph_height = 0 return def _extract_glyphs(self, format): total_data_size = 0 total_rle_data_size = 0 converted_img = qmk.painter.convert_requested_format(self.image, format) (self.palette, _) = qmk.painter.convert_image_bytes(converted_img, format) # Work out how many bytes used for RLE vs. non-RLE for _, glyph_entry in self.glyph_data.items(): glyph_img = converted_img.crop((glyph_entry.x, 1, glyph_entry.x + glyph_entry.w, 1 + self.glyph_height)) (_, this_glyph_image_bytes) = qmk.painter.convert_image_bytes(glyph_img, format) this_glyph_rle_bytes = qmk.painter.compress_bytes_qmk_rle(this_glyph_image_bytes) total_data_size += len(this_glyph_image_bytes) total_rle_data_size += len(this_glyph_rle_bytes) glyph_entry['image_uncompressed_bytes'] = this_glyph_image_bytes glyph_entry['image_compressed_bytes'] = this_glyph_rle_bytes return (total_data_size, total_rle_data_size) def _parse_image(self, img, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''): # Clear out any existing font metadata self.image = None # Each glyph is key=code_point, value={ x: ?, w: ? } self.glyph_data = {} self.glyph_height = 0 # Work out the list of glyphs required glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs) # Work out the geometry (width, height) = img.size # Work out the glyph offsets/widths glyph_pixel_offsets = [] glyph_pixel_widths = [] pixels = img.load() # Run through the markers and work out where each glyph starts/stops glyph_split_color = pixels[0, 0] # top left pixel is the marker color we're going to use to split each glyph glyph_pixel_offsets.append(0) last_offset = 0 for x in range(1, width): if pixels[x, 0] == glyph_split_color: glyph_pixel_offsets.append(x) glyph_pixel_widths.append(x - last_offset) last_offset = x glyph_pixel_widths.append(width - last_offset) # Make sure the number of glyphs we're attempting to generate matches the input image if len(glyph_pixel_offsets) != len(glyphs): self.logger.error('The number of glyphs to generate doesn\'t match the number of detected glyphs in the input image.') return # Set up the required metadata for each glyph for n in range(0, len(glyph_pixel_offsets)): self.glyph_data[glyphs[n]] = QFFGlyphInfo(code_point=glyphs[n], x=glyph_pixel_offsets[n], w=glyph_pixel_widths[n]) # Parsing was successful, keep the image in this instance self.image = img self.glyph_height = height - 1 # subtract the line with the markers 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): # Load the font font = ImageFont.truetype(str(ttf_file), int(font_size)) # Work out the max font size max_font_size = font.font.ascent + abs(font.font.descent) # Work out the list of glyphs required glyphs = _generate_font_glyphs_list(include_ascii_glyphs, unicode_glyphs) baseline_offset = 9999999 total_glyph_width = 0 max_glyph_height = -1 # Measure each glyph to determine the overall baseline offset required for glyph in glyphs: (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls') glyph_width = (ls_r - ls_l) if include_before_left else (ls_r) glyph_height = font.getbbox(glyph, anchor='la')[3] if max_glyph_height < glyph_height: max_glyph_height = glyph_height total_glyph_width += glyph_width if baseline_offset > ls_t: baseline_offset = ls_t # Create the output image img = Image.new("RGB", (total_glyph_width + 1, max_font_size * 2 + 1), (0, 0, 0, 255)) cur_x_pos = 0 # Loop through each glyph... for glyph in glyphs: # Work out this glyph's bounding box (ls_l, ls_t, ls_r, ls_b) = font.getbbox(glyph, anchor='ls') glyph_width = (ls_r - ls_l) if include_before_left else (ls_r) glyph_height = ls_b - ls_t x_offset = -ls_l y_offset = ls_t - baseline_offset # Draw each glyph to its own image so we don't get anti-aliasing applied to the final image when straddling edges glyph_img = Image.new("RGB", (glyph_width, max_font_size), (0, 0, 0, 255)) glyph_draw = ImageDraw.Draw(glyph_img) if not use_aa: glyph_draw.fontmode = "1" glyph_draw.text((x_offset, y_offset), glyph, font=font, anchor='lt') # Place the glyph-specific image in the correct location overall img.paste(glyph_img, (cur_x_pos, 1)) # Set up the marker for start of each glyph pixels = img.load() pixels[cur_x_pos, 0] = (255, 0, 255) # Increment for the next glyph's position cur_x_pos += glyph_width # Add the ending marker so that the difference/crop works pixels = img.load() pixels[cur_x_pos, 0] = (255, 0, 255) # Determine the usable font area dummy_img = Image.new("RGB", (total_glyph_width + 1, max_font_size + 1), (0, 0, 0, 255)) bbox = ImageChops.difference(img, dummy_img).getbbox() bbox = (bbox[0], bbox[1], bbox[2] - 1, bbox[3]) # remove the unused end-marker # Crop and re-parse the resulting image to ensure we're generating the correct format self._parse_image(img.crop(bbox), include_ascii_glyphs, unicode_glyphs) def save_to_image(self, img_file: Path): # Drop out if there's no image loaded if self.image is None: self.logger.error('No image is loaded.') return # Save the image to the supplied file self.image.save(str(img_file)) def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''): # Load and parse the supplied image file self._parse_image(Image.open(str(img_file)), include_ascii_glyphs, unicode_glyphs) return def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp): # Drop out if there's no image loaded if self.image is None: self.logger.error('No image is loaded.') return # Work out if we want to use RLE at all, skipping it if it's not any smaller (it's applied per-glyph) (total_data_size, total_rle_data_size) = self._extract_glyphs(format) if use_rle: use_rle = (total_rle_data_size < total_data_size) # For each glyph, work out which image data we want to use and append it to the image buffer, recording the byte-wise offset img_buffer = bytes() for _, glyph_entry in self.glyph_data.items(): glyph_entry['data_offset'] = len(img_buffer) glyph_img_bytes = glyph_entry.image_compressed_bytes if use_rle else glyph_entry.image_uncompressed_bytes img_buffer += bytes(glyph_img_bytes) font_descriptor = QFFFontDescriptor() ascii_table = QFFAsciiGlyphTableV1() unicode_table = QFFUnicodeGlyphTableV1() data_descriptor = QFFFontDataDescriptorV1() data_descriptor.data = img_buffer # Check if we have all the ASCII glyphs present include_ascii_glyphs = all([chr(n) in self.glyph_data for n in range(0x20, 0x7F)]) # Helper for populating the blocks for code_point, glyph_entry in self.glyph_data.items(): if ord(code_point) >= 0x20 and ord(code_point) <= 0x7E and include_ascii_glyphs: ascii_table.add_glyph(glyph_entry) else: unicode_table.add_glyph(glyph_entry) # Configure the font descriptor font_descriptor.line_height = self.glyph_height font_descriptor.has_ascii_table = include_ascii_glyphs font_descriptor.unicode_glyph_count = len(unicode_table.glyphs.keys()) font_descriptor.is_transparent = False font_descriptor.format = format['image_format_byte'] font_descriptor.compression = 0x01 if use_rle else 0x00 # Write a dummy font descriptor -- we'll have to come back and write it properly once we've rendered out everything else font_descriptor_location = fp.tell() font_descriptor.write(fp) # Write out the ASCII table if required if font_descriptor.has_ascii_table: ascii_table.write(fp) # Write out the unicode table if required if font_descriptor.unicode_glyph_count > 0: unicode_table.write(fp) # Write out the palette if required if format['has_palette']: palette_descriptor = QGFFramePaletteDescriptorV1() # Helper to convert from RGB888 to the QMK "dialect" of HSV888 def rgb888_to_qmk_hsv888(e): hsv = rgb_to_hsv(e[0] / 255.0, e[1] / 255.0, e[2] / 255.0) return (int(hsv[0] * 255.0), int(hsv[1] * 255.0), int(hsv[2] * 255.0)) # Convert all palette entries to HSV888 and write to the output palette_descriptor.palette_entries = list(map(rgb888_to_qmk_hsv888, self.palette)) palette_descriptor.write(fp) # Write out the image data data_descriptor.write(fp) # Now fix up the overall font descriptor, then write it in the correct location font_descriptor.total_file_size = fp.tell() fp.seek(font_descriptor_location, 0) font_descriptor.write(fp)