mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-08-14 06:39:45 +00:00
310 lines
9.2 KiB
Python
Executable File
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()
|