Quantum Painter (#10174)
* Install dependencies before executing unit tests. * Split out UTF-8 decoder. * Fixup python formatting rules. * Add documentation for QGF/QFF and the RLE format used. * Add CLI commands for converting images and fonts. * Add stub rules.mk for QP. * Add stream type. * Add base driver and comms interfaces. * Add support for SPI, SPI+D/C comms drivers. * Include <qp.h> when enabled. * Add base support for SPI+D/C+RST panels, as well as concrete implementation of ST7789. * Add support for GC9A01. * Add support for ILI9341. * Add support for ILI9163. * Add support for SSD1351. * Implement qp_setpixel, including pixdata buffer management. * Implement qp_line. * Implement qp_rect. * Implement qp_circle. * Implement qp_ellipse. * Implement palette interpolation. * Allow for streams to work with either flash or RAM. * Image loading. * Font loading. * QGF palette loading. * Progressive decoder of pixel data supporting Raw+RLE, 1-,2-,4-,8-bpp monochrome and palette-based images. * Image drawing. * Animations. * Font rendering. * Check against 256 colours, dump out the loaded palette if debugging enabled. * Fix build. * AVR is not the intended audience. * `qmk format-c` * Generation fix. * First batch of docs. * More docs and examples. * Review comments. * Public API documentation.
This commit is contained in:
		
							parent
							
								
									1dbbd2b6b0
								
							
						
					
					
						commit
						1f2b1dedcc
					
				
					 62 changed files with 7561 additions and 35 deletions
				
			
		| 
						 | 
				
			
			@ -16,7 +16,8 @@ import_names = {
 | 
			
		|||
    # A mapping of package name to importable name
 | 
			
		||||
    'pep8-naming': 'pep8ext_naming',
 | 
			
		||||
    'pyusb': 'usb.core',
 | 
			
		||||
    'qmk-dotty-dict': 'dotty_dict'
 | 
			
		||||
    'qmk-dotty-dict': 'dotty_dict',
 | 
			
		||||
    'pillow': 'PIL'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
safe_commands = [
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +68,7 @@ subcommands = [
 | 
			
		|||
    'qmk.cli.multibuild',
 | 
			
		||||
    'qmk.cli.new.keyboard',
 | 
			
		||||
    'qmk.cli.new.keymap',
 | 
			
		||||
    'qmk.cli.painter',
 | 
			
		||||
    'qmk.cli.pyformat',
 | 
			
		||||
    'qmk.cli.pytest',
 | 
			
		||||
    'qmk.cli.via2json',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								lib/python/qmk/cli/painter/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								lib/python/qmk/cli/painter/__init__.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
from . import convert_graphics
 | 
			
		||||
from . import make_font
 | 
			
		||||
							
								
								
									
										86
									
								
								lib/python/qmk/cli/painter/convert_graphics.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								lib/python/qmk/cli/painter/convert_graphics.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,86 @@
 | 
			
		|||
"""This script tests QGF functionality.
 | 
			
		||||
"""
 | 
			
		||||
import re
 | 
			
		||||
import datetime
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
from qmk.path import normpath
 | 
			
		||||
from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats
 | 
			
		||||
from milc import cli
 | 
			
		||||
from PIL import Image
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@cli.argument('-v', '--verbose', arg_only=True, action='store_true', help='Turns on verbose output.')
 | 
			
		||||
@cli.argument('-i', '--input', required=True, help='Specify input graphic file.')
 | 
			
		||||
@cli.argument('-o', '--output', default='', help='Specify output directory. Defaults to same directory as input.')
 | 
			
		||||
@cli.argument('-f', '--format', required=True, help='Output format, valid types: %s' % (', '.join(valid_formats.keys())))
 | 
			
		||||
@cli.argument('-r', '--no-rle', arg_only=True, action='store_true', help='Disables the use of RLE when encoding images.')
 | 
			
		||||
@cli.argument('-d', '--no-deltas', arg_only=True, action='store_true', help='Disables the use of delta frames when encoding animations.')
 | 
			
		||||
@cli.subcommand('Converts an input image to something QMK understands')
 | 
			
		||||
def painter_convert_graphics(cli):
 | 
			
		||||
    """Converts an image file to a format that Quantum Painter understands.
 | 
			
		||||
 | 
			
		||||
    This command uses the `qmk.painter` module to generate a Quantum Painter image defintion from an image. The generated definitions are written to a files next to the input -- `INPUT.c` and `INPUT.h`.
 | 
			
		||||
    """
 | 
			
		||||
    # Work out the input file
 | 
			
		||||
    if cli.args.input != '-':
 | 
			
		||||
        cli.args.input = normpath(cli.args.input)
 | 
			
		||||
 | 
			
		||||
        # Error checking
 | 
			
		||||
        if not cli.args.input.exists():
 | 
			
		||||
            cli.log.error('Input image file does not exist!')
 | 
			
		||||
            cli.print_usage()
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    # Work out the output directory
 | 
			
		||||
    if len(cli.args.output) == 0:
 | 
			
		||||
        cli.args.output = cli.args.input.parent
 | 
			
		||||
    cli.args.output = normpath(cli.args.output)
 | 
			
		||||
 | 
			
		||||
    # Ensure we have a valid format
 | 
			
		||||
    if cli.args.format not in valid_formats.keys():
 | 
			
		||||
        cli.log.error('Output format %s is invalid. Allowed values: %s' % (cli.args.format, ', '.join(valid_formats.keys())))
 | 
			
		||||
        cli.print_usage()
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    # Work out the encoding parameters
 | 
			
		||||
    format = valid_formats[cli.args.format]
 | 
			
		||||
 | 
			
		||||
    # Load the input image
 | 
			
		||||
    input_img = Image.open(cli.args.input)
 | 
			
		||||
 | 
			
		||||
    # Convert the image to QGF using PIL
 | 
			
		||||
    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)
 | 
			
		||||
    out_bytes = out_data.getvalue()
 | 
			
		||||
 | 
			
		||||
    # Work out the text substitutions for rendering the output data
 | 
			
		||||
    subs = {
 | 
			
		||||
        '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
 | 
			
		||||
    header_text = render_header(subs)
 | 
			
		||||
    header_file = cli.args.output / (cli.args.input.stem + ".qgf.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 + ".qgf.c")
 | 
			
		||||
    with open(source_file, 'w') as source:
 | 
			
		||||
        print(f"Writing {source_file}...")
 | 
			
		||||
        source.write(source_text)
 | 
			
		||||
        source.close()
 | 
			
		||||
							
								
								
									
										87
									
								
								lib/python/qmk/cli/painter/make_font.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								lib/python/qmk/cli/painter/make_font.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,87 @@
 | 
			
		|||
"""This script automates the conversion of font files into a format QMK firmware understands.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import re
 | 
			
		||||
import datetime
 | 
			
		||||
from io import BytesIO
 | 
			
		||||
from qmk.path import normpath
 | 
			
		||||
from qmk.painter_qff import QFFFont
 | 
			
		||||
from qmk.painter import render_header, render_source, render_license, render_bytes, valid_formats
 | 
			
		||||
from milc import cli
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@cli.argument('-f', '--font', required=True, help='Specify input font file.')
 | 
			
		||||
@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('-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('-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')
 | 
			
		||||
def painter_make_font_image(cli):
 | 
			
		||||
    # Create the font object
 | 
			
		||||
    font = QFFFont(cli)
 | 
			
		||||
    # Read from the input file
 | 
			
		||||
    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('-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('-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('-r', '--no-rle', arg_only=True, action='store_true', help='Disable the use of RLE to minimise converted image size.')
 | 
			
		||||
@cli.subcommand('Converts an input font image to something QMK firmware understands')
 | 
			
		||||
def painter_convert_font_image(cli):
 | 
			
		||||
    # Work out the format
 | 
			
		||||
    format = valid_formats[cli.args.format]
 | 
			
		||||
 | 
			
		||||
    # Create the font object
 | 
			
		||||
    font = QFFFont(cli.log)
 | 
			
		||||
 | 
			
		||||
    # Read from the input file
 | 
			
		||||
    cli.args.input = normpath(cli.args.input)
 | 
			
		||||
    font.read_from_image(cli.args.input, include_ascii_glyphs=(not cli.args.no_ascii), unicode_glyphs=cli.args.unicode_glyphs)
 | 
			
		||||
 | 
			
		||||
    # Work out the output directory
 | 
			
		||||
    if len(cli.args.output) == 0:
 | 
			
		||||
        cli.args.output = cli.args.input.parent
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
    # 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': out_data.getbuffer().nbytes,
 | 
			
		||||
        'bytes_lines': render_bytes(out_data.getbuffer().tobytes()),
 | 
			
		||||
        '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()
 | 
			
		||||
							
								
								
									
										268
									
								
								lib/python/qmk/painter.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										268
									
								
								lib/python/qmk/painter.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,268 @@
 | 
			
		|||
"""Functions that help us work with Quantum Painter's file formats.
 | 
			
		||||
"""
 | 
			
		||||
import math
 | 
			
		||||
import re
 | 
			
		||||
from string import Template
 | 
			
		||||
from PIL import Image, ImageOps
 | 
			
		||||
 | 
			
		||||
# The list of valid formats Quantum Painter supports
 | 
			
		||||
valid_formats = {
 | 
			
		||||
    'pal256': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_PALETTE',
 | 
			
		||||
        'bpp': 8,
 | 
			
		||||
        'has_palette': True,
 | 
			
		||||
        'num_colors': 256,
 | 
			
		||||
        'image_format_byte': 0x07,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'pal16': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_PALETTE',
 | 
			
		||||
        'bpp': 4,
 | 
			
		||||
        'has_palette': True,
 | 
			
		||||
        'num_colors': 16,
 | 
			
		||||
        'image_format_byte': 0x06,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'pal4': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_PALETTE',
 | 
			
		||||
        'bpp': 2,
 | 
			
		||||
        'has_palette': True,
 | 
			
		||||
        'num_colors': 4,
 | 
			
		||||
        'image_format_byte': 0x05,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'pal2': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_PALETTE',
 | 
			
		||||
        'bpp': 1,
 | 
			
		||||
        'has_palette': True,
 | 
			
		||||
        'num_colors': 2,
 | 
			
		||||
        'image_format_byte': 0x04,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'mono256': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_GRAYSCALE',
 | 
			
		||||
        'bpp': 8,
 | 
			
		||||
        'has_palette': False,
 | 
			
		||||
        'num_colors': 256,
 | 
			
		||||
        'image_format_byte': 0x03,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'mono16': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_GRAYSCALE',
 | 
			
		||||
        'bpp': 4,
 | 
			
		||||
        'has_palette': False,
 | 
			
		||||
        'num_colors': 16,
 | 
			
		||||
        'image_format_byte': 0x02,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'mono4': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_GRAYSCALE',
 | 
			
		||||
        'bpp': 2,
 | 
			
		||||
        'has_palette': False,
 | 
			
		||||
        'num_colors': 4,
 | 
			
		||||
        'image_format_byte': 0x01,  # see qp_internal_formats.h
 | 
			
		||||
    },
 | 
			
		||||
    'mono2': {
 | 
			
		||||
        'image_format': 'IMAGE_FORMAT_GRAYSCALE',
 | 
			
		||||
        'bpp': 1,
 | 
			
		||||
        'has_palette': False,
 | 
			
		||||
        'num_colors': 2,
 | 
			
		||||
        'image_format_byte': 0x00,  # see qp_internal_formats.h
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
license_template = """\
 | 
			
		||||
// Copyright ${year} QMK -- generated source code only, ${generated_type} retains original copyright
 | 
			
		||||
// SPDX-License-Identifier: GPL-2.0-or-later
 | 
			
		||||
 | 
			
		||||
// This file was auto-generated by `${generator_command}`
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_license(subs):
 | 
			
		||||
    license_txt = Template(license_template)
 | 
			
		||||
    return license_txt.substitute(subs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
header_file_template = """\
 | 
			
		||||
${license}
 | 
			
		||||
#pragma once
 | 
			
		||||
 | 
			
		||||
#include <qp.h>
 | 
			
		||||
 | 
			
		||||
extern const uint32_t ${var_prefix}_${sane_name}_length;
 | 
			
		||||
extern const uint8_t  ${var_prefix}_${sane_name}[${byte_count}];
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_header(subs):
 | 
			
		||||
    header_txt = Template(header_file_template)
 | 
			
		||||
    return header_txt.substitute(subs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
source_file_template = """\
 | 
			
		||||
${license}
 | 
			
		||||
#include <qp.h>
 | 
			
		||||
 | 
			
		||||
const uint32_t ${var_prefix}_${sane_name}_length = ${byte_count};
 | 
			
		||||
 | 
			
		||||
// clang-format off
 | 
			
		||||
const uint8_t ${var_prefix}_${sane_name}[${byte_count}] = {
 | 
			
		||||
${bytes_lines}
 | 
			
		||||
};
 | 
			
		||||
// clang-format on
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_source(subs):
 | 
			
		||||
    source_txt = Template(source_file_template)
 | 
			
		||||
    return source_txt.substitute(subs)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def render_bytes(bytes, newline_after=16):
 | 
			
		||||
    lines = ''
 | 
			
		||||
    for n in range(len(bytes)):
 | 
			
		||||
        if n % newline_after == 0 and n > 0 and n != len(bytes):
 | 
			
		||||
            lines = lines + "\n   "
 | 
			
		||||
        elif n == 0:
 | 
			
		||||
            lines = lines + "   "
 | 
			
		||||
        lines = lines + " 0x{0:02X},".format(bytes[n])
 | 
			
		||||
    return lines.rstrip()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def clean_output(str):
 | 
			
		||||
    str = re.sub(r'\r', '', str)
 | 
			
		||||
    str = re.sub(r'[\n]{3,}', r'\n\n', str)
 | 
			
		||||
    return str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def rescale_byte(val, maxval):
 | 
			
		||||
    """Rescales a byte value to the supplied range, i.e. [0,255] -> [0,maxval].
 | 
			
		||||
    """
 | 
			
		||||
    return int(round(val * maxval / 255.0))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def convert_requested_format(im, format):
 | 
			
		||||
    """Convert an image to the requested format.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Work out the requested format
 | 
			
		||||
    ncolors = format["num_colors"]
 | 
			
		||||
    image_format = format["image_format"]
 | 
			
		||||
 | 
			
		||||
    # Ensure we have a valid number of colors for the palette
 | 
			
		||||
    if ncolors <= 0 or ncolors > 256 or (ncolors & (ncolors - 1) != 0):
 | 
			
		||||
        raise ValueError("Number of colors must be 2, 4, 16, or 256.")
 | 
			
		||||
 | 
			
		||||
    # Work out where we're getting the bytes from
 | 
			
		||||
    if image_format == 'IMAGE_FORMAT_GRAYSCALE':
 | 
			
		||||
        # If mono, convert input to grayscale, then to RGB, then grab the raw bytes corresponding to the intensity of the red channel
 | 
			
		||||
        im = ImageOps.grayscale(im)
 | 
			
		||||
        im = im.convert("RGB")
 | 
			
		||||
    elif image_format == 'IMAGE_FORMAT_PALETTE':
 | 
			
		||||
        # If color, convert input to RGB, palettize based on the supplied number of colors, then get the raw palette bytes
 | 
			
		||||
        im = im.convert("RGB")
 | 
			
		||||
        im = im.convert("P", palette=Image.ADAPTIVE, colors=ncolors)
 | 
			
		||||
 | 
			
		||||
    return im
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def convert_image_bytes(im, format):
 | 
			
		||||
    """Convert the supplied image to the equivalent bytes required by the QMK firmware.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # Work out the requested format
 | 
			
		||||
    ncolors = format["num_colors"]
 | 
			
		||||
    image_format = format["image_format"]
 | 
			
		||||
    shifter = int(math.log2(ncolors))
 | 
			
		||||
    pixels_per_byte = int(8 / math.log2(ncolors))
 | 
			
		||||
    (width, height) = im.size
 | 
			
		||||
    expected_byte_count = ((width * height) + (pixels_per_byte - 1)) // pixels_per_byte
 | 
			
		||||
 | 
			
		||||
    if image_format == 'IMAGE_FORMAT_GRAYSCALE':
 | 
			
		||||
        # Take the red channel
 | 
			
		||||
        image_bytes = im.tobytes("raw", "R")
 | 
			
		||||
        image_bytes_len = len(image_bytes)
 | 
			
		||||
 | 
			
		||||
        # No palette
 | 
			
		||||
        palette = None
 | 
			
		||||
 | 
			
		||||
        bytearray = []
 | 
			
		||||
        for x in range(expected_byte_count):
 | 
			
		||||
            byte = 0
 | 
			
		||||
            for n in range(pixels_per_byte):
 | 
			
		||||
                byte_offset = x * pixels_per_byte + n
 | 
			
		||||
                if byte_offset < image_bytes_len:
 | 
			
		||||
                    # If mono, each input byte is a grayscale [0,255] pixel -- rescale to the range we want then pack together
 | 
			
		||||
                    byte = byte | (rescale_byte(image_bytes[byte_offset], ncolors - 1) << int(n * shifter))
 | 
			
		||||
            bytearray.append(byte)
 | 
			
		||||
 | 
			
		||||
    elif image_format == 'IMAGE_FORMAT_PALETTE':
 | 
			
		||||
        # Convert each pixel to the palette bytes
 | 
			
		||||
        image_bytes = im.tobytes("raw", "P")
 | 
			
		||||
        image_bytes_len = len(image_bytes)
 | 
			
		||||
 | 
			
		||||
        # Export the palette
 | 
			
		||||
        palette = []
 | 
			
		||||
        pal = im.getpalette()
 | 
			
		||||
        for n in range(0, ncolors * 3, 3):
 | 
			
		||||
            palette.append((pal[n + 0], pal[n + 1], pal[n + 2]))
 | 
			
		||||
 | 
			
		||||
        bytearray = []
 | 
			
		||||
        for x in range(expected_byte_count):
 | 
			
		||||
            byte = 0
 | 
			
		||||
            for n in range(pixels_per_byte):
 | 
			
		||||
                byte_offset = x * pixels_per_byte + n
 | 
			
		||||
                if byte_offset < image_bytes_len:
 | 
			
		||||
                    # If color, each input byte is the index into the color palette -- pack them together
 | 
			
		||||
                    byte = byte | ((image_bytes[byte_offset] & (ncolors - 1)) << int(n * shifter))
 | 
			
		||||
            bytearray.append(byte)
 | 
			
		||||
 | 
			
		||||
    if len(bytearray) != expected_byte_count:
 | 
			
		||||
        raise Exception(f"Wrong byte count, was {len(bytearray)}, expected {expected_byte_count}")
 | 
			
		||||
 | 
			
		||||
    return (palette, bytearray)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def compress_bytes_qmk_rle(bytearray):
 | 
			
		||||
    debug_dump = False
 | 
			
		||||
    output = []
 | 
			
		||||
    temp = []
 | 
			
		||||
    repeat = False
 | 
			
		||||
 | 
			
		||||
    def append_byte(c):
 | 
			
		||||
        if debug_dump:
 | 
			
		||||
            print('Appending byte:', '0x{0:02X}'.format(int(c)), '=', c)
 | 
			
		||||
        output.append(c)
 | 
			
		||||
 | 
			
		||||
    def append_range(r):
 | 
			
		||||
        append_byte(127 + len(r))
 | 
			
		||||
        if debug_dump:
 | 
			
		||||
            print('Appending {0} byte(s):'.format(len(r)), '[', ', '.join(['{0:02X}'.format(e) for e in r]), ']')
 | 
			
		||||
        output.extend(r)
 | 
			
		||||
 | 
			
		||||
    for n in range(0, len(bytearray) + 1):
 | 
			
		||||
        end = True if n == len(bytearray) else False
 | 
			
		||||
        if not end:
 | 
			
		||||
            c = bytearray[n]
 | 
			
		||||
            temp.append(c)
 | 
			
		||||
            if len(temp) <= 1:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
        if debug_dump:
 | 
			
		||||
            print('Temp buffer state {0:3d} bytes:'.format(len(temp)), '[', ', '.join(['{0:02X}'.format(e) for e in temp]), ']')
 | 
			
		||||
 | 
			
		||||
        if repeat:
 | 
			
		||||
            if temp[-1] != temp[-2]:
 | 
			
		||||
                repeat = False
 | 
			
		||||
            if not repeat or len(temp) == 128 or end:
 | 
			
		||||
                append_byte(len(temp) if end else len(temp) - 1)
 | 
			
		||||
                append_byte(temp[0])
 | 
			
		||||
                temp = [temp[-1]]
 | 
			
		||||
                repeat = False
 | 
			
		||||
        else:
 | 
			
		||||
            if len(temp) >= 2 and temp[-1] == temp[-2]:
 | 
			
		||||
                repeat = True
 | 
			
		||||
                if len(temp) > 2:
 | 
			
		||||
                    append_range(temp[0:(len(temp) - 2)])
 | 
			
		||||
                    temp = [temp[-1], temp[-1]]
 | 
			
		||||
                continue
 | 
			
		||||
            if len(temp) == 128 or end:
 | 
			
		||||
                append_range(temp)
 | 
			
		||||
                temp = []
 | 
			
		||||
                repeat = False
 | 
			
		||||
    return output
 | 
			
		||||
							
								
								
									
										401
									
								
								lib/python/qmk/painter_qff.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								lib/python/qmk/painter_qff.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,401 @@
 | 
			
		|||
# 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)
 | 
			
		||||
							
								
								
									
										408
									
								
								lib/python/qmk/painter_qgf.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										408
									
								
								lib/python/qmk/painter_qgf.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,408 @@
 | 
			
		|||
# Copyright 2021 Nick Brassel (@tzarc)
 | 
			
		||||
# SPDX-License-Identifier: GPL-2.0-or-later
 | 
			
		||||
 | 
			
		||||
# Quantum Graphics File "QGF" Image File Format.
 | 
			
		||||
# See https://docs.qmk.fm/#/quantum_painter_qgf for more information.
 | 
			
		||||
 | 
			
		||||
from colorsys import rgb_to_hsv
 | 
			
		||||
from types import FunctionType
 | 
			
		||||
from PIL import Image, ImageFile, ImageChops
 | 
			
		||||
from PIL._binary import o8, o16le as o16, o32le as o32
 | 
			
		||||
import qmk.painter
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def o24(i):
 | 
			
		||||
    return o16(i & 0xFFFF) + o8((i & 0xFF0000) >> 16)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFBlockHeader:
 | 
			
		||||
    block_size = 5
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        fp.write(b''  # start off with empty bytes...
 | 
			
		||||
                 + o8(self.type_id)  # block type id
 | 
			
		||||
                 + o8((~self.type_id) & 0xFF)  # negated block type id
 | 
			
		||||
                 + o24(self.length)  # blob length
 | 
			
		||||
                 )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFGraphicsDescriptor:
 | 
			
		||||
    type_id = 0x00
 | 
			
		||||
    length = 18
 | 
			
		||||
    magic = 0x464751
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFGraphicsDescriptor.type_id
 | 
			
		||||
        self.header.length = QGFGraphicsDescriptor.length
 | 
			
		||||
        self.version = 1
 | 
			
		||||
        self.total_file_size = 0
 | 
			
		||||
        self.image_width = 0
 | 
			
		||||
        self.image_height = 0
 | 
			
		||||
        self.frame_count = 0
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        fp.write(
 | 
			
		||||
            b''  # start off with empty bytes...
 | 
			
		||||
            + o24(QGFGraphicsDescriptor.magic)  # magic
 | 
			
		||||
            + o8(self.version)  # version
 | 
			
		||||
            + o32(self.total_file_size)  # file size
 | 
			
		||||
            + o32((~self.total_file_size) & 0xFFFFFFFF)  # negated file size
 | 
			
		||||
            + o16(self.image_width)  # width
 | 
			
		||||
            + o16(self.image_height)  # height
 | 
			
		||||
            + o16(self.frame_count)  # frame count
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFFrameOffsetDescriptorV1:
 | 
			
		||||
    type_id = 0x01
 | 
			
		||||
 | 
			
		||||
    def __init__(self, frame_count):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFFrameOffsetDescriptorV1.type_id
 | 
			
		||||
        self.frame_offsets = [0xFFFFFFFF] * frame_count
 | 
			
		||||
        self.frame_count = frame_count
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.length = len(self.frame_offsets) * 4
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        for offset in self.frame_offsets:
 | 
			
		||||
            fp.write(b''  # start off with empty bytes...
 | 
			
		||||
                     + o32(offset)  # offset
 | 
			
		||||
                     )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFFrameDescriptorV1:
 | 
			
		||||
    type_id = 0x02
 | 
			
		||||
    length = 6
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFFrameDescriptorV1.type_id
 | 
			
		||||
        self.header.length = QGFFrameDescriptorV1.length
 | 
			
		||||
        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
 | 
			
		||||
        self.delay = 1000  # Placeholder until it gets read from the animation
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        fp.write(b''  # start off with empty bytes...
 | 
			
		||||
                 + o8(self.format)  # format
 | 
			
		||||
                 + o8(self.flags)  # flags
 | 
			
		||||
                 + o8(self.compression)  # compression
 | 
			
		||||
                 + o8(self.transparency_index)  # transparency index
 | 
			
		||||
                 + o16(self.delay)  # delay
 | 
			
		||||
                 )
 | 
			
		||||
 | 
			
		||||
    @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
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_delta(self):
 | 
			
		||||
        return (self.flags & 0x02) == 0x02
 | 
			
		||||
 | 
			
		||||
    @is_delta.setter
 | 
			
		||||
    def is_delta(self, val):
 | 
			
		||||
        if val:
 | 
			
		||||
            self.flags |= 0x02
 | 
			
		||||
        else:
 | 
			
		||||
            self.flags &= ~0x02
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFFramePaletteDescriptorV1:
 | 
			
		||||
    type_id = 0x03
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFFramePaletteDescriptorV1.type_id
 | 
			
		||||
        self.header.length = 0
 | 
			
		||||
        self.palette_entries = [(0xFF, 0xFF, 0xFF)] * 4
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.length = len(self.palette_entries) * 3
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        for entry in self.palette_entries:
 | 
			
		||||
            fp.write(b''  # start off with empty bytes...
 | 
			
		||||
                     + o8(entry[0])  # h
 | 
			
		||||
                     + o8(entry[1])  # s
 | 
			
		||||
                     + o8(entry[2])  # v
 | 
			
		||||
                     )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFFrameDeltaDescriptorV1:
 | 
			
		||||
    type_id = 0x04
 | 
			
		||||
    length = 8
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFFrameDeltaDescriptorV1.type_id
 | 
			
		||||
        self.header.length = QGFFrameDeltaDescriptorV1.length
 | 
			
		||||
        self.left = 0
 | 
			
		||||
        self.top = 0
 | 
			
		||||
        self.right = 0
 | 
			
		||||
        self.bottom = 0
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        fp.write(b''  # start off with empty bytes...
 | 
			
		||||
                 + o16(self.left)  # left
 | 
			
		||||
                 + o16(self.top)  # top
 | 
			
		||||
                 + o16(self.right)  # right
 | 
			
		||||
                 + o16(self.bottom)  # bottom
 | 
			
		||||
                 )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFFrameDataDescriptorV1:
 | 
			
		||||
    type_id = 0x05
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.header = QGFBlockHeader()
 | 
			
		||||
        self.header.type_id = QGFFrameDataDescriptorV1.type_id
 | 
			
		||||
        self.data = []
 | 
			
		||||
 | 
			
		||||
    def write(self, fp):
 | 
			
		||||
        self.header.length = len(self.data)
 | 
			
		||||
        self.header.write(fp)
 | 
			
		||||
        fp.write(bytes(self.data))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QGFImageFile(ImageFile.ImageFile):
 | 
			
		||||
 | 
			
		||||
    format = "QGF"
 | 
			
		||||
    format_description = "Quantum Graphics File Format"
 | 
			
		||||
 | 
			
		||||
    def _open(self):
 | 
			
		||||
        raise NotImplementedError("Reading QGF files is not supported")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _accept(prefix):
 | 
			
		||||
    """Helper method used by PIL to work out if it can parse an input file.
 | 
			
		||||
 | 
			
		||||
    Currently unimplemented.
 | 
			
		||||
    """
 | 
			
		||||
    return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _save(im, fp, filename):
 | 
			
		||||
    """Helper method used by PIL to write to an output file.
 | 
			
		||||
    """
 | 
			
		||||
    # Work out from the parameters if we need to do anything special
 | 
			
		||||
    encoderinfo = im.encoderinfo.copy()
 | 
			
		||||
    append_images = list(encoderinfo.get("append_images", []))
 | 
			
		||||
    verbose = encoderinfo.get("verbose", False)
 | 
			
		||||
    use_deltas = encoderinfo.get("use_deltas", True)
 | 
			
		||||
    use_rle = encoderinfo.get("use_rle", True)
 | 
			
		||||
 | 
			
		||||
    # Helper for inline verbose prints
 | 
			
		||||
    def vprint(s):
 | 
			
		||||
        if verbose:
 | 
			
		||||
            print(s)
 | 
			
		||||
 | 
			
		||||
    # Helper to iterate through all frames in the input image
 | 
			
		||||
    def _for_all_frames(x: FunctionType):
 | 
			
		||||
        frame_num = 0
 | 
			
		||||
        last_frame = None
 | 
			
		||||
        for frame in [im] + append_images:
 | 
			
		||||
            # Get number of of frames in this image
 | 
			
		||||
            nfr = getattr(frame, "n_frames", 1)
 | 
			
		||||
            for idx in range(nfr):
 | 
			
		||||
                frame.seek(idx)
 | 
			
		||||
                frame.load()
 | 
			
		||||
                copy = frame.copy().convert("RGB")
 | 
			
		||||
                x(frame_num, copy, last_frame)
 | 
			
		||||
                last_frame = copy
 | 
			
		||||
                frame_num += 1
 | 
			
		||||
 | 
			
		||||
    # Collect all the frame sizes
 | 
			
		||||
    frame_sizes = []
 | 
			
		||||
    _for_all_frames(lambda idx, frame, last_frame: frame_sizes.append(frame.size))
 | 
			
		||||
 | 
			
		||||
    # Make sure all frames are the same size
 | 
			
		||||
    if len(list(set(frame_sizes))) != 1:
 | 
			
		||||
        raise ValueError("Mismatching sizes on frames")
 | 
			
		||||
 | 
			
		||||
    # Write out the initial graphics descriptor (and write a dummy value), so that we can come back and fill in the
 | 
			
		||||
    # correct values once we've written all the frames to the output
 | 
			
		||||
    graphics_descriptor_location = fp.tell()
 | 
			
		||||
    graphics_descriptor = QGFGraphicsDescriptor()
 | 
			
		||||
    graphics_descriptor.frame_count = len(frame_sizes)
 | 
			
		||||
    graphics_descriptor.image_width = frame_sizes[0][0]
 | 
			
		||||
    graphics_descriptor.image_height = frame_sizes[0][1]
 | 
			
		||||
    vprint(f'{"Graphics descriptor block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
    graphics_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
    # Work out the frame offset descriptor location (and write a dummy value), so that we can come back and fill in the
 | 
			
		||||
    # correct offsets once we've written all the frames to the output
 | 
			
		||||
    frame_offset_location = fp.tell()
 | 
			
		||||
    frame_offsets = QGFFrameOffsetDescriptorV1(graphics_descriptor.frame_count)
 | 
			
		||||
    vprint(f'{"Frame offsets block":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
    frame_offsets.write(fp)
 | 
			
		||||
 | 
			
		||||
    # Helper function to save each frame to the output file
 | 
			
		||||
    def _write_frame(idx, frame, last_frame):
 | 
			
		||||
        # If we replace the frame we're going to output with a delta, we can override it here
 | 
			
		||||
        this_frame = frame
 | 
			
		||||
        location = (0, 0)
 | 
			
		||||
        size = frame.size
 | 
			
		||||
 | 
			
		||||
        # Work out the format we're going to use
 | 
			
		||||
        format = encoderinfo["qmk_format"]
 | 
			
		||||
 | 
			
		||||
        # Convert the original frame so we can do comparisons
 | 
			
		||||
        converted = qmk.painter.convert_requested_format(this_frame, format)
 | 
			
		||||
        graphic_data = qmk.painter.convert_image_bytes(converted, format)
 | 
			
		||||
 | 
			
		||||
        # Convert the raw data to RLE-encoded if requested
 | 
			
		||||
        raw_data = graphic_data[1]
 | 
			
		||||
        if use_rle:
 | 
			
		||||
            rle_data = qmk.painter.compress_bytes_qmk_rle(graphic_data[1])
 | 
			
		||||
        use_raw_this_frame = not use_rle or len(raw_data) <= len(rle_data)
 | 
			
		||||
        image_data = raw_data if use_raw_this_frame else rle_data
 | 
			
		||||
 | 
			
		||||
        # Work out if a delta frame is smaller than injecting it directly
 | 
			
		||||
        use_delta_this_frame = False
 | 
			
		||||
        if use_deltas and last_frame is not None:
 | 
			
		||||
            # If we want to use deltas, then find the difference
 | 
			
		||||
            diff = ImageChops.difference(frame, last_frame)
 | 
			
		||||
 | 
			
		||||
            # Get the bounding box of those differences
 | 
			
		||||
            bbox = diff.getbbox()
 | 
			
		||||
 | 
			
		||||
            # If we have a valid bounding box...
 | 
			
		||||
            if bbox:
 | 
			
		||||
                # ...create the delta frame by cropping the original.
 | 
			
		||||
                delta_frame = frame.crop(bbox)
 | 
			
		||||
                delta_location = (bbox[0], bbox[1])
 | 
			
		||||
                delta_size = (bbox[2] - bbox[0], bbox[3] - bbox[1])
 | 
			
		||||
 | 
			
		||||
                # Convert the delta frame to the requested format
 | 
			
		||||
                delta_converted = qmk.painter.convert_requested_format(delta_frame, format)
 | 
			
		||||
                delta_graphic_data = qmk.painter.convert_image_bytes(delta_converted, format)
 | 
			
		||||
 | 
			
		||||
                # Work out how large the delta frame is going to be with compression etc.
 | 
			
		||||
                delta_raw_data = delta_graphic_data[1]
 | 
			
		||||
                if use_rle:
 | 
			
		||||
                    delta_rle_data = qmk.painter.compress_bytes_qmk_rle(delta_graphic_data[1])
 | 
			
		||||
                delta_use_raw_this_frame = not use_rle or len(delta_raw_data) <= len(delta_rle_data)
 | 
			
		||||
                delta_image_data = delta_raw_data if delta_use_raw_this_frame else delta_rle_data
 | 
			
		||||
 | 
			
		||||
                # If the size of the delta frame (plus delta descriptor) is smaller than the original, use that instead
 | 
			
		||||
                # This ensures that if a non-delta is overall smaller in size, we use that in preference due to flash
 | 
			
		||||
                # sizing constraints.
 | 
			
		||||
                if (len(delta_image_data) + QGFFrameDeltaDescriptorV1.length) < len(image_data):
 | 
			
		||||
                    # Copy across all the delta equivalents so that the rest of the processing acts on those
 | 
			
		||||
                    this_frame = delta_frame
 | 
			
		||||
                    location = delta_location
 | 
			
		||||
                    size = delta_size
 | 
			
		||||
                    converted = delta_converted
 | 
			
		||||
                    graphic_data = delta_graphic_data
 | 
			
		||||
                    raw_data = delta_raw_data
 | 
			
		||||
                    rle_data = delta_rle_data
 | 
			
		||||
                    use_raw_this_frame = delta_use_raw_this_frame
 | 
			
		||||
                    image_data = delta_image_data
 | 
			
		||||
                    use_delta_this_frame = True
 | 
			
		||||
 | 
			
		||||
        # Write out the frame descriptor
 | 
			
		||||
        frame_offsets.frame_offsets[idx] = fp.tell()
 | 
			
		||||
        vprint(f'{f"Frame {idx:3d} base":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
        frame_descriptor = QGFFrameDescriptorV1()
 | 
			
		||||
        frame_descriptor.is_delta = use_delta_this_frame
 | 
			
		||||
        frame_descriptor.is_transparent = False
 | 
			
		||||
        frame_descriptor.format = format['image_format_byte']
 | 
			
		||||
        frame_descriptor.compression = 0x00 if use_raw_this_frame else 0x01  # See qp.h, painter_compression_t
 | 
			
		||||
        frame_descriptor.delay = frame.info['duration'] if 'duration' in frame.info else 1000  # If we're not an animation, just pretend we're delaying for 1000ms
 | 
			
		||||
        frame_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
        # Write out the palette if required
 | 
			
		||||
        if format['has_palette']:
 | 
			
		||||
            palette = graphic_data[0]
 | 
			
		||||
            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, palette))
 | 
			
		||||
            vprint(f'{f"Frame {idx:3d} palette":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
            palette_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
        # Write out the delta info if required
 | 
			
		||||
        if use_delta_this_frame:
 | 
			
		||||
            # Set up the rendering location of where the delta frame should be situated
 | 
			
		||||
            delta_descriptor = QGFFrameDeltaDescriptorV1()
 | 
			
		||||
            delta_descriptor.left = location[0]
 | 
			
		||||
            delta_descriptor.top = location[1]
 | 
			
		||||
            delta_descriptor.right = location[0] + size[0]
 | 
			
		||||
            delta_descriptor.bottom = location[1] + size[1]
 | 
			
		||||
 | 
			
		||||
            # Write the delta frame to the output
 | 
			
		||||
            vprint(f'{f"Frame {idx:3d} delta":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
            delta_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
        # Write out the data for this frame to the output
 | 
			
		||||
        data_descriptor = QGFFrameDataDescriptorV1()
 | 
			
		||||
        data_descriptor.data = image_data
 | 
			
		||||
        vprint(f'{f"Frame {idx:3d} data":26s} {fp.tell():5d}d / {fp.tell():04X}h')
 | 
			
		||||
        data_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
    # Iterate over each if the input frames, writing it to the output in the process
 | 
			
		||||
    _for_all_frames(_write_frame)
 | 
			
		||||
 | 
			
		||||
    # Go back and update the graphics descriptor now that we can determine the final file size
 | 
			
		||||
    graphics_descriptor.total_file_size = fp.tell()
 | 
			
		||||
    fp.seek(graphics_descriptor_location, 0)
 | 
			
		||||
    graphics_descriptor.write(fp)
 | 
			
		||||
 | 
			
		||||
    # Go back and update the frame offsets now that they're written to the file
 | 
			
		||||
    fp.seek(frame_offset_location, 0)
 | 
			
		||||
    frame_offsets.write(fp)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
########################################################################################################################
 | 
			
		||||
 | 
			
		||||
# Register with PIL so that it knows about the QGF format
 | 
			
		||||
Image.register_open(QGFImageFile.format, QGFImageFile, _accept)
 | 
			
		||||
Image.register_save(QGFImageFile.format, _save)
 | 
			
		||||
Image.register_save_all(QGFImageFile.format, _save)
 | 
			
		||||
Image.register_extension(QGFImageFile.format, f".{QGFImageFile.format.lower()}")
 | 
			
		||||
Image.register_mime(QGFImageFile.format, f"image/{QGFImageFile.format.lower()}")
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue