This commit is contained in:
elpekenin 2023-06-03 14:09:59 +02:00
parent ef788c68f4
commit 0e4619ed30
4 changed files with 237 additions and 117 deletions

View File

@ -1,10 +1,8 @@
"""This script tests QGF functionality. """This script tests QGF functionality.
""" """
import re
import datetime
from io import BytesIO from io import BytesIO
from qmk.path import normpath from qmk.path import normpath
from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats from qmk.painter import generate_subs, render_header, render_source, valid_formats
from milc import cli from milc import cli
from PIL import Image from PIL import Image
@ -51,7 +49,8 @@ def painter_convert_graphics(cli):
# Convert the image to QGF using PIL # Convert the image to QGF using PIL
out_data = BytesIO() out_data = BytesIO()
input_img.save(out_data, "QGF", use_deltas=(not cli.args.no_deltas), use_rle=(not cli.args.no_rle), qmk_format=format, verbose=cli.args.verbose) metadata = []
input_img.save(out_data, "QGF", use_deltas=(not cli.args.no_deltas), use_rle=(not cli.args.no_rle), qmk_format=format, verbose=cli.args.verbose, metadata=metadata)
out_bytes = out_data.getvalue() out_bytes = out_data.getvalue()
if cli.args.raw: if cli.args.raw:
@ -61,20 +60,7 @@ def painter_convert_graphics(cli):
return return
# Work out the text substitutions for rendering the output data # Work out the text substitutions for rendering the output data
subs = { subs = generate_subs(cli, out_bytes, image_metadata=metadata)
'generated_type': 'image',
'var_prefix': 'gfx',
'generator_command': f'qmk painter-convert-graphics -i {cli.args.input.name} -f {cli.args.format}',
'year': datetime.date.today().strftime("%Y"),
'input_file': cli.args.input.name,
'sane_name': re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
'byte_count': len(out_bytes),
'bytes_lines': render_bytes(out_bytes),
'format': cli.args.format,
}
# Render the license
subs.update({'license': render_license(subs)})
# Render and write the header file # Render and write the header file
header_text = render_header(subs) header_text = render_header(subs)
@ -82,7 +68,6 @@ def painter_convert_graphics(cli):
with open(header_file, 'w') as header: with open(header_file, 'w') as header:
print(f"Writing {header_file}...") print(f"Writing {header_file}...")
header.write(header_text) header.write(header_text)
header.close()
# Render and write the source file # Render and write the source file
source_text = render_source(subs) source_text = render_source(subs)
@ -90,4 +75,3 @@ def painter_convert_graphics(cli):
with open(source_file, 'w') as source: with open(source_file, 'w') as source:
print(f"Writing {source_file}...") print(f"Writing {source_file}...")
source.write(source_text) source.write(source_text)
source.close()

View File

