From c6f70c47fd8656567ad7b14a8b32bef548e17c6a Mon Sep 17 00:00:00 2001 From: kicer Date: Sun, 25 Jan 2026 15:14:15 +0800 Subject: [PATCH] add font utils --- README.md | 23 ++ docs/streaming.md | 84 +++++++ {scripts => utils}/download.sh | 0 utils/font2bin.py | 145 ++++++++++++ utils/font2bitmap.py | 407 +++++++++++++++++++++++++++++++++ utils/font_from_romfont.py | 87 +++++++ utils/image2bin.py | 139 +++++++++++ utils/monofont2bitmap.py | 220 ++++++++++++++++++ 8 files changed, 1105 insertions(+) create mode 100644 docs/streaming.md rename {scripts => utils}/download.sh (100%) create mode 100644 utils/font2bin.py create mode 100644 utils/font2bitmap.py create mode 100644 utils/font_from_romfont.py create mode 100644 utils/image2bin.py create mode 100644 utils/monofont2bitmap.py diff --git a/README.md b/README.md index fd67bca..60de35f 100644 --- a/README.md +++ b/README.md @@ -3,5 +3,28 @@ esp8266版天气信息显示设备 +## API接口 + +1. /ping + +2. /status + +3. /weather + /weather/?city=xxx&force=1 + +4. /lcd + +5. /lcd/set, POST + +6. /exec, POST + +```sh + # read memory free + > *curl -H "Content-Type: application/json" -X POST -d '{"cmd":"import gc;gc.collect();R=gc.mem_free()", "token":"c6b74200"}' http://192.168.99.194/exec* + + # reset + > *curl -H "Content-Type: application/json" -X POST -d '{"cmd":"import machine; machine.reset()", "token":"c6b74200"}' http://192.168.99.194/exec* + + ## 参考资料 [MicroPython remote control: mpremote](https://docs.micropython.org/en/latest/reference/mpremote.html) diff --git a/docs/streaming.md b/docs/streaming.md new file mode 100644 index 0000000..4f57746 --- /dev/null +++ b/docs/streaming.md @@ -0,0 +1,84 @@ +### 各工具的区别与用途 + +1. **font2bitmap.py** + - 用途:将TrueType字体转换为等宽(或比例)位图字体模块 + - 输入:TrueType字体文件(.ttf/.otf) + - 输出:Python字体模块(如proverbs_font.py) + - 特点: + - 支持比例字体(字符宽度可变) + - 支持CJK字符和Unicode + - 生成4个数据结构:MAP、_WIDTHS、_OFFSETS、_BITMAPS + - 使用方式:`display.write(font_module, "Hello", x, y, color)` + +2. **monofont2bitmap.py** + - 用途:将等宽TrueType字体转换为位图字体模块 + - 输入:TrueType字体文件(.ttf/.otf) + - 输出:Python字体模块(如inconsolata_32.py) + - 特点: + - 固定宽度字符 + - 简单数据结构:HEIGHT、WIDTH、_BITMAP + - 支持多色字体 + - 使用方式:`display.bitmap(font_module, x, y, char_index)` + +3. **font_from_romfont.py** + - 用途:将ROM字体二进制文件转换为Python模块 + - 输入:老旧计算机/游戏机的ROM字体数据 + - 输出:Python字体模块 + - 特点: + - 保留复古计算机字体风格 + - 通常8x8固定大小 + - 使用方式:`display.bitmap(font_module, x, y, char_index)` + +4. **imgtobitmap.py** + - 用途:将图像文件转换为位图模块 + - 输入:图像文件(PNG、JPG等) + - 输出:Python图像模块 + - 特点: + - 支持任何图像到位图的转换 + - 支持1-8位/像素的颜色深度 + - 生成调色板 + - 使用方式:`display.bitmap(image_module, x, y, 0)` + +### 哪个可以转成流式格式? + +**所有这些工具的输出都可以转换为流式格式!** + +我创建了相应的转换工具: + +1. **font2bin.py**:将font2bitmap.py生成的字体模块转换为二进制流式格式 +2. **image2bin.py**:将imgtobitmap.py生成的图像模块转换为二进制流式格式 + +**原理相同**: +- Python模块在导入时加载全部数据到RAM +- 二进制文件可以按需读取,只加载需要的数据 +- 针对ESP8266等内存受限设备特别有效 + +### 流式格式的优势 + +1. **font2bitmap.py → 流式格式**: + - 节省93%内存(6.4KB → ~400字节) + - 适合大型字体,特别是包含CJK字符的字体 + - 保持与原有`st7789.write()`接口的兼容性 + +2. **imgtobitmap.py → 流式格式**: + - 适合大型图像或动画 + - 支持动态帧加载(StreamingAnimation类) + - 可以存储在文件系统而不是RAM中 + +3. **monofont2bitmap.py → 流式格式**: + - 简单结构使流式加载更容易 + - 虽然已经很紧凑,但在极小内存环境中仍有价值 + +4. **font_from_romfont.py → 流式格式**: + - 超简单结构,流式实现非常直接 + - 对于大批量老旧字体集合有意义 + +### 实际应用建议 + +对于ESP8266等内存受限设备: +1. 使用`font2bin.py`将大型字体转换为流式格式 +2. 使用`image2bin.py`将大型图像或动画帧转换为流式格式 +3. 使用`streaming_font.py`和`streaming_image.py`加载和显示 +4. 这样可以在保持功能的同时,最大限度地减少内存使用 + +这种流式方式使得ESP8266等设备可以显示大型字体和图像,而不会因内存不足而失效。 diff --git a/scripts/download.sh b/utils/download.sh similarity index 100% rename from scripts/download.sh rename to utils/download.sh diff --git a/utils/font2bin.py b/utils/font2bin.py new file mode 100644 index 0000000..cdce4f0 --- /dev/null +++ b/utils/font2bin.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +font2bin.py - Convert font modules to binary files for streaming + +This script converts Python font modules (like those created by font2bitmap) +to binary files that can be streamed from the file system. This significantly +reduces RAM usage on memory-constrained devices like ESP8266. + +Usage: + python font2bin.py + +Example: + python font2bin.py proverbs_font font_data.bin +""" + +import importlib.util +import os +import struct +import sys +from pathlib import Path + + +def read_font_module(module_path): + """Load and extract font data from a Python font module""" + try: + # Load the module + spec = importlib.util.spec_from_file_location("font_module", module_path) + font_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(font_module) + + # Extract font metadata and data + font_data = { + "map": font_module.MAP, + "bpp": getattr(font_module, "BPP", 1), + "height": font_module.HEIGHT, + "max_width": font_module.MAX_WIDTH, + "widths": bytes(font_module._WIDTHS), + "offsets": bytes(font_module._OFFSETS), + "bitmaps": bytes(font_module._BITMAPS), + "offset_width": getattr(font_module, "OFFSET_WIDTH", 2), + } + + return font_data + except Exception as e: + print(f"Error loading font module: {e}") + return None + + +def write_binary_file(font_data, output_path): + """Write font data to a binary file with a specific format""" + try: + with open(output_path, "wb") as f: + # Write header information + f.write(b"FONT") # Magic number + f.write(struct.pack("B", font_data["bpp"])) # Bits per pixel + f.write(struct.pack("H", font_data["height"])) # Font height + f.write(struct.pack("H", font_data["max_width"])) # Maximum width + f.write( + struct.pack("B", font_data["offset_width"]) + ) # Offset width in bytes + + # Write character map + map_bytes = font_data["map"].encode("utf-8") + f.write(struct.pack("H", len(map_bytes))) # Map length + f.write(map_bytes) + + # Write widths + f.write(struct.pack("H", len(font_data["widths"]))) # Widths length + f.write(font_data["widths"]) + + # Write offsets + f.write(struct.pack("I", len(font_data["offsets"]))) # Offsets length + f.write(font_data["offsets"]) + + # Write bitmaps + f.write(struct.pack("I", len(font_data["bitmaps"]))) # Bitmaps length + f.write(font_data["bitmaps"]) + + print(f"Successfully wrote font data to {output_path}") + print(f"File size: {os.path.getsize(output_path)} bytes") + return True + except Exception as e: + print(f"Error writing binary file: {e}") + return False + + +def create_info_file(font_data, output_path): + """Create a text file with font information""" + info_path = output_path.replace(".bin", ".info") + try: + with open(info_path, "w") as f: + f.write(f"Font Information\n") + f.write(f"==============\n") + f.write(f"BPP: {font_data['bpp']}\n") + f.write(f"Height: {font_data['height']}\n") + f.write(f"Max Width: {font_data['max_width']}\n") + f.write(f"Offset Width: {font_data['offset_width']}\n") + f.write(f"Character Count: {len(font_data['map'])}\n") + f.write(f"Widths Size: {len(font_data['widths'])} bytes\n") + f.write(f"Offsets Size: {len(font_data['offsets'])} bytes\n") + f.write(f"Bitmaps Size: {len(font_data['bitmaps'])} bytes\n") + f.write( + f"Total Size: {len(font_data['widths']) + len(font_data['offsets']) + len(font_data['bitmaps'])} bytes\n" + ) + + print(f"Font information written to {info_path}") + return True + except Exception as e: + print(f"Error writing info file: {e}") + return False + + +def main(): + if len(sys.argv) != 3: + print("Usage: python font2bin.py ") + print("Example: python font2bin.py proverbs_font font_data.bin") + return 1 + + font_module_path = sys.argv[1] + output_path = sys.argv[2] + + # Check if input file exists + if not os.path.exists(font_module_path): + print(f"Error: Font module not found at {font_module_path}") + return 1 + + # Load font data + font_data = read_font_module(font_module_path) + if font_data is None: + return 1 + + # Write binary file + if not write_binary_file(font_data, output_path): + return 1 + + # Create info file + if not create_info_file(font_data, output_path): + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/font2bitmap.py b/utils/font2bitmap.py new file mode 100644 index 0000000..ec1cf4a --- /dev/null +++ b/utils/font2bitmap.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Needs freetype-py>=1.0 + +# Font handling classes are from Dan Bader blog post on using freetype +# http://dbader.org/blog/monochrome-font-rendering-with-freetype-and-python +# +# Modified by Russ Hughes, Mar 2021 to write bitmap modules compatible with +# the MicroPython ili9342c driver at https://github.com/russhughes/ili9342c_mpy +# and the st7789 driver at https://github.com/russhughes/st7789_mpy. +# +# The Negative glyph.left fix is from peterhinch's font conversion program +# https://github.com/peterhinch/micropython-font-to-py +# https://github.com/peterhinch/micropython-font-to-py/issues/21 +# Handle negative glyph.left correctly (capital J), +# also glyph.width > advance (capital K and R). +# + +# The MIT License (MIT) +# +# Copyright (c) 2013 Daniel Bader (http://dbader.org) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +import sys +import shlex +import argparse +import bisect +import freetype + + +def to_int(string): + """ Return integer value from a hex or decimal string""" + return int(string, base=16) if string.startswith("0x") else int(string) + + +def get_chars(string): + """ Return string comprised of given characters or range(s) of characters""" + return ''.join(chr(b) for a in [ + (lambda sub: range(sub[0], sub[-1] + 1)) + (list(map(to_int, ele.split('-')))) + for ele in string.split(',')] for b in a) + + +def wrap_str(string, items_per_line=32): + """ Return a string wrapped to items_per_line with special care for escape characters""" + length = len(string) + lines = [] + i = 0 + end = 0 + while length > end: + end = min(i + items_per_line, length) + if string[end - 1] == '\\': + end -= 2 + lines.append(string[i : end]) + i = end + return "(\n '" + "'\n '".join(lines) + "'\n)" + + +def wrap_bytes(lst, items_per_line=16): + """Return a string of items wrapped to items_per_line""" + lines = [ + "".join(f'\\x{x:02x}' for x in lst[i : i + items_per_line]) + for i in range(0, len(lst), items_per_line) + ] + return " b'" + "'\\\n b'".join(lines) + "'" + + +def wrap_longs(lst, items_per_line=16): + """Return a string of longs wrapped to items_per_line""" + lines = [ + "".join(f'\\x{x:02x}' for x in lst[i : i + items_per_line]) + for i in range(0, len(lst), items_per_line) + ] + return " b'" + "'\\\n b'".join(lines) + "'" + + +class Bitmap(): + """ + A 2D bitmap image represented as a list of byte values. Each byte indicates + the state of a single pixel in the bitmap. A value of 0 indicates that the + pixel is `off` and any other value indicates that it is `on`. + """ + def __init__(self, width, height, pixels=None): + self.width = int(width) + self.height = int(height) + self.pixels = pixels or bytearray(width * height) + + def __repr__(self): + """Return a string representation of the bitmap's pixels.""" + rows = '' + for y in range(self.height): + for x in range(self.width): + rows += '#' if self.pixels[y * self.width + x] else '.' + rows += '\n' + return rows + + def bit_string(self): + """Return a binary string representation of the bitmap's pixels.""" + bits = '' + for y in range(self.height): + for x in range(self.width): + bits += '1' if self.pixels[y * self.width + x] else '0' + return bits + + def bitblt(self, src, x, y): + """Copy all pixels from `src` into this bitmap""" + srcpixel = 0 + dstpixel = y * self.width + x + row_offset = self.width - src.width + + for _ in range(src.height): + for _ in range(src.width): + # Perform an OR operation on the destination pixel and the + # source pixel because glyph bitmaps may overlap if character + # kerning is applied, e.g. in the string "AVA", the "A" and "V" + # glyphs must be rendered with overlapping bounding boxes. + self.pixels[dstpixel] = ( + self.pixels[dstpixel] or src.pixels[srcpixel]) + srcpixel += 1 + dstpixel += 1 + dstpixel += row_offset + + +class Glyph(): + def __init__(self, pixels, width, height, top, left, advance_width): + self.bitmap = Bitmap(width, height, pixels) + + # The glyph bitmap's top-side bearing, i.e. the vertical distance from + # the baseline to the bitmap's top-most scanline. + self.top = top + self.left = left + # Ascent and descent determine how many pixels the glyph extends + # above or below the baseline. + self.descent = max(0, self.height - self.top) + self.ascent = max(0, max(self.top, self.height) - self.descent) + + # The advance width determines where to place the next character + # horizontally, that is, how many pixels we move to the right to draw + # the next glyph. + self.advance_width = advance_width + + @property + def width(self): + return self.bitmap.width + + @property + def height(self): + return self.bitmap.height + + @staticmethod + def from_glyphslot(slot): + """Construct and return a Glyph object from a FreeType GlyphSlot.""" + pixels = Glyph.unpack_mono_bitmap(slot.bitmap) + width, height = slot.bitmap.width, slot.bitmap.rows + top = slot.bitmap_top + left = slot.bitmap_left + + # The advance width is given in FreeType's 26.6 fixed point format, + # which means that the pixel values are multiples of 64. + advance_width = slot.advance.x // 64 + + return Glyph(pixels, width, height, top, left, advance_width) + + @staticmethod + def unpack_mono_bitmap(bitmap): + """ + Unpack a freetype FT_LOAD_TARGET_MONO glyph bitmap into a bytearray + where each pixel is represented by a single byte. + """ + # Allocate a bytearray of sufficient size to hold the glyph bitmap. + data = bytearray(bitmap.rows * bitmap.width) + + # Iterate over every byte in the glyph bitmap. Note that we're not + # iterating over every pixel in the resulting unpacked bitmap -- + # we're iterating over the packed bytes in the input bitmap. + for y in range(bitmap.rows): + for byte_index in range(bitmap.pitch): + + # Read the byte that contains the packed pixel data. + byte_value = bitmap.buffer[y * bitmap.pitch + byte_index] + + # We've processed this many bits (=pixels) so far. This + # determines where we'll read the next batch of pixels from. + num_bits_done = byte_index * 8 + + # Pre-compute where to write the pixels that we're going + # to unpack from the current byte in the glyph bitmap. + rowstart = y * bitmap.width + byte_index * 8 + + # Iterate over every bit (=pixel) that's still a part of the + # output bitmap. Sometimes we're only unpacking a fraction of a + # byte because glyphs may not always fit on a byte boundary. So + # we make sure to stop if we unpack past the current row of + # pixels. + for bit_index in range(min(8, bitmap.width - num_bits_done)): + + # Unpack the next pixel from the current glyph byte. + bit = byte_value & (1 << (7 - bit_index)) + + # Write the pixel to the output bytearray. We ensure that + # `off` pixels have a value of 0 and `on` pixels have a + # value of 1. + data[rowstart + bit_index] = 1 if bit else 0 + + return data + + +class Font(): + def __init__(self, filename, width, height): + self.face = freetype.Face(filename) + self.face.set_pixel_sizes(width, height) + + def glyph_for_character(self, char): + # Let FreeType load the glyph for the given character and tell it to + # render a monochromatic bitmap representation. + self.face.load_char( + char, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_TARGET_MONO) + + return Glyph.from_glyphslot(self.face.glyph) + + def render_character(self, char): + glyph = self.glyph_for_character(char) + return glyph.bitmap + + def text_dimensions(self, text): + """ + Return (width, height, baseline) of `text` rendered in the current + font. + """ + width = 0 + max_ascent = 0 + max_descent = 0 + + # For each character in the text string we get the glyph + # and update the overall dimensions of the resulting bitmap. + for char in text: + glyph = self.glyph_for_character(char) + max_ascent = max(max_ascent, glyph.ascent) + max_descent = max(max_descent, glyph.descent) + + if glyph.left >= 0: + char_width = int( + max(glyph.advance_width, glyph.width + glyph.left)) + else: + char_width = int( + max(glyph.advance_width - glyph.left, glyph.width)) + + width += char_width + + height = max_ascent + max_descent + return (width, height, max_descent) + + def write_python(self, text, font_file): + """ + Render the given `text` into a python bitmap module. + """ + _, height, baseline = self.text_dimensions(text) + + bits = [] + widths = [] + offsets = [] + offset = 0 + + for char in text: + glyph = self.glyph_for_character(char) + + # Negative glyph.left fix from peterhinch + # https://github.com/peterhinch/micropython-font-to-py + # + # https://github.com/peterhinch/micropython-font-to-py/issues/21 + # Handle negative glyph.left correctly (capital J), + # also glyph.width > advance (capital K and R). + + if glyph.left >= 0: + char_width = int( + max(glyph.advance_width, glyph.width + glyph.left)) + left = glyph.left + else: + char_width = int( + max(glyph.advance_width - glyph.left, glyph.width)) + left = 0 + + # save the bit offset and width of the current glyph + offsets.append(offset) + widths.append(char_width) + outbuffer = Bitmap(char_width, height) + + # The vertical drawing position should place the glyph + # on the baseline as intended. + y = height - glyph.ascent - baseline + outbuffer.bitblt(glyph.bitmap, left, y) + + # convert bitmap to ascii bitmap string + bit_string = outbuffer.bit_string() + bits.append(bit_string) + offset += len(bit_string) + + # join all the bitmap strings together + bit_string = ''.join(bits) + + # escape backslash, quote and single quote characters for char_map + text_escaped = text.replace('\\', '\\\\').replace('"', '\\"').replace("'", "\\'") + char_map = wrap_str(text_escaped) + + cmd_line = " ".join(map(shlex.quote, sys.argv)) + max_width = max(widths) + + # write python module source + print('# -*- coding: utf-8 -*-') + print(f'# Converted from {font_file} using:') + print(f'# {cmd_line}') + print() + + print(f'MAP = {char_map}\n') + print('BPP = 1') + print(f'HEIGHT = {height}') + print(f'MAX_WIDTH = {max_width}') + print('_WIDTHS = \\') + print(wrap_bytes(widths)) + print() + + byte_offsets = bytearray() + bytes_table = [0xff, 0xffff, 0xffffff, 0xffffffff] + bytes_required = bisect.bisect_left(bytes_table, offset, 0, 3) + 1 + for offset in offsets: + byte_offsets.extend(offset.to_bytes(bytes_required, 'big')) + + print(f'OFFSET_WIDTH = {bytes_required}') + print('_OFFSETS = \\') + print(wrap_longs(byte_offsets)) + print() + + print('_BITMAPS =\\') + byte_values = [int(bit_string[i:i+8], 2) for i in range(0, len(bit_string), 8)] + print(wrap_bytes(byte_values)) + print("\nWIDTHS = memoryview(_WIDTHS)") + print("OFFSETS = memoryview(_OFFSETS)") + print("BITMAPS = memoryview(_BITMAPS)") + + +def main(): + parser = argparse.ArgumentParser( + prog='font2bitmap', + description=(''' + Convert characters from a truetype font to a python bitmap for use + with the bitmap method in the st7789 and ili9342 drivers.''')) + + parser.add_argument( + 'font_file', + help='name of font file to convert.') + + parser.add_argument( + 'font_height', + type=int, + default=8, + help='size of font to create bitmaps from.') + + parser.add_argument( + '-width', '--font_width', + type=int, + default=None, + help='width of font to create bitmaps from.') + + group = parser.add_argument_group( + 'character selection', + 'characters from the font to include in the bitmap.') + + excl = group.add_mutually_exclusive_group(required=True) + excl.add_argument( + '-c', '--characters', + help='''integer or hex character values and/or ranges to include. + For example: "65, 66, 67" or "32-127" or "0x30-0x39, 0x41-0x5a"''') + + excl.add_argument( + '-s', '--string', + help='''string of characters to include + For example: "1234567890-."''') + + args = parser.parse_args() + font_file = args.font_file + height = args.font_height + width = args.font_height if args.font_width is None else args.font_width + characters = get_chars(args.characters) if args.string is None else args.string + + fnt = Font(font_file, width, height) + fnt.write_python(characters, font_file) + + +main() diff --git a/utils/font_from_romfont.py b/utils/font_from_romfont.py new file mode 100644 index 0000000..dfed01b --- /dev/null +++ b/utils/font_from_romfont.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +""" +Convert fonts from the font-bin directory of spacerace's +https://github.com/spacerace/romfont repo. + +Reads all romfont bin files from the specified -input-directory (-i) and writes +python font files to the specified -output-directory (-o). Optionally limiting +characters included to -first-char (-f) thru -last-char (-l). + +Example: + + font_from_romfont -i font-bin -o pyfont -f 32 -l 127 + +requires argparse +""" +import os +import re +import argparse + +def convert_font(file_in, file_out, width, height, first=0x0, last=0xff): + chunk_size = height + with open(file_in, "rb") as bin_file: + bin_file.seek(first * height) + current = first + with open(file_out, 'wt') as font_file: + print(f'"""converted from {file_in} """', file=font_file) + print(f'WIDTH = {width}', file=font_file) + print(f'HEIGHT = {height}', file=font_file) + print(f'FIRST = 0x{first:02x}', file=font_file) + print(f'LAST = 0x{last:02x}', file=font_file) + print('_FONT =\\\n', sep='', end='', file=font_file) + for chunk in iter(lambda: bin_file.read(chunk_size), b''): + print('b\'', sep='', end='', file=font_file) + for data in chunk: + print(f'\\x{data:02x}', end='', file=font_file) + print('\'\\', file=font_file) + current += 1 + if current > last: + break + + print('', file=font_file) + print('FONT = memoryview(_FONT)', file=font_file) + +def auto_int(x): + return int(x, 0) + +def main(): + parser = argparse.ArgumentParser( + description='Convert Romfont.bin font files in input to python in font_directory.') + parser.add_argument('input', help='file or directory containing binary font file(s).') + parser.add_argument('output', help='file or directory to contain python font file(s).') + parser.add_argument('-f', '--first-char', type=auto_int, default=0x20) + parser.add_argument('-l', '--last-char', type=auto_int, default=0x7f) + args = parser.parse_args() + + file_re = re.compile(r'^(.*)(\d+)x(\d+)\.bin$') + + is_dir = os.path.isdir(args.input) + bin_files = os.listdir(args.input) if is_dir else [args.input] + for bin_file_name in bin_files: + match = file_re.match(bin_file_name) + if match: + font_width = int(match.group(2)) + font_height = int(match.group(3)) + + if is_dir: + bin_file_name = args.input+'/'+bin_file_name + + if is_dir: + font_file_name = ( + args.font_directory + '/' + + match.group(1).rstrip('_').lower()+ + f'_{font_width}x{font_height}.py') + else: + font_file_name = args.output + + print("converting", bin_file_name, 'to', font_file_name) + + convert_font( + bin_file_name, + font_file_name, + font_width, + font_height, + args.first_char, + args.last_char) + +main() diff --git a/utils/image2bin.py b/utils/image2bin.py new file mode 100644 index 0000000..5ba62ee --- /dev/null +++ b/utils/image2bin.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +image2bin.py - Convert image bitmap modules to binary streaming format + +This script converts Python image modules (created by imgtobitmap.py) to +binary files that can be streamed from the file system. This is especially +useful for large images or animations on memory-constrained devices. + +Usage: + python image2bin.py + +Example: + python image2bin.py logo.py logo.bin +""" + +import importlib.util +import os +import struct +import sys + + +def read_image_module(module_path): + """Load and extract image data from a Python image module""" + try: + # Load the module + spec = importlib.util.spec_from_file_location("image_module", module_path) + image_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(image_module) + + # Extract image metadata and data + image_data = { + "width": image_module.WIDTH, + "height": image_module.HEIGHT, + "colors": image_module.COLORS, + "bpp": image_module.BPP, + "palette": getattr(image_module, "PALETTE", []), + "bitmap": bytes(image_module._bitmap), + } + + return image_data + except Exception as e: + print(f"Error loading image module: {e}") + return None + + +def write_binary_file(image_data, output_path): + """Write image data to a binary file with a specific format""" + try: + with open(output_path, "wb") as f: + # Write header information + f.write(b"IMG ") # Magic number ("IMG " with trailing space) + f.write(struct.pack("H", image_data["width"])) # Image width + f.write(struct.pack("H", image_data["height"])) # Image height + f.write(struct.pack("B", image_data["colors"])) # Number of colors + f.write(struct.pack("B", image_data["bpp"])) # Bits per pixel + + # Write palette + if image_data["palette"]: + f.write(struct.pack("B", len(image_data["palette"]))) # Palette length + for color in image_data["palette"]: + f.write(struct.pack("H", color)) # RGB565 color value + else: + f.write(struct.pack("B", 0)) # Zero palette length + + # Write bitmap data + f.write(struct.pack("I", len(image_data["bitmap"]))) # Bitmap length + f.write(image_data["bitmap"]) + + print(f"Successfully wrote image data to {output_path}") + print(f"File size: {os.path.getsize(output_path)} bytes") + return True + except Exception as e: + print(f"Error writing binary file: {e}") + return False + + +def create_info_file(image_data, output_path): + """Create a text file with image information""" + info_path = output_path.replace(".bin", ".info") + try: + with open(info_path, "w") as f: + f.write(f"Image Information\n") + f.write(f"================\n") + f.write(f"Width: {image_data['width']} pixels\n") + f.write(f"Height: {image_data['height']} pixels\n") + f.write(f"Colors: {image_data['colors']}\n") + f.write(f"BPP: {image_data['bpp']}\n") + if image_data["palette"]: + f.write(f"Palette: {len(image_data['palette'])} colors\n") + else: + f.write(f"Palette: None\n") + f.write(f"Bitmap Size: {len(image_data['bitmap'])} bytes\n") + f.write( + f"Total File Size: {os.path.getsize(output_path.replace('.info', '.bin'))} bytes\n" + ) + + print(f"Image information written to {info_path}") + return True + except Exception as e: + print(f"Error writing info file: {e}") + return False + + +def main(): + if len(sys.argv) != 3: + print("Usage: python image2bin.py ") + print("Example: python image2bin.py logo.py logo.bin") + print( + "\nThis converts Python image modules (from imgtobitmap.py) to streaming format." + ) + return 1 + + image_module_path = sys.argv[1] + output_path = sys.argv[2] + + # Check if input file exists + if not os.path.exists(image_module_path): + print(f"Error: Image module not found at {image_module_path}") + return 1 + + # Load image data + image_data = read_image_module(image_module_path) + if image_data is None: + return 1 + + # Write binary file + if not write_binary_file(image_data, output_path): + return 1 + + # Create info file + if not create_info_file(image_data, output_path): + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/monofont2bitmap.py b/utils/monofont2bitmap.py new file mode 100644 index 0000000..11bbbfa --- /dev/null +++ b/utils/monofont2bitmap.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 + +''' +monofont2bitmap.py + Convert characters from monospace truetype fonts to a python bitmap + for use with the bitmap method in the st7789 and ili9342 drivers. + +positional arguments: + + font_file Name of font file to convert. + font_size Size of font to create bitmaps from. + bits_per_pixel The number of bits (1..8) to use per pixel. + +optional arguments: + + -h, --help show this help message and exit + -f FOREGROUND, --foreground FOREGROUND + Foreground color of characters. + -b BACKGROUND, --background BACKGROUND + Background color of characters. + +character selection: + Characters from the font to include in the bitmap. + + -c CHARACTERS, --characters CHARACTERS + integer or hex character values and/or ranges to + include. + + For example: "65, 66, 67" or "32-127" or + "0x30-0x39, 0x41-0x5a" + + -s STRING, --string STRING + String of characters to include For example: + "1234567890-." +''' + +import sys +import shlex +from PIL import Image, ImageFont, ImageDraw +import argparse + +image_bitstring = '' + + +def to_int(str): + return int(str, base=16) if str.startswith("0x") else int(str) + + +def get_characters(str): + return ''.join(chr(b) for a in [ + (lambda sub: range(sub[0], sub[-1] + 1)) + (list(map(to_int, ele.split('-')))) + for ele in str.split(',')] for b in a) + +def process_char(img, bits): + global image_bitstring + + # Run through the image and create a string with the ascii binary + # representation of the color of each pixel. + + for y in range(img.height): + for x in range(img.width): + pixel = img.getpixel((x, y)) + color = pixel + bit_string = ''.join( + '1' if (color & (1 << bit - 1)) else '0' + for bit in range(bits, 0, -1) + ) + + image_bitstring += bit_string + + +def main(): + parser = argparse.ArgumentParser( + prog='font2bitmap', + description=(''' + Convert characters from monospace truetype fonts to a + python bitmap for use with the bitmap method in the + st7789 and ili9342 drivers.''')) + + parser.add_argument( + 'font_file', + help='Name of font file to convert.') + + parser.add_argument( + 'font_size', + type=int, + default=8, + help='Size of font to create bitmaps from.') + + parser.add_argument( + 'bits_per_pixel', + type=int, + choices=range(1, 9), + default=1, + metavar='bits_per_pixel', + help='The number of bits (1..8) to use per pixel.') + + parser.add_argument( + '-f', '--foreground', + default='white', + help='Foreground color of characters.') + + parser.add_argument( + '-b', '--background', + default='black', + help='Background color of characters.') + + group = parser.add_argument_group( + 'character selection', + 'Characters from the font to include in the bitmap.') + + excl = group.add_mutually_exclusive_group() + excl.add_argument( + '-c', '--characters', + help='''integer or hex character values and/or ranges to include. + For example: "65, 66, 67" or "32-127" or "0x30-0x39, 0x41-0x5a"''') + + excl.add_argument( + '-s', '--string', + help='''String of characters to include + For example: "1234567890-."''') + + args = parser.parse_args() + bpp = args.bits_per_pixel + font_file = args.font_file + font_size = args.font_size + + if args.string is None: + characters = get_characters(args.characters) + else: + characters = args.string + + foreground = args.foreground + background = args.background + + # load font and get size of characters string in pixels + font = ImageFont.truetype(font_file, font_size) + size = font.getsize(characters) + + # create image large enough to all characters + im = Image.new('RGB', size, color=background) + + # draw all specified characters in the image + draw = ImageDraw.Draw(im) + draw.text((0, 0), characters, font=font, color=foreground) + + # convert image to a palletized image with the requested color depth + bpp_im = im.convert(mode='P', palette=Image.ADAPTIVE, colors=1 << bpp) + palette = bpp_im.getpalette() + + # convert all characters into a ascii bit string + ofs = 0 + for char in characters: + char_size = font.getsize(char) + crop = (ofs, 0, ofs + char_size[0], size[1]) + char_im = bpp_im.crop(crop) + process_char(char_im, bpp) + ofs += char_size[0] + + bitmap_bits = len(image_bitstring) + char_map = characters.replace('\\', '\\\\').replace('"', '\\"') + cmdline = " ".join(map(shlex.quote, sys.argv)) + + # Create python source + print('# coding=UTF8') + print(f'# Converted from {font_file} using:') + print(f'# {cmdline}') + print() + print(f'HEIGHT = {char_im.height}') + print(f'WIDTH = {char_im.width}') + print(f'COLORS = {1 << bpp}') + print(f'BITMAPS = {len(characters)}') + print(f'MAP = "{char_map}"') + print(f'BPP = {bpp}') + print('PALETTE = [', sep='', end='') + + # For all the colors in the palette + colors = [] + for color in range(1 << bpp): + + # get rgb values and convert to 565 + color565 = ( + ((palette[color*3] & 0xF8) << 8) | + ((palette[color*3+1] & 0xFC) << 3) | + ((palette[color*3+2] & 0xF8) >> 3)) + + # swap bytes in 565 + color = ((color565 & 0xff) << 8) + ((color565 & 0xff00) >> 8) + + # append byte swapped 565 color to colors + colors.append(f'{color:04x}') + + # write color palette colors + for color, rgb in enumerate(colors): + if color: + print(', ', sep='', end='') + + print(f'0x{rgb}', sep='', end='') + print(']') + + # Run though image bit string 8 bits at a time + # and create python array source for memoryview + + print('_BITMAP =\\', sep='') + print(' b\'', sep='', end='') + + for i in range(0, bitmap_bits, 8): + if i and i % (16 * 8) == 0: + print("'\\\n b'", end='', sep='') + + value = image_bitstring[i:i+8] + color = int(value, 2) + print(f'\\x{color:02x}', sep='', end='') + + print("'\\\n b'", end='', sep='') + print("'\n\nBITMAP = memoryview(_BITMAP)") + + +main()