Files
trail-mate/tools/compress_lvgl_bitmap_font.py

310 lines
9.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Compress an LVGL fmt_txt bitmap font in-place by converting glyph bitmaps to
LV_FONT_FMT_TXT_COMPRESSED_NO_PREFILTER (bitmap_format=2).
Designed for fonts generated by lv_font_conv where glyph_bitmap and glyph_dsc
sections are present in a C file.
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def get_bits(buf: bytes, bit_pos: int, bit_len: int) -> int:
out = 0
for _ in range(bit_len):
byte = buf[bit_pos >> 3]
bit = (byte >> (7 - (bit_pos & 7))) & 1
out = (out << 1) | bit
bit_pos += 1
return out
def read_plain_pixels(raw_bytes: bytes, start_byte: int, px_count: int, bpp: int) -> list[int]:
vals: list[int] = []
bit_pos = start_byte * 8
for _ in range(px_count):
vals.append(get_bits(raw_bytes, bit_pos, bpp))
bit_pos += bpp
return vals
class BitWriter:
def __init__(self) -> None:
self.bits: list[int] = []
def put(self, value: int, width: int) -> None:
for i in range(width - 1, -1, -1):
self.bits.append((value >> i) & 1)
def to_bytes(self) -> bytes:
out = bytearray()
for i in range(0, len(self.bits), 8):
b = 0
chunk = self.bits[i : i + 8]
for bit in chunk:
b = (b << 1) | bit
if len(chunk) < 8:
b <<= 8 - len(chunk)
out.append(b)
return bytes(out)
def encode_no_counter(vals: list[int], bpp: int) -> bytes:
"""
Encode with LVGL compressed stream syntax, intentionally avoiding the
long-counter branch for simplicity/robustness.
"""
if not vals:
return b""
w = BitWriter()
state_single = True
prev = 0
rdp_nonzero = False
repeat_count = 0
for v in vals:
if state_single:
w.put(v, bpp)
if rdp_nonzero and v == prev:
state_single = False
repeat_count = 0
prev = v
rdp_nonzero = True
continue
if v == prev and repeat_count < 10:
# Continue repeated state with flag=1
w.put(1, 1)
repeat_count += 1
else:
# Exit repeated state: flag=0 + next literal
w.put(0, 1)
w.put(v, bpp)
prev = v
state_single = True
return w.to_bytes()
def decode_lvgl_stream(comp_bytes: bytes, px_count: int, bpp: int) -> list[int]:
"""Reference decoder equivalent to lv_font_fmt_txt.c state machine."""
rdp = 0
prev = 0
state = 0 # 0 SINGLE, 1 REPEATED, 2 COUNTER
count = 0
out: list[int] = []
def gb(n: int) -> int:
nonlocal rdp
v = get_bits(comp_bytes, rdp, n)
rdp += n
return v
for _ in range(px_count):
if state == 0:
ret = gb(bpp)
if rdp != bpp and prev == ret:
state = 1
count = 0
prev = ret
elif state == 1:
v = gb(1)
count += 1
if v == 1:
ret = prev
if count == 11:
count = gb(6)
if count != 0:
state = 2
else:
ret = gb(bpp)
prev = ret
state = 0
else:
ret = gb(bpp)
prev = ret
state = 0
else:
ret = prev
count -= 1
if count == 0:
ret = gb(bpp)
prev = ret
state = 0
out.append(ret)
return out
def format_byte_array(data: bytes, indent: str = " ", per_line: int = 16) -> str:
if not data:
return indent + "0x00,\n"
toks = [f"0x{b:02x}" for b in data]
lines = []
for i in range(0, len(toks), per_line):
lines.append(indent + ", ".join(toks[i : i + per_line]) + ",")
return "\n".join(lines) + "\n"
def replace_between(text: str, start_token: str, end_token: str, replacement: str) -> tuple[str, str]:
s = text.find(start_token)
if s < 0:
raise ValueError(f"start token not found: {start_token}")
s2 = s + len(start_token)
e = text.find(end_token, s2)
if e < 0:
raise ValueError(f"end token not found: {end_token}")
body = text[s2:e]
out = text[:s2] + replacement + text[e:]
return out, body
def compress_font(path: Path) -> tuple[int, int, int]:
text = path.read_text(encoding="utf-8")
bitmap_start = "static LV_ATTRIBUTE_LARGE_CONST const uint8_t glyph_bitmap[] = {\n"
bitmap_end = "\n\n/*---------------------\n * GLYPH DESCRIPTION"
glyph_start = "static const lv_font_fmt_txt_glyph_dsc_t glyph_dsc[] = {\n"
glyph_end = "\n\n/*---------------------\n * CHARACTER MAPPING"
# Parse raw bitmap bytes from current section
b_s = text.find(bitmap_start)
if b_s < 0:
raise ValueError("glyph_bitmap start not found")
b_body_start = b_s + len(bitmap_start)
b_e = text.find(bitmap_end, b_body_start)
if b_e < 0:
raise ValueError("glyph_bitmap end marker not found")
bitmap_block = text[b_body_start:b_e]
raw_vals = [int(x, 16) for x in re.findall(r"0x([0-9a-fA-F]{1,2})", bitmap_block)]
raw_bytes = bytes(raw_vals)
# Parse glyph descriptors
g_s = text.find(glyph_start)
if g_s < 0:
raise ValueError("glyph_dsc start not found")
g_body_start = g_s + len(glyph_start)
g_e = text.find(glyph_end, g_body_start)
if g_e < 0:
raise ValueError("glyph_dsc end marker not found")
glyph_body = text[g_body_start:g_e]
entry_re = re.compile(
r"\{\.bitmap_index = (\d+), \.adv_w = (\d+), \.box_w = (\d+), \.box_h = (\d+), \.ofs_x = (-?\d+), \.ofs_y = (-?\d+)\}(?: /\*.*?\*/)?",
flags=re.S,
)
entries = []
for mm in entry_re.finditer(glyph_body):
entries.append(
{
"bitmap_index": int(mm.group(1)),
"box_w": int(mm.group(3)),
"box_h": int(mm.group(4)),
"full": mm.group(0),
"start": mm.start(),
"end": mm.end(),
}
)
if not entries:
raise ValueError("no glyph_dsc entries parsed")
m_bpp = re.search(r"\.bpp = (\d+),", text)
if not m_bpp:
raise ValueError("bpp field not found")
bpp = int(m_bpp.group(1))
m_fmt = re.search(r"\.bitmap_format = (\d+),", text)
if not m_fmt:
raise ValueError("bitmap_format field not found")
bitmap_format = int(m_fmt.group(1))
if bitmap_format != 0:
raise ValueError(
f"expected bitmap_format=0 (plain) before compression, got {bitmap_format}"
)
new_bitmap = bytearray()
new_indexes: list[int] = []
for i, e in enumerate(entries):
old_start = e["bitmap_index"]
old_end = entries[i + 1]["bitmap_index"] if i + 1 < len(entries) else len(raw_bytes)
if old_end < old_start:
raise ValueError(f"invalid bitmap_index order at glyph {i}")
px_count = e["box_w"] * e["box_h"]
new_indexes.append(len(new_bitmap))
if px_count == 0:
continue
plain = read_plain_pixels(raw_bytes, old_start, px_count, bpp)
comp = encode_no_counter(plain, bpp)
# strict round-trip validation
back = decode_lvgl_stream(comp, px_count, bpp)
if back != plain:
raise ValueError(f"compression validation failed at glyph {i}")
new_bitmap.extend(comp)
# Rewrite glyph bitmap section
new_bitmap_body = format_byte_array(bytes(new_bitmap)).rstrip("\n")
# Keep the C array terminator before the next section marker.
text, _ = replace_between(text, bitmap_start, bitmap_end, new_bitmap_body + "\n};")
# Rewrite bitmap_index values in glyph_dsc section
g_s = text.find(glyph_start)
g_body_start = g_s + len(glyph_start)
g_e = text.find(glyph_end, g_body_start)
glyph_body = text[g_body_start:g_e]
parts = []
last = 0
idx = 0
for mm in entry_re.finditer(glyph_body):
parts.append(glyph_body[last : mm.start()])
repl = re.sub(r"\.bitmap_index = \d+", f".bitmap_index = {new_indexes[idx]}", mm.group(0), count=1)
parts.append(repl)
last = mm.end()
idx += 1
parts.append(glyph_body[last:])
new_glyph_body = "".join(parts)
text, _ = replace_between(text, glyph_start, glyph_end, new_glyph_body)
# Switch font bitmap format to compressed-no-prefilter
text, n_fmt = re.subn(r"\.bitmap_format = \d+,", ".bitmap_format = 2,", text, count=1)
if n_fmt != 1:
raise ValueError("bitmap_format field update failed")
path.write_text(text, encoding="utf-8")
return len(raw_bytes), len(new_bitmap), len(raw_bytes) - len(new_bitmap)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument(
"font_c",
nargs="?",
default="src/ui/assets/fonts/lv_font_noto_cjk_16_2bpp.c",
help="Path to lvgl font C file",
)
args = ap.parse_args()
raw_len, new_len, saved = compress_font(Path(args.font_c))
ratio = (new_len * 100.0 / raw_len) if raw_len else 0.0
print(f"raw={raw_len} compressed={new_len} saved={saved} ratio={ratio:.2f}%")
if __name__ == "__main__":
main()