@ -1,16 +1,88 @@
"""This script automates the conversion of font files into a format QMK firmware understands. """This script automates the conversion of font files into a format QMK firmware understands.
""" """
import re
import datetime
from io import BytesIO from io import BytesIO
from qmk.path import normpath from qmk.path import normpath
from qmk.painter_qff import QFFFont from qmk.painter_qff import _generate_font_glyphs_list, QFFFont
from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats from qmk.painter import generate_subs, render_header, render_source, valid_formats
from milc import cli from milc import cli
@cli.argument('-f', '--font', required=True, help='Specify input font file.') def _font_to_image(cli, output):
"""
This function converts a font, eg .ttf, into an image, eg .png
Uses cli to work out how to make the conversion, and writes to the provided file
"""
# Create the font object
font = QFFFont(cli)
# Read from the input file
font.generate_image(
normpath(cli.args.input),
cli.args.size,
include_ascii_glyphs=not cli.args.no_ascii,
unicode_glyphs=cli.args.unicode_glyphs,
use_aa=not cli.args.no_aa,
)
# Render out the data
font.save_to_image(output)
def _image_to_qff(cli, input_):
"""
This function converts an image, eg .png, into its QFF representation
Uses cli to work out how to make the conversion, and reads from the provided file
"""
# Work out the format
format = valid_formats[cli.args.format]
# Create the font object
font = QFFFont(cli.log)
# Read from the input
font.read_from_image(input_, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs)
# Render out the data
out_data = BytesIO()
font.save_to_qff(format, not cli.args.no_rle, out_data)
out_bytes = out_data.getvalue()
if cli.args.raw:
raw_file = cli.args.output / f"{cli.args.input.stem}.qff"
with open(raw_file, 'wb') as raw:
raw.write(out_bytes)
return
# Work out the text substitutions for rendering the output data
metadata = {"glyphs": _generate_font_glyphs_list(not cli.args.no_ascii, cli.args.unicode_glyphs)}
subs = generate_subs(cli, out_bytes, font_metadata=metadata)
# Render and write the header file
header_text = render_header(subs)
header_file = cli.args.output / f"{cli.args.input.stem}.qff.h"
with open(header_file, 'w') as header:
print(f"Writing {header_file} ...")
header.write(header_text)
# Render and write the source file
source_text = render_source(subs)
source_file = cli.args.output / f"{cli.args.input.stem}.qff.c"
with open(source_file, 'w') as source:
print(f"Writing {source_file} ...")
source.write(source_text)
def _sanitize_paths(cli):
cli.args.input = normpath(cli.args.input)
if len(cli.args.output) == 0:
cli.args.output = cli.args.input.parent
cli.args.output = normpath(cli.args.output)
@cli.argument('-i', '--input', required=True, help='Specify input font file.')
@cli.argument('-o', '--output', required=True, help='Specify output image path.') @cli.argument('-o', '--output', required=True, help='Specify output image path.')
@cli.argument('-s', '--size', default=12, help='Specify font size. Default 12.') @cli.argument('-s', '--size', default=12, help='Specify font size. Default 12.')
@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.') @cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
@ -18,78 +90,38 @@ from milc import cli
@cli.argument('-a', '--no-aa', arg_only=True, action='store_true', help='Disable anti-aliasing on fonts.') @cli.argument('-a', '--no-aa', arg_only=True, action='store_true', help='Disable anti-aliasing on fonts.')
@cli.subcommand('Converts an input font to something QMK understands') @cli.subcommand('Converts an input font to something QMK understands')
def painter_make_font_image(cli): def painter_make_font_image(cli):
# Create the font object _sanitize_paths(cli)
font = QFFFont(cli) _font_to_image(cli, cli.args.output)
# Read from the input file print(f"Writing {cli.args.output} ...")
cli.args.font = normpath(cli.args.font)
font.generate_image(cli.args.font, cli.args.size, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs, use_aa=(False if cli.args.no_aa else True))
# Render out the data
font.save_to_image(normpath(cli.args.output))
@cli.argument('-i', '--input', help='Specify input graphic file.') @cli.argument('-i', '--input', help='Specify input graphic file.')
@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.') @cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
@cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.') @cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.') @cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
@cli.argument('-f', '--format', required=True, help='Output format, valid types: %s' % (', '.join(valid_formats.keys()))) @cli.argument('-f', '--format', required=True, help=f"Output format, valid types: {', '.join(valid_formats.keys())}")
@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.') @cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.')
@cli.argument('-w', '--raw', arg_only=True, action='store_true', help='Writes out the QFF file as raw data instead of c/h combo.') @cli.argument('-w', '--raw', arg_only=True, action='store_true', help='Writes out the QFF file as raw data instead of c/h combo.')
@cli.subcommand('Converts an input font image to something QMK firmware understands') @cli.subcommand('Converts an input font image to something QMK firmware understands')
def painter_convert_font_image(cli): def painter_convert_font_image(cli):
# Work out the format _sanitize_paths(cli)
format = valid_formats[cli.args.format] _image_to_qff(cli, cli.args.input)
# Create the font object
font = QFFFont(cli.log)
# Read from the input file @cli.argument('-a', '--no-aa', arg_only=True, action='store_true', help='Disable anti-aliasing on fonts.')
cli.args.input = normpath(cli.args.input) @cli.argument('-n', '--no-ascii', arg_only=True, action='store_true', help='Disables output of the full ASCII character set (0x20..0x7E), exporting only the glyphs specified.')
font.read_from_image(cli.args.input, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs) @cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.')
@cli.argument('-f', '--format', required=True, help=f"Output format, valid types: {', '.join(valid_formats.keys())}")
@cli.argument('-i', '--input', help='Specify input font file.')
@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
@cli.argument('-s', '--size', default=12, help='Specify font size. Default 12.')
@cli.argument('-u', '--unicode-glyphs', default='', help='Also generate the specified unicode glyphs.')
@cli.argument('-w', '--raw', arg_only=True, action='store_true', help='Writes out the QFF file as raw data instead of c/h combo.')
@cli.subcommand('Converts an input font file to something QMK firmware understands')
def painter_convert_font(cli):
_sanitize_paths(cli)
# Work out the output directory with BytesIO() as file:
if len(cli.args.output) == 0: _font_to_image(cli, file)
cli.args.output = cli.args.input.parent _image_to_qff(cli, file)
cli.args.output = normpath(cli.args.output)
# Render out the data
out_data = BytesIO()
font.save_to_qff(format, (False if cli.args.no_rle else True), out_data)
out_bytes = out_data.getvalue()
if cli.args.raw:
raw_file = cli.args.output / (cli.args.input.stem + ".qff")
with open(raw_file, 'wb') as raw:
raw.write(out_bytes)
return
# Work out the text substitutions for rendering the output data
subs = {
'generated_type': 'font',
'var_prefix': 'font',
'generator_command': f'qmk painter-convert-font-image -i {cli.args.input.name} -f {cli.args.format}',
'year': datetime.date.today().strftime("%Y"),
'input_file': cli.args.input.name,
'sane_name': re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
'byte_count': len(out_bytes),
'bytes_lines': render_bytes(out_bytes),
'format': cli.args.format,
}
# Render the license
subs.update({'license': render_license(subs)})
# Render and write the header file
header_text = render_header(subs)
header_file = cli.args.output / (cli.args.input.stem + ".qff.h")
with open(header_file, 'w') as header:
print(f"Writing {header_file}...")
header.write(header_text)
header.close()
# Render and write the source file
source_text = render_source(subs)
source_file = cli.args.output / (cli.args.input.stem + ".qff.c")
with open(source_file, 'w') as source:
print(f"Writing {source_file}...")
source.write(source_text)
source.close()

