diff options
Diffstat (limited to 'lib')
| m--------- | lib/chibios | 0 | ||||
| m--------- | lib/chibios-contrib | 0 | ||||
| -rw-r--r-- | lib/python/qmk/cli/__init__.py | 4 | ||||
| -rwxr-xr-x | lib/python/qmk/cli/generate/api.py | 28 | ||||
| -rwxr-xr-x | lib/python/qmk/cli/generate/config_h.py | 24 | ||||
| -rw-r--r-- | lib/python/qmk/cli/generate/docs.py | 7 | ||||
| -rwxr-xr-x | lib/python/qmk/cli/generate/rules_mk.py | 14 | ||||
| -rwxr-xr-x | lib/python/qmk/cli/info.py | 16 | ||||
| -rw-r--r-- | lib/python/qmk/cli/new/keyboard.py | 20 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/__init__.py | 2 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/convert_graphics.py | 86 | ||||
| -rw-r--r-- | lib/python/qmk/cli/painter/make_font.py | 87 | ||||
| -rw-r--r-- | lib/python/qmk/cli/pytest.py | 3 | ||||
| -rw-r--r-- | lib/python/qmk/commands.py | 2 | ||||
| -rw-r--r-- | lib/python/qmk/info.py | 134 | ||||
| -rw-r--r-- | lib/python/qmk/json_schema.py | 6 | ||||
| -rw-r--r-- | lib/python/qmk/painter.py | 268 | ||||
| -rw-r--r-- | lib/python/qmk/painter_qff.py | 401 | ||||
| -rw-r--r-- | lib/python/qmk/painter_qgf.py | 408 | ||||
| -rw-r--r-- | lib/python/qmk/tests/test_cli_commands.py | 4 | 
20 files changed, 1447 insertions, 67 deletions
| diff --git a/lib/chibios b/lib/chibios -Subproject d7b9d1c87f724bd7c8cd1486d6d0dc3ba52e0d5 +Subproject 257302333c31f1f710800c2b97acf3550de043e diff --git a/lib/chibios-contrib b/lib/chibios-contrib -Subproject d1c2126d1cd867c50127da84425805e225df855 +Subproject 2a6b73ff51baf89083a220b6692a04ca2cae875 diff --git a/lib/python/qmk/cli/__init__.py b/lib/python/qmk/cli/__init__.py index 5f65e677e5..85baa238a8 100644 --- a/lib/python/qmk/cli/__init__.py +++ b/lib/python/qmk/cli/__init__.py @@ -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', diff --git a/lib/python/qmk/cli/generate/api.py b/lib/python/qmk/cli/generate/api.py index 285bd90eb5..0596b3f22b 100755 --- a/lib/python/qmk/cli/generate/api.py +++ b/lib/python/qmk/cli/generate/api.py @@ -1,7 +1,7 @@  """This script automates the generation of the QMK API data.  """  from pathlib import Path -from shutil import copyfile +import shutil  import json  from milc import cli @@ -12,28 +12,42 @@ from qmk.json_encoders import InfoJSONEncoder  from qmk.json_schema import json_load  from qmk.keyboard import find_readme, list_keyboards +TEMPLATE_PATH = Path('data/templates/api/') +BUILD_API_PATH = Path('.build/api_data/') +  @cli.argument('-n', '--dry-run', arg_only=True, action='store_true', help="Don't write the data to disk.") +@cli.argument('-f', '--filter', arg_only=True, action='append', default=[], help="Filter the list of keyboards based on partial name matches the supplied value. May be passed multiple times.")  @cli.subcommand('Creates a new keymap for the keyboard of your choosing', hidden=False if cli.config.user.developer else True)  def generate_api(cli):      """Generates the QMK API data.      """ -    api_data_dir = Path('api_data') -    v1_dir = api_data_dir / 'v1' +    if BUILD_API_PATH.exists(): +        shutil.rmtree(BUILD_API_PATH) + +    shutil.copytree(TEMPLATE_PATH, BUILD_API_PATH) + +    v1_dir = BUILD_API_PATH / 'v1'      keyboard_all_file = v1_dir / 'keyboards.json'  # A massive JSON containing everything      keyboard_list_file = v1_dir / 'keyboard_list.json'  # A simple list of keyboard targets      keyboard_aliases_file = v1_dir / 'keyboard_aliases.json'  # A list of historical keyboard names and their new name      keyboard_metadata_file = v1_dir / 'keyboard_metadata.json'  # All the data configurator/via needs for initialization      usb_file = v1_dir / 'usb.json'  # A mapping of USB VID/PID -> keyboard target -    if not api_data_dir.exists(): -        api_data_dir.mkdir() +    # Filter down when required +    keyboard_list = list_keyboards() +    if cli.args.filter: +        kb_list = [] +        for keyboard_name in keyboard_list: +            if any(i in keyboard_name for i in cli.args.filter): +                kb_list.append(keyboard_name) +        keyboard_list = kb_list      kb_all = {}      usb_list = {}      # Generate and write keyboard specific JSON files -    for keyboard_name in list_keyboards(): +    for keyboard_name in keyboard_list:          kb_all[keyboard_name] = info_json(keyboard_name)          keyboard_dir = v1_dir / 'keyboards' / keyboard_name          keyboard_info = keyboard_dir / 'info.json' @@ -47,7 +61,7 @@ def generate_api(cli):              cli.log.debug('Wrote file %s', keyboard_info)              if keyboard_readme_src: -                copyfile(keyboard_readme_src, keyboard_readme) +                shutil.copyfile(keyboard_readme_src, keyboard_readme)                  cli.log.debug('Copied %s -> %s', keyboard_readme_src, keyboard_readme)          if 'usb' in kb_all[keyboard_name]: diff --git a/lib/python/qmk/cli/generate/config_h.py b/lib/python/qmk/cli/generate/config_h.py index fdc76b23d4..893892c479 100755 --- a/lib/python/qmk/cli/generate/config_h.py +++ b/lib/python/qmk/cli/generate/config_h.py @@ -5,10 +5,9 @@ from pathlib import Path  from dotty_dict import dotty  from milc import cli -from qmk.info import info_json -from qmk.json_schema import json_load, validate +from qmk.info import info_json, keymap_json_config +from qmk.json_schema import json_load  from qmk.keyboard import keyboard_completer, keyboard_folder -from qmk.keymap import locate_keymap  from qmk.commands import dump_lines  from qmk.path import normpath  from qmk.constants import GPL2_HEADER_C_LIKE, GENERATED_HEADER_C_LIKE @@ -84,7 +83,7 @@ def generate_config_items(kb_info_json, config_h_lines):      for config_key, info_dict in info_config_map.items():          info_key = info_dict['info_key'] -        key_type = info_dict.get('value_type', 'str') +        key_type = info_dict.get('value_type', 'raw')          to_config = info_dict.get('to_config', True)          if not to_config: @@ -95,7 +94,12 @@ def generate_config_items(kb_info_json, config_h_lines):          except KeyError:              continue -        if key_type.startswith('array'): +        if key_type.startswith('array.array'): +            config_h_lines.append('') +            config_h_lines.append(f'#ifndef {config_key}') +            config_h_lines.append(f'#   define {config_key} {{ {", ".join(["{" + ",".join(list(map(str, x))) + "}" for x in config_value])} }}') +            config_h_lines.append(f'#endif // {config_key}') +        elif key_type.startswith('array'):              config_h_lines.append('')              config_h_lines.append(f'#ifndef {config_key}')              config_h_lines.append(f'#   define {config_key} {{ {", ".join(map(str, config_value))} }}') @@ -112,6 +116,11 @@ def generate_config_items(kb_info_json, config_h_lines):                  config_h_lines.append(f'#ifndef {key}')                  config_h_lines.append(f'#   define {key} {value}')                  config_h_lines.append(f'#endif // {key}') +        elif key_type == 'str': +            config_h_lines.append('') +            config_h_lines.append(f'#ifndef {config_key}') +            config_h_lines.append(f'#   define {config_key} "{config_value}"') +            config_h_lines.append(f'#endif // {config_key}')          elif key_type == 'bcd_version':              (major, minor, revision) = config_value.split('.')              config_h_lines.append('') @@ -175,10 +184,7 @@ def generate_config_h(cli):      """      # Determine our keyboard/keymap      if cli.args.keymap: -        km = locate_keymap(cli.args.keyboard, cli.args.keymap) -        km_json = json_load(km) -        validate(km_json, 'qmk.keymap.v1') -        kb_info_json = dotty(km_json.get('config', {})) +        kb_info_json = dotty(keymap_json_config(cli.args.keyboard, cli.args.keymap))      else:          kb_info_json = dotty(info_json(cli.args.keyboard)) diff --git a/lib/python/qmk/cli/generate/docs.py b/lib/python/qmk/cli/generate/docs.py index 74112d834d..eb3099e138 100644 --- a/lib/python/qmk/cli/generate/docs.py +++ b/lib/python/qmk/cli/generate/docs.py @@ -10,6 +10,7 @@ DOCS_PATH = Path('docs/')  BUILD_PATH = Path('.build/')  BUILD_DOCS_PATH = BUILD_PATH / 'docs'  DOXYGEN_PATH = BUILD_PATH / 'doxygen' +MOXYGEN_PATH = BUILD_DOCS_PATH / 'internals'  @cli.subcommand('Build QMK documentation.', hidden=False if cli.config.user.developer else True) @@ -34,10 +35,10 @@ def generate_docs(cli):          'stdin': DEVNULL,      } -    cli.log.info('Generating internal docs...') +    cli.log.info('Generating docs...')      # Generate internal docs      cli.run(['doxygen', 'Doxyfile'], **args) -    cli.run(['moxygen', '-q', '-g', '-o', BUILD_DOCS_PATH / 'internals_%s.md', DOXYGEN_PATH / 'xml'], **args) +    cli.run(['moxygen', '-q', '-g', '-o', MOXYGEN_PATH / '%s.md', DOXYGEN_PATH / 'xml'], **args) -    cli.log.info('Successfully generated internal docs to %s.', BUILD_DOCS_PATH) +    cli.log.info('Successfully generated docs to %s.', BUILD_DOCS_PATH) diff --git a/lib/python/qmk/cli/generate/rules_mk.py b/lib/python/qmk/cli/generate/rules_mk.py index 29a1130f99..9623d00fb5 100755 --- a/lib/python/qmk/cli/generate/rules_mk.py +++ b/lib/python/qmk/cli/generate/rules_mk.py @@ -5,10 +5,9 @@ from pathlib import Path  from dotty_dict import dotty  from milc import cli -from qmk.info import info_json -from qmk.json_schema import json_load, validate +from qmk.info import info_json, keymap_json_config +from qmk.json_schema import json_load  from qmk.keyboard import keyboard_completer, keyboard_folder -from qmk.keymap import locate_keymap  from qmk.commands import dump_lines  from qmk.path import normpath  from qmk.constants import GPL2_HEADER_SH_LIKE, GENERATED_HEADER_SH_LIKE @@ -21,7 +20,7 @@ def process_mapping_rule(kb_info_json, rules_key, info_dict):          return None      info_key = info_dict['info_key'] -    key_type = info_dict.get('value_type', 'str') +    key_type = info_dict.get('value_type', 'raw')      try:          rules_value = kb_info_json[info_key] @@ -34,6 +33,8 @@ def process_mapping_rule(kb_info_json, rules_key, info_dict):          return f'{rules_key} ?= {"yes" if rules_value else "no"}'      elif key_type == 'mapping':          return '\n'.join([f'{key} ?= {value}' for key, value in rules_value.items()]) +    elif key_type == 'str': +        return f'{rules_key} ?= "{rules_value}"'      return f'{rules_key} ?= {rules_value}' @@ -49,10 +50,7 @@ def generate_rules_mk(cli):      """      # Determine our keyboard/keymap      if cli.args.keymap: -        km = locate_keymap(cli.args.keyboard, cli.args.keymap) -        km_json = json_load(km) -        validate(km_json, 'qmk.keymap.v1') -        kb_info_json = dotty(km_json.get('config', {})) +        kb_info_json = dotty(keymap_json_config(cli.args.keyboard, cli.args.keymap))      else:          kb_info_json = dotty(info_json(cli.args.keyboard)) diff --git a/lib/python/qmk/cli/info.py b/lib/python/qmk/cli/info.py index 3131d4b53f..fa5729bcc9 100755 --- a/lib/python/qmk/cli/info.py +++ b/lib/python/qmk/cli/info.py @@ -11,8 +11,8 @@ from qmk.json_encoders import InfoJSONEncoder  from qmk.constants import COL_LETTERS, ROW_LETTERS  from qmk.decorators import automagic_keyboard, automagic_keymap  from qmk.keyboard import keyboard_completer, keyboard_folder, render_layouts, render_layout, rules_mk +from qmk.info import info_json, keymap_json  from qmk.keymap import locate_keymap -from qmk.info import info_json  from qmk.path import is_keyboard  UNICODE_SUPPORT = sys.stdout.encoding.lower().startswith('utf') @@ -135,7 +135,7 @@ def print_parsed_rules_mk(keyboard_name):  @cli.argument('-kb', '--keyboard', type=keyboard_folder, completer=keyboard_completer, help='Keyboard to show info for.') -@cli.argument('-km', '--keymap', help='Show the layers for a JSON keymap too.') +@cli.argument('-km', '--keymap', help='Keymap to show info for (Optional).')  @cli.argument('-l', '--layouts', action='store_true', help='Render the layouts.')  @cli.argument('-m', '--matrix', action='store_true', help='Render the layouts with matrix information.')  @cli.argument('-f', '--format', default='friendly', arg_only=True, help='Format to display the data in (friendly, text, json) (Default: friendly).') @@ -161,8 +161,15 @@ def info(cli):          print_parsed_rules_mk(cli.config.info.keyboard)          return False +    # default keymap stored in config file should be ignored +    if cli.config_source.info.keymap == 'config_file': +        cli.config_source.info.keymap = None +      # Build the info.json file -    kb_info_json = info_json(cli.config.info.keyboard) +    if cli.config.info.keymap: +        kb_info_json = keymap_json(cli.config.info.keyboard, cli.config.info.keymap) +    else: +        kb_info_json = info_json(cli.config.info.keyboard)      # Output in the requested format      if cli.args.format == 'json': @@ -178,11 +185,12 @@ def info(cli):          cli.log.error('Unknown format: %s', cli.args.format)          return False +    # Output requested extras      if cli.config.info.layouts:          show_layouts(kb_info_json, title_caps)      if cli.config.info.matrix:          show_matrix(kb_info_json, title_caps) -    if cli.config_source.info.keymap and cli.config_source.info.keymap != 'config_file': +    if cli.config.info.keymap:          show_keymap(kb_info_json, title_caps) diff --git a/lib/python/qmk/cli/new/keyboard.py b/lib/python/qmk/cli/new/keyboard.py index 1cdfe53206..8d4def1bef 100644 --- a/lib/python/qmk/cli/new/keyboard.py +++ b/lib/python/qmk/cli/new/keyboard.py @@ -14,7 +14,7 @@ from qmk.git import git_get_username  from qmk.json_schema import load_jsonschema  from qmk.path import keyboard  from qmk.json_encoders import InfoJSONEncoder -from qmk.json_schema import deep_update +from qmk.json_schema import deep_update, json_load  from qmk.constants import MCU2BOOTLOADER  COMMUNITY = Path('layouts/default/') @@ -23,13 +23,14 @@ TEMPLATE = Path('data/templates/keyboard/')  # defaults  schema = dotty(load_jsonschema('keyboard'))  mcu_types = sorted(schema["properties.processor.enum"], key=str.casefold) +dev_boards = sorted(schema["properties.development_board.enum"], key=str.casefold)  available_layouts = sorted([x.name for x in COMMUNITY.iterdir() if x.is_dir()])  def mcu_type(mcu):      """Callable for argparse validation.      """ -    if mcu not in mcu_types: +    if mcu not in (dev_boards + mcu_types):          raise ValueError      return mcu @@ -176,14 +177,14 @@ https://docs.qmk.fm/#/compatible_microcontrollers  MCU? """      # remove any options strictly used for compatibility -    filtered_mcu = [x for x in mcu_types if not any(xs in x for xs in ['cortex', 'unknown'])] +    filtered_mcu = [x for x in (dev_boards + mcu_types) if not any(xs in x for xs in ['cortex', 'unknown'])]      return choice(prompt, filtered_mcu, default=filtered_mcu.index("atmega32u4"))  @cli.argument('-kb', '--keyboard', help='Specify the name for the new keyboard directory', arg_only=True, type=keyboard_name)  @cli.argument('-l', '--layout', help='Community layout to bootstrap with', arg_only=True, type=layout_type) -@cli.argument('-t', '--type', help='Specify the keyboard MCU type', arg_only=True, type=mcu_type) +@cli.argument('-t', '--type', help='Specify the keyboard MCU type (or "development_board" preset)', arg_only=True, type=mcu_type)  @cli.argument('-u', '--username', help='Specify your username (default from Git config)', dest='name')  @cli.argument('-n', '--realname', help='Specify your real name if you want to use that. Defaults to username', arg_only=True)  @cli.subcommand('Creates a new keyboard directory') @@ -198,7 +199,6 @@ def new_keyboard(cli):      real_name = cli.args.realname or cli.config.new_keyboard.name if cli.args.realname or cli.config.new_keyboard.name else prompt_name(user_name)      default_layout = cli.args.layout if cli.args.layout else prompt_layout()      mcu = cli.args.type if cli.args.type else prompt_mcu() -    bootloader = select_default_bootloader(mcu)      if not validate_keyboard_name(kb_name):          cli.log.error('Keyboard names must contain only {fg_cyan}lowercase a-z{fg_reset}, {fg_cyan}0-9{fg_reset}, and {fg_cyan}_{fg_reset}! Please choose a different name.') @@ -208,6 +208,16 @@ def new_keyboard(cli):          cli.log.error(f'Keyboard {{fg_cyan}}{kb_name}{{fg_reset}} already exists! Please choose a different name.')          return 1 +    # Preprocess any development_board presets +    if mcu in dev_boards: +        defaults_map = json_load(Path('data/mappings/defaults.json')) +        board = defaults_map['development_board'][mcu] + +        mcu = board['processor'] +        bootloader = board['bootloader'] +    else: +        bootloader = select_default_bootloader(mcu) +      tokens = {  # Comment here is to force multiline formatting          'YEAR': str(date.today().year),          'KEYBOARD': kb_name, diff --git a/lib/python/qmk/cli/painter/__init__.py b/lib/python/qmk/cli/painter/__init__.py new file mode 100644 index 0000000000..d1a225346c --- /dev/null +++ b/lib/python/qmk/cli/painter/__init__.py @@ -0,0 +1,2 @@ +from . import convert_graphics +from . import make_font diff --git a/lib/python/qmk/cli/painter/convert_graphics.py b/lib/python/qmk/cli/painter/convert_graphics.py new file mode 100644 index 0000000000..bbc30d26ff --- /dev/null +++ b/lib/python/qmk/cli/painter/convert_graphics.py @@ -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() diff --git a/lib/python/qmk/cli/painter/make_font.py b/lib/python/qmk/cli/painter/make_font.py new file mode 100644 index 0000000000..0762843fd3 --- /dev/null +++ b/lib/python/qmk/cli/painter/make_font.py @@ -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() diff --git a/lib/python/qmk/cli/pytest.py b/lib/python/qmk/cli/pytest.py index b7b17f0e9d..5c9c173caa 100644 --- a/lib/python/qmk/cli/pytest.py +++ b/lib/python/qmk/cli/pytest.py @@ -12,8 +12,7 @@ from milc import cli  def pytest(cli):      """Run several linting/testing commands.      """ -    nose2 = cli.run(['nose2', '-v', '-t' -                     'lib/python', *cli.args.test], capture_output=False, stdin=DEVNULL) +    nose2 = cli.run(['nose2', '-v', '-t', 'lib/python', *cli.args.test], capture_output=False, stdin=DEVNULL)      flake8 = cli.run(['flake8', 'lib/python'], capture_output=False, stdin=DEVNULL)      return flake8.returncode | nose2.returncode diff --git a/lib/python/qmk/commands.py b/lib/python/qmk/commands.py index d0bd00beb7..9c0a5dce56 100644 --- a/lib/python/qmk/commands.py +++ b/lib/python/qmk/commands.py @@ -228,7 +228,7 @@ def dump_lines(output_file, lines, quiet=True):          output_file.parent.mkdir(parents=True, exist_ok=True)          if output_file.exists():              output_file.replace(output_file.parent / (output_file.name + '.bak')) -        output_file.write_text(generated) +        output_file.write_text(generated, encoding='utf-8')          if not quiet:              cli.log.info(f'Wrote {output_file.name} to {output_file}.') diff --git a/lib/python/qmk/info.py b/lib/python/qmk/info.py index fd8a3062b7..49d1054519 100644 --- a/lib/python/qmk/info.py +++ b/lib/python/qmk/info.py @@ -8,10 +8,11 @@ from dotty_dict import dotty  from milc import cli  from qmk.constants import CHIBIOS_PROCESSORS, LUFA_PROCESSORS, VUSB_PROCESSORS -from qmk.c_parse import find_layouts +from qmk.c_parse import find_layouts, parse_config_h_file  from qmk.json_schema import deep_update, json_load, validate  from qmk.keyboard import config_h, rules_mk -from qmk.keymap import list_keymaps +from qmk.keymap import list_keymaps, locate_keymap +from qmk.commands import parse_configurator_json  from qmk.makefile import parse_rules_mk_file  from qmk.math import compute @@ -68,8 +69,9 @@ def info_json(keyboard):      # Merge in the data from info.json, config.h, and rules.mk      info_data = merge_info_jsons(keyboard, info_data) -    info_data = _extract_rules_mk(info_data) -    info_data = _extract_config_h(info_data) +    info_data = _process_defaults(info_data) +    info_data = _extract_rules_mk(info_data, rules_mk(str(keyboard))) +    info_data = _extract_config_h(info_data, config_h(str(keyboard)))      # Ensure that we have matrix row and column counts      info_data = _matrix_size(info_data) @@ -166,28 +168,46 @@ def _extract_pins(pins):      return [_pin_name(pin) for pin in pins.split(',')] -def _extract_direct_matrix(direct_pins): -    """ +def _extract_2d_array(raw): +    """Return a 2d array of strings      """ -    direct_pin_array = [] +    out_array = [] -    while direct_pins[-1] != '}': -        direct_pins = direct_pins[:-1] +    while raw[-1] != '}': +        raw = raw[:-1] -    for row in direct_pins.split('},{'): +    for row in raw.split('},{'):          if row.startswith('{'):              row = row[1:]          if row.endswith('}'):              row = row[:-1] -        direct_pin_array.append([]) +        out_array.append([]) + +        for val in row.split(','): +            out_array[-1].append(val) + +    return out_array + + +def _extract_2d_int_array(raw): +    """Return a 2d array of ints +    """ +    ret = _extract_2d_array(raw) + +    return [list(map(int, x)) for x in ret] + -        for pin in row.split(','): -            if pin == 'NO_PIN': -                pin = None +def _extract_direct_matrix(direct_pins): +    """extract direct_matrix +    """ +    direct_pin_array = _extract_2d_array(direct_pins) -            direct_pin_array[-1].append(pin) +    for i in range(len(direct_pin_array)): +        for j in range(len(direct_pin_array[i])): +            if direct_pin_array[i][j] == 'NO_PIN': +                direct_pin_array[i][j] = None      return direct_pin_array @@ -205,6 +225,21 @@ def _extract_audio(info_data, config_c):          info_data['audio'] = {'pins': audio_pins} +def _extract_secure_unlock(info_data, config_c): +    """Populate data about the secure unlock sequence +    """ +    unlock = config_c.get('SECURE_UNLOCK_SEQUENCE', '').replace(' ', '')[1:-1] +    if unlock: +        unlock_array = _extract_2d_int_array(unlock) +        if 'secure' not in info_data: +            info_data['secure'] = {} + +        if 'unlock_sequence' in info_data['secure']: +            _log_warning(info_data, 'Secure unlock sequence is specified in both config.h (SECURE_UNLOCK_SEQUENCE) and info.json (secure.unlock_sequence) (Value: %s), the config.h value wins.' % info_data['secure']['unlock_sequence']) + +        info_data['secure']['unlock_sequence'] = unlock_array + +  def _extract_split_main(info_data, config_c):      """Populate data about the split configuration      """ @@ -270,14 +305,16 @@ def _extract_split_transport(info_data, config_c):          info_data['split']['transport']['protocol'] = 'i2c' -    elif 'protocol' not in info_data.get('split', {}).get('transport', {}): +    # Ignore transport defaults if "SPLIT_KEYBOARD" is unset +    elif 'enabled' in info_data.get('split', {}):          if 'split' not in info_data:              info_data['split'] = {}          if 'transport' not in info_data['split']:              info_data['split']['transport'] = {} -        info_data['split']['transport']['protocol'] = 'serial' +        if 'protocol' not in info_data['split']['transport']: +            info_data['split']['transport']['protocol'] = 'serial'  def _extract_split_right_pins(info_data, config_c): @@ -400,18 +437,16 @@ def _extract_device_version(info_data):              info_data['usb']['device_version'] = f'{major}.{minor}.{revision}' -def _extract_config_h(info_data): +def _extract_config_h(info_data, config_c):      """Pull some keyboard information from existing config.h files      """ -    config_c = config_h(info_data['keyboard_folder']) -      # Pull in data from the json map      dotty_info = dotty(info_data)      info_config_map = json_load(Path('data/mappings/info_config.json'))      for config_key, info_dict in info_config_map.items():          info_key = info_dict['info_key'] -        key_type = info_dict.get('value_type', 'str') +        key_type = info_dict.get('value_type', 'raw')          try:              if config_key in config_c and info_dict.get('to_json', True): @@ -443,6 +478,9 @@ def _extract_config_h(info_data):                  elif key_type == 'int':                      dotty_info[info_key] = int(config_c[config_key]) +                elif key_type == 'str': +                    dotty_info[info_key] = config_c[config_key].strip('"') +                  elif key_type == 'bcd_version':                      major = int(config_c[config_key][2:4])                      minor = int(config_c[config_key][4]) @@ -461,6 +499,7 @@ def _extract_config_h(info_data):      # Pull data that easily can't be mapped in json      _extract_matrix_info(info_data, config_c)      _extract_audio(info_data, config_c) +    _extract_secure_unlock(info_data, config_c)      _extract_split_main(info_data, config_c)      _extract_split_transport(info_data, config_c)      _extract_split_right_pins(info_data, config_c) @@ -469,10 +508,21 @@ def _extract_config_h(info_data):      return info_data -def _extract_rules_mk(info_data): +def _process_defaults(info_data): +    """Process any additional defaults based on currently discovered information +    """ +    defaults_map = json_load(Path('data/mappings/defaults.json')) +    for default_type in defaults_map.keys(): +        thing_map = defaults_map[default_type] +        if default_type in info_data: +            for key, value in thing_map.get(info_data[default_type], {}).items(): +                info_data[key] = value +    return info_data + + +def _extract_rules_mk(info_data, rules):      """Pull some keyboard information from existing rules.mk files      """ -    rules = rules_mk(info_data['keyboard_folder'])      info_data['processor'] = rules.get('MCU', info_data.get('processor', 'atmega32u4'))      if info_data['processor'] in CHIBIOS_PROCESSORS: @@ -491,7 +541,7 @@ def _extract_rules_mk(info_data):      for rules_key, info_dict in info_rules_map.items():          info_key = info_dict['info_key'] -        key_type = info_dict.get('value_type', 'str') +        key_type = info_dict.get('value_type', 'raw')          try:              if rules_key in rules and info_dict.get('to_json', True): @@ -523,6 +573,9 @@ def _extract_rules_mk(info_data):                  elif key_type == 'int':                      dotty_info[info_key] = int(rules[rules_key]) +                elif key_type == 'str': +                    dotty_info[info_key] = rules[rules_key].strip('"') +                  else:                      dotty_info[info_key] = rules[rules_key] @@ -760,3 +813,36 @@ def find_info_json(keyboard):      # Return a list of the info.json files that actually exist      return [info_json for info_json in info_jsons if info_json.exists()] + + +def keymap_json_config(keyboard, keymap): +    """Extract keymap level config +    """ +    keymap_folder = locate_keymap(keyboard, keymap).parent + +    km_info_json = parse_configurator_json(keymap_folder / 'keymap.json') +    return km_info_json.get('config', {}) + + +def keymap_json(keyboard, keymap): +    """Generate the info.json data for a specific keymap. +    """ +    keymap_folder = locate_keymap(keyboard, keymap).parent + +    # Files to scan +    keymap_config = keymap_folder / 'config.h' +    keymap_rules = keymap_folder / 'rules.mk' +    keymap_file = keymap_folder / 'keymap.json' + +    # Build the info.json file +    kb_info_json = info_json(keyboard) + +    # Merge in the data from keymap.json +    km_info_json = keymap_json_config(keyboard, keymap) if keymap_file.exists() else {} +    deep_update(kb_info_json, km_info_json) + +    # Merge in the data from config.h, and rules.mk +    _extract_rules_mk(kb_info_json, parse_rules_mk_file(keymap_rules)) +    _extract_config_h(kb_info_json, parse_config_h_file(keymap_config)) + +    return kb_info_json diff --git a/lib/python/qmk/json_schema.py b/lib/python/qmk/json_schema.py index 2b48782fbb..682346113e 100644 --- a/lib/python/qmk/json_schema.py +++ b/lib/python/qmk/json_schema.py @@ -68,7 +68,11 @@ def create_validator(schema):      schema_store = compile_schema_store()      resolver = jsonschema.RefResolver.from_schema(schema_store[schema], store=schema_store) -    return jsonschema.Draft7Validator(schema_store[schema], resolver=resolver).validate +    # TODO: Remove this after the jsonschema>=4 requirement had time to reach users +    try: +        return jsonschema.Draft202012Validator(schema_store[schema], resolver=resolver).validate +    except AttributeError: +        return jsonschema.Draft7Validator(schema_store[schema], resolver=resolver).validate  def validate(data, schema): diff --git a/lib/python/qmk/painter.py b/lib/python/qmk/painter.py new file mode 100644 index 0000000000..d0cc1dddec --- /dev/null +++ b/lib/python/qmk/painter.py @@ -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 diff --git a/lib/python/qmk/painter_qff.py b/lib/python/qmk/painter_qff.py new file mode 100644 index 0000000000..746bb166e5 --- /dev/null +++ b/lib/python/qmk/painter_qff.py @@ -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) diff --git a/lib/python/qmk/painter_qgf.py b/lib/python/qmk/painter_qgf.py new file mode 100644 index 0000000000..71ce1f5a02 --- /dev/null +++ b/lib/python/qmk/painter_qgf.py @@ -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()}") diff --git a/lib/python/qmk/tests/test_cli_commands.py b/lib/python/qmk/tests/test_cli_commands.py index 55e69175e6..d40d4bf573 100644 --- a/lib/python/qmk/tests/test_cli_commands.py +++ b/lib/python/qmk/tests/test_cli_commands.py @@ -244,7 +244,7 @@ def test_clean():  def test_generate_api(): -    result = check_subcommand('generate-api', '--dry-run') +    result = check_subcommand('generate-api', '--dry-run', '--filter', 'handwired/pytest')      check_returncode(result) @@ -259,7 +259,7 @@ def test_generate_config_h():      result = check_subcommand('generate-config-h', '-kb', 'handwired/pytest/basic')      check_returncode(result)      assert '#   define DEVICE_VER 0x0001' in result.stdout -    assert '#   define DESCRIPTION handwired/pytest/basic' in result.stdout +    assert '#   define DESCRIPTION "handwired/pytest/basic"' in result.stdout      assert '#   define DIODE_DIRECTION COL2ROW' in result.stdout      assert '#   define MANUFACTURER none' in result.stdout      assert '#   define PRODUCT pytest' in result.stdout | 
