mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 12:41:58 +00:00
Keep Companion and Terminal Chat profiles unchanged; retain full-sensor and reduced-sensor OTA builds. Add compact crypto and OTA names, qualified display asset compression, font license packaging, and scope regression tests.
330 lines
13 KiB
Python
330 lines
13 KiB
Python
"""Generate opt-in, lossless Arial font storage experiments.
|
|
|
|
The checked-in source font remains authoritative. Every one of its 224 slots,
|
|
including blank slots and their advance widths, survives these experiments.
|
|
Mode 2 uses per-glyph byte RLE; mode 3 independently inflates <=512-byte glyph
|
|
groups. Whole-font compression is measured only, not instantiated on the device.
|
|
Google Zopfli is used at 1,000 iterations; no compressor runs on the device.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
import re
|
|
|
|
from zopfli_compress import MAXIMUM_ITERATIONS, raw_deflate_compress
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE = ROOT / "src/helpers/ui/OLEDDisplayFonts.cpp"
|
|
OUTPUT = ROOT / "src/helpers/ui/CompactArialFonts.h"
|
|
CHUNK_LIMIT = 512
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Glyph:
|
|
width: int
|
|
bitmap: bytes
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Font:
|
|
name: str
|
|
original: bytes
|
|
glyphs: tuple[Glyph, ...]
|
|
|
|
@property
|
|
def header(self) -> bytes:
|
|
return self.original[:4]
|
|
|
|
|
|
def read_fonts(path: Path = SOURCE) -> tuple[Font, ...]:
|
|
source = path.read_text(encoding="utf-8")
|
|
fonts = []
|
|
for name, body in re.findall(
|
|
r"const uint8_t (ArialMT_Plain_\d+)\[\] PROGMEM = \{(.*?)\};", source, re.S
|
|
):
|
|
body = re.sub(r"//[^\n]*", "", body)
|
|
data = bytes(int(value, 16) for value in re.findall(r"0x([0-9a-fA-F]+)", body))
|
|
if len(data) < 4:
|
|
raise ValueError(f"{name}: incomplete header")
|
|
base = 4 + 4 * data[3]
|
|
if base > len(data) or data[2] + data[3] > 256:
|
|
raise ValueError(f"{name}: invalid character table")
|
|
glyphs = []
|
|
consumed = 0
|
|
for index in range(data[3]):
|
|
entry = data[4 + 4 * index:8 + 4 * index]
|
|
offset = int.from_bytes(entry[:2], "big")
|
|
length, width = entry[2:]
|
|
if offset == 0xffff:
|
|
if length:
|
|
raise ValueError(f"{name}: blank slot contains bitmap bytes")
|
|
bitmap = b""
|
|
else:
|
|
if not length or offset != consumed or base + offset + length > len(data):
|
|
raise ValueError(f"{name}: invalid or non-contiguous bitmap")
|
|
bitmap = data[base + offset:base + offset + length]
|
|
consumed += length
|
|
glyphs.append(Glyph(width, bitmap))
|
|
if base + consumed != len(data):
|
|
raise ValueError(f"{name}: trailing bitmap bytes")
|
|
fonts.append(Font(name, data, tuple(glyphs)))
|
|
if [font.name for font in fonts] != [f"ArialMT_Plain_{size}" for size in (10, 16, 24)]:
|
|
raise ValueError("Expected the original Arial 10, 16 and 24 fonts")
|
|
return tuple(fonts)
|
|
|
|
|
|
def rle_encode(data: bytes) -> bytes:
|
|
"""PackBits-style literals (1..128) and repeated bytes (3..130)."""
|
|
output = bytearray()
|
|
position = 0
|
|
while position < len(data):
|
|
run = 1
|
|
while position + run < len(data) and data[position + run] == data[position] and run < 130:
|
|
run += 1
|
|
if run >= 3:
|
|
output.extend((0x80 | (run - 3), data[position]))
|
|
position += run
|
|
continue
|
|
first = position
|
|
position += run
|
|
while position < len(data) and position - first < 128:
|
|
run = 1
|
|
while position + run < len(data) and data[position + run] == data[position] and run < 3:
|
|
run += 1
|
|
if run >= 3:
|
|
break
|
|
position += min(run, 128 - (position - first))
|
|
output.append(position - first - 1)
|
|
output.extend(data[first:position])
|
|
return bytes(output)
|
|
|
|
|
|
def rle_font(font: Font) -> tuple[bytes, tuple[tuple[int, int, int], ...]]:
|
|
output = bytearray()
|
|
records = []
|
|
for glyph in font.glyphs:
|
|
if not glyph.bitmap:
|
|
records.append((0xffff, 0, glyph.width))
|
|
continue
|
|
compressed = rle_encode(glyph.bitmap)
|
|
raw = len(compressed) >= len(glyph.bitmap)
|
|
location = len(output) | (0x8000 if raw else 0)
|
|
output.extend(glyph.bitmap if raw else compressed)
|
|
records.append((location, len(glyph.bitmap), glyph.width))
|
|
if len(output) >= 0x7fff:
|
|
raise ValueError("RLE offsets no longer fit the 15-bit experiment format")
|
|
return bytes(output), tuple(records)
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def chunk_font(font: Font) -> tuple[bytes, tuple[tuple[int, int, int], ...], tuple[int, ...], tuple[int, ...]]:
|
|
chunks = []
|
|
current = bytearray()
|
|
records = []
|
|
for glyph in font.glyphs:
|
|
if not glyph.bitmap:
|
|
records.append((0xffff, 0, glyph.width))
|
|
continue
|
|
if len(glyph.bitmap) > CHUNK_LIMIT:
|
|
raise ValueError("A glyph exceeds the bounded font scratch buffer")
|
|
if len(current) + len(glyph.bitmap) > CHUNK_LIMIT:
|
|
chunks.append(bytes(current))
|
|
current.clear()
|
|
records.append(((len(chunks) << 9) | len(current), len(glyph.bitmap), glyph.width))
|
|
current.extend(glyph.bitmap)
|
|
if current:
|
|
chunks.append(bytes(current))
|
|
if len(chunks) >= 128:
|
|
raise ValueError("Chunk identifiers exceed the packed 7-bit format")
|
|
output = bytearray()
|
|
offsets = [0]
|
|
for chunk in chunks:
|
|
output.extend(raw_deflate_compress(chunk, numiterations=MAXIMUM_ITERATIONS))
|
|
offsets.append(len(output))
|
|
if len(output) > 0xffff:
|
|
raise ValueError("Compressed font offsets exceed 16 bits")
|
|
return bytes(output), tuple(records), tuple(offsets), tuple(map(len, chunks))
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def whole_font(font: Font) -> bytes:
|
|
return raw_deflate_compress(font.original, numiterations=MAXIMUM_ITERATIONS)
|
|
|
|
|
|
def array(name: str, data, ctype: str = "uint8_t", columns: int = 16) -> str:
|
|
values = [f"0x{value:02x}" if ctype == "uint8_t" else str(value) for value in data]
|
|
lines = [f"static const {ctype} {name}[] = {{"]
|
|
for first in range(0, len(values), columns):
|
|
lines.append(" " + ", ".join(values[first:first + columns]) + ",")
|
|
lines.append("};")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def descriptor(font: Font, rows) -> bytes:
|
|
output = bytearray(font.header)
|
|
for location, length, width in rows:
|
|
output.extend(location.to_bytes(2, "big"))
|
|
output.extend((length, width))
|
|
return bytes(output)
|
|
|
|
|
|
def generate_header(fonts: tuple[Font, ...]) -> str:
|
|
output = [HEADER_START]
|
|
for mode in (2, 3):
|
|
output.append(f"#if MESH_ARIAL_EXPERIMENT == {mode}")
|
|
for index, font in enumerate(fonts[1:], 1):
|
|
prefix = f"FONT_{index}"
|
|
# Inline function-local statics have one address across translation
|
|
# units. Namespace-static arrays would silently break identify().
|
|
output.append(f"inline const Font& fontStorage{index}() {{")
|
|
if mode == 2:
|
|
data, rows = rle_font(font)
|
|
offsets, lengths = (), ()
|
|
elif mode == 3:
|
|
data, rows, offsets, lengths = chunk_font(font)
|
|
output.append(array(prefix + "_DATA", data))
|
|
output.append(array(prefix + "_DESCRIPTOR", descriptor(font, rows)))
|
|
if offsets:
|
|
output.append(array(prefix + "_OFFSETS", offsets, "uint16_t"))
|
|
output.append(array(prefix + "_LENGTHS", lengths, "uint16_t"))
|
|
output.append(
|
|
f"static const Font {prefix} = {{ " + prefix + "_DESCRIPTOR, " + prefix + "_DATA, "
|
|
+ (prefix + "_OFFSETS, " + prefix + "_LENGTHS" if offsets else "nullptr, nullptr")
|
|
+ f", {len(lengths)}, {len(data)} }};"
|
|
)
|
|
output.append(f"return {prefix};\n}}")
|
|
output.append("#endif")
|
|
output.append(HEADER_END)
|
|
return "\n\n".join(output) + "\n"
|
|
|
|
|
|
HEADER_START = r'''// Generated by scripts/generate_compact_arial.py; do not edit.
|
|
// Experiments are LOSSLESS: same Arial pixels, all 224 slots and advance widths.
|
|
// 2 = glyph RLE (<=91 scratch bytes); 3 = Zopfli 1000 chunks (<=512 scratch).
|
|
// Only Arial16 and Arial24 are instantiated; the unused initial Arial10 is not.
|
|
#pragma once
|
|
#include <stddef.h>
|
|
#include <stdint.h>
|
|
#if MESH_ARIAL_EXPERIMENT == 3
|
|
#include "../ota/tinf/tinf.h"
|
|
#endif
|
|
#if MESH_ARIAL_EXPERIMENT != 2 && MESH_ARIAL_EXPERIMENT != 3
|
|
#error "CompactArialFonts.h is an opt-in experiment for modes 2 and 3 only"
|
|
#endif
|
|
namespace mesh { namespace ui { namespace arial {
|
|
static constexpr size_t kScratchSize = MESH_ARIAL_EXPERIMENT == 2 ? 91 : 512;
|
|
struct Font {
|
|
const uint8_t* descriptor;
|
|
const uint8_t* data;
|
|
const uint16_t* offsets;
|
|
const uint16_t* rawLengths;
|
|
uint16_t chunkCount;
|
|
uint16_t dataLength;
|
|
};
|
|
struct Glyph {
|
|
const uint8_t* data;
|
|
uint8_t width;
|
|
uint8_t length;
|
|
bool drawable;
|
|
bool valid;
|
|
};'''
|
|
|
|
|
|
HEADER_END = r'''inline const uint8_t* font(unsigned id) {
|
|
return id == 1 ? fontStorage1().descriptor : id == 2 ? fontStorage2().descriptor : nullptr;
|
|
}
|
|
inline int identify(const uint8_t* descriptor) {
|
|
return descriptor == fontStorage1().descriptor ? 1 : descriptor == fontStorage2().descriptor ? 2 : -1;
|
|
}
|
|
inline const Font* storage(const uint8_t* descriptor) {
|
|
const int id = identify(descriptor);
|
|
return id == 1 ? &fontStorage1() : id == 2 ? &fontStorage2() : nullptr;
|
|
}
|
|
// Writes only to caller scratch. No global cache, heap allocation, or mutable state.
|
|
inline bool decodeRle(const uint8_t* input, size_t inputLength,
|
|
uint8_t* output, size_t outputLength) {
|
|
if ((!input && inputLength) || (!output && outputLength)) return false;
|
|
size_t source = 0, dest = 0;
|
|
while (source < inputLength) {
|
|
const uint8_t token = input[source++];
|
|
const size_t count = token < 128 ? size_t(token) + 1 : size_t(token & 127) + 3;
|
|
if (count > outputLength - dest) return false;
|
|
if (token < 128) {
|
|
if (count > inputLength - source) return false;
|
|
for (size_t j = 0; j < count; ++j) output[dest++] = input[source++];
|
|
} else {
|
|
if (source == inputLength) return false;
|
|
const uint8_t value = input[source++];
|
|
for (size_t j = 0; j < count; ++j) output[dest++] = value;
|
|
}
|
|
}
|
|
return dest == outputLength;
|
|
}
|
|
inline Glyph glyph(const uint8_t* descriptor, uint8_t code, uint8_t* scratch, size_t capacity) {
|
|
const Font* f = storage(descriptor);
|
|
if (!f || code < descriptor[2] || unsigned(code - descriptor[2]) >= descriptor[3])
|
|
return { nullptr, 0, 0, false, false };
|
|
const unsigned slot = code - descriptor[2];
|
|
const uint8_t* record = descriptor + 4 + 4 * slot;
|
|
const unsigned location = (unsigned(record[0]) << 8) | record[1];
|
|
const uint8_t length = record[2], width = record[3];
|
|
if (!length) return { nullptr, width, 0, false, location == 0xffff };
|
|
#if MESH_ARIAL_EXPERIMENT == 2
|
|
const unsigned first = location & 0x7fff;
|
|
unsigned end = f->dataLength;
|
|
for (unsigned next = slot + 1; next < descriptor[3]; ++next) {
|
|
const uint8_t* nextRecord = descriptor + 4 + 4 * next;
|
|
if (nextRecord[2]) { end = ((unsigned(nextRecord[0]) << 8) | nextRecord[1]) & 0x7fff; break; }
|
|
}
|
|
if (first > end || end > f->dataLength) return { nullptr, width, 0, false, false };
|
|
if (location & 0x8000) {
|
|
if (end - first != length) return { nullptr, width, 0, false, false };
|
|
return { f->data + first, width, length, true, true };
|
|
}
|
|
if (!scratch || capacity < length || !decodeRle(f->data + first, end - first, scratch, length))
|
|
return { nullptr, width, 0, false, false };
|
|
return { scratch, width, length, true, true };
|
|
#elif MESH_ARIAL_EXPERIMENT == 3
|
|
const unsigned group = location >> 9;
|
|
const unsigned offset = location & 511;
|
|
if (group >= f->chunkCount) return { nullptr, width, 0, false, false };
|
|
const unsigned first = f->offsets[group], end = f->offsets[group + 1];
|
|
const unsigned expected = f->rawLengths[group];
|
|
if (!scratch || expected > kScratchSize || capacity < expected || offset + length > expected || first > end || end > f->dataLength)
|
|
return { nullptr, width, 0, false, false };
|
|
unsigned int produced = expected;
|
|
if (tinf_uncompress_exact(scratch, &produced, f->data + first, end - first) != TINF_OK || produced != expected)
|
|
return { nullptr, width, 0, false, false };
|
|
return { scratch + offset, width, length, true, true };
|
|
#endif
|
|
}
|
|
}}} // namespace mesh::ui::arial'''
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true", help="Check the generated header without writing")
|
|
args = parser.parse_args()
|
|
fonts = read_fonts()
|
|
header = generate_header(fonts)
|
|
if args.check:
|
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != header:
|
|
raise SystemExit("CompactArialFonts.h is stale; regenerate the font experiments")
|
|
else:
|
|
OUTPUT.write_text(header, encoding="utf-8", newline="\n")
|
|
print("font original RLE+table chunks+table wholefont chunks")
|
|
for font in fonts:
|
|
rle, _ = rle_font(font)
|
|
chunk, _, offsets, lengths = chunk_font(font)
|
|
print(f"{font.name:20} {len(font.original):5} {len(rle) + 4 * len(font.glyphs) + 4:9} "
|
|
f"{len(chunk) + 4 * len(font.glyphs) + 2 * (len(offsets) + len(lengths)) + 4:12} "
|
|
f"{len(whole_font(font)):9} {len(lengths):6}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|