View File

@ -1,5 +1,6 @@
"""Functions that help us work with Quantum Painter's file formats. """Functions that help us work with Quantum Painter's file formats.
""" """
import datetime
import math import math
import re import re
from string import Template from string import Template
@ -79,6 +80,106 @@ valid_formats = {
} }
} }
def _render_text(values):
# FIXME: May need more chars with GIFs containing lots of frames (or longer durations)
return "|".join([f"{i:4d}" for i in values])
def _render_numeration(metadata):
return _render_text(range(len(metadata)))
def _render_values(metadata, key):
return _render_text([i[key] for i in metadata])
def _render_image_metadata(metadata):
size = metadata.pop(0)
lines = [
"// Image's metadata",
"// ----------------",
f"// Width: {size['width']}",
f"// Height: {size['height']}",
]
if len(metadata) == 1:
lines.append("// Single frame")
else:
lines.extend([
f"// Frame: {_render_numeration(metadata)}",
f"// Duration(ms): {_render_values(metadata, 'delay')}",
f"// Compression: {_render_values(metadata, 'compression')} >> See qp.h, painter_compression_t",
f"// Delta: {_render_values(metadata, 'delta')}",
])
deltas = []
for i, v in enumerate(metadata):
# Not a delta frame, go to next one
if not v["delta"]:
continue
# Unpack rect's coords
l, t, r, b = v["delta_rect"]
delta_px = (r - l) * (b - t)
px = size["width"] * size["height"]
# FIXME: May need need more chars here too
deltas.append(f"// Frame {i:3d}: ({l:3d}, {t:3d}) - ({r:3d}, {b:3d}) >> {delta_px:4d}/{px:4d} pixels ({100*delta_px/px:.2f}%)")
if deltas:
lines.append("// Areas on delta frames")
lines.extend(deltas)
return "\n".join(lines)
def generate_subs(cli, out_bytes, *, font_metadata=None, image_metadata=None):
if font_metadata is not None and image_metadata is not None:
raise ValueError("Cant generate subs for font and image at the same time")
subs = {
"year": datetime.date.today().strftime("%Y"),
"input_file": cli.args.input.name,
"sane_name": re.sub(r"[^a-zA-Z0-9]", "_", cli.args.input.stem),
"byte_count": len(out_bytes),
"bytes_lines": render_bytes(out_bytes),
"format": cli.args.format,
}
if font_metadata is not None:
subs.update({
"generated_type": "font",
"var_prefix": "font",
"generator_command": f"qmk painter-convert-font-image -i {cli.args.input.name} -f {cli.args.format}{' --no-ascii' if cli.args.no_ascii else ''}",
# not using triple quotes to avoid extra indentation/weird formatted code
"metadata": "\n".join([
"// Font's metadata",
"// ---------------",
f"// Glyphs: {', '.join([i for i in font_metadata['glyphs']])}",
]),
})
elif image_metadata is not None:
subs.update({
"generated_type": "image",
"var_prefix": "gfx",
"generator_command": f"qmk painter-convert-graphics -i {cli.args.input.name} -f {cli.args.format}",
# not using triple quotes to avoid extra indentation/weird formatted code
"metadata": _render_image_metadata(image_metadata),
})
else:
raise ValueError("Pass metadata for either an image or a font")
subs.update({"license": render_license(subs)})
return subs
license_template = """\ license_template = """\
// Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright // Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
@ -110,6 +211,8 @@ def render_header(subs):
source_file_template = """\ source_file_template = """\
${license} ${license}
${metadata}
#include <qp.h> #include <qp.h>
const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count}; const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
@ -191,7 +294,13 @@ def convert_requested_format(im, format):
return im return im
def convert_image_bytes(im, format): def rgb_to565(r, g, b):
msb = ((r >> 3 & 0x1F) << 3) + (g >> 5 & 0x07)
lsb = ((g >> 2 & 0x07) << 5) + (b >> 3 & 0x1F)
return [msb, lsb]
def convert_image_bytes(im, format): # sourcery skip: raise-specific-error
"""Convert the supplied image to the equivalent bytes required by the QMK firmware. """Convert the supplied image to the equivalent bytes required by the QMK firmware.
""" """
@ -248,41 +357,26 @@ def convert_image_bytes(im, format):
if image_format == 'IMAGE_FORMAT_RGB565': if image_format == 'IMAGE_FORMAT_RGB565':
# Take the red, green, and blue channels # Take the red, green, and blue channels
image_bytes_red = im.tobytes("raw", "R") red = im.tobytes("raw", "R")
image_bytes_green = im.tobytes("raw", "G") green = im.tobytes("raw", "G")
image_bytes_blue = im.tobytes("raw", "B") blue = im.tobytes("raw", "B")
image_pixels_len = len(image_bytes_red)
# No palette # No palette
palette = None palette = None
bytearray = [] bytearray = [byte for r, g, b in zip(red, green, blue) for byte in rgb_to565(r, g, b)]
for x in range(image_pixels_len):
# 5 bits of red, 3 MSb of green
byte = ((image_bytes_red[x] >> 3 & 0x1F) << 3) + (image_bytes_green[x] >> 5 & 0x07)
bytearray.append(byte)
# 3 LSb of green, 5 bits of blue
byte = ((image_bytes_green[x] >> 2 & 0x07) << 5) + (image_bytes_blue[x] >> 3 & 0x1F)
bytearray.append(byte)
if image_format == 'IMAGE_FORMAT_RGB888': if image_format == 'IMAGE_FORMAT_RGB888':
# Take the red, green, and blue channels # Take the red, green, and blue channels
image_bytes_red = im.tobytes("raw", "R") red = im.tobytes("raw", "R")
image_bytes_green = im.tobytes("raw", "G") green = im.tobytes("raw", "G")
image_bytes_blue = im.tobytes("raw", "B") blue = im.tobytes("raw", "B")
image_pixels_len = len(image_bytes_red)
# No palette # No palette
palette = None palette = None
bytearray = [] bytearray = [byte for r, g, b in zip(red, green, blue) for byte in (r, g, b)]
for x in range(image_pixels_len):
byte = image_bytes_red[x]
bytearray.append(byte)
byte = image_bytes_green[x]
bytearray.append(byte)
byte = image_bytes_blue[x]
bytearray.append(byte)
if len(bytearray) != expected_byte_count: if len(bytearray) != expected_byte_count:
raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}") raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
@ -308,7 +402,7 @@ def compress_bytes_qmk_rle(bytearray):
output.extend(r) output.extend(r)
for n in range(0, len(bytearray) + 1): for n in range(0, len(bytearray) + 1):
end = True if n == len(bytearray) else False end = n == len(bytearray)
if not end: if not end:
c = bytearray[n] c = bytearray[n]
temp.append(c) temp.append(c)
@ -330,7 +424,7 @@ def compress_bytes_qmk_rle(bytearray):
if len(temp) >= 2 and temp[-1] == temp[-2]: if len(temp) >= 2 and temp[-1] == temp[-2]:
repeat = True repeat = True
if len(temp) > 2: if len(temp) > 2:
append_range(temp[0:(len(temp) - 2)]) append_range(temp[:len(temp) - 2])
temp = [temp[-1], temp[-1]] temp = [temp[-1], temp[-1]]
continue continue
if len(temp) == 128 or end: if len(temp) == 128 or end:

