You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

401 lines
15 KiB

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