View File

@ -4,6 +4,7 @@
# Quantum Font File "QFF" Font File Format. # Quantum Font File "QFF" Font File Format.
# See https://docs.qmk.fm/#/quantum_painter_qff for more information. # See https://docs.qmk.fm/#/quantum_painter_qff for more information.
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any
from colorsys import rgb_to_hsv from colorsys import rgb_to_hsv
@ -14,6 +15,8 @@ from milc.attrdict import AttrDict
import qmk.painter import qmk.painter
DEFAULT_FMT = "PNG"
def o24(i): def o24(i):
return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16) return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
@ -318,12 +321,19 @@ class QFFFont:
return return
# Save the image to the supplied file # Save the image to the supplied file
self.image.save(str(img_file)) if isinstance(img_file, BytesIO):
self.image.save(img_file, format=DEFAULT_FMT)
else:
self.image.save(str(img_file))
def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''): def read_from_image(self, img_file: Path, include_ascii_glyphs: bool = True, unicode_glyphs: str = ''):
# Load and parse the supplied image file # Load and parse the supplied image file
self._parse_image(Image.open(str(img_file)), include_ascii_glyphs, unicode_glyphs) if isinstance(img_file, BytesIO):
return image = Image.open(img_file, formats=(DEFAULT_FMT,))
else:
image = Image.open(str(img_file))
self._parse_image(image, include_ascii_glyphs, unicode_glyphs)
def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp): def save_to_qff(self, format: Dict[str, Any], use_rle: bool, fp):
# Drop out if there's no image loaded # Drop out if there's no image loaded