mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 18:31:57 +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.
155 lines
7.0 KiB
Python
155 lines
7.0 KiB
Python
"""Losslessly compress the selected Noto Condensed production bitmap fonts.
|
|
|
|
The checked-in, licensed mode7 assets in FontFamilyTrials.h are authoritative;
|
|
regeneration needs Google Zopfli but no Pillow, FreeType or installed font file.
|
|
Every glyph's pixels, advance width, blank slot and font metric are preserved.
|
|
Normal firmware builds use the checked-in header without running this script.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
from pathlib import Path
|
|
import re
|
|
|
|
from generate_compact_arial import (
|
|
Font, Glyph, HEADER_END as ARIAL_DECODER, ROOT, array, chunk_font, descriptor,
|
|
)
|
|
|
|
|
|
SOURCE = ROOT / "src/helpers/ui/FontFamilyTrials.h"
|
|
OUTPUT = ROOT / "src/helpers/ui/CompactNotoFonts.h"
|
|
|
|
|
|
def read_fonts(path: Path = SOURCE) -> tuple[tuple[Font, ...], str]:
|
|
source = path.read_text(encoding="utf-8")
|
|
match = re.search(r"#if MESH_ARIAL_EXPERIMENT == 7\s*(.*?)\n#endif", source, re.S)
|
|
if not match:
|
|
raise ValueError("Authoritative raw Noto Condensed mode7 assets are missing")
|
|
body = match[1]
|
|
fonts = []
|
|
for id, encoded in re.findall(r"static const uint8_t TRIAL_([12])\[\] = \{(.*?)\};", body, re.S):
|
|
data = bytes(int(value, 16) for value in re.findall(r"0x([0-9a-f]{2})", encoded))
|
|
index = int(id)
|
|
expected_header = bytes((16, 19, 32, 224)) if index == 1 else bytes((24, 28, 32, 224))
|
|
if data[:4] != expected_header:
|
|
raise ValueError("Production Noto dimensions/character coverage changed")
|
|
base = 4 + 4 * data[3]
|
|
if len(data) < base:
|
|
raise ValueError("Incomplete raw Noto character table")
|
|
glyphs = []
|
|
consumed = 0
|
|
for slot in range(data[3]):
|
|
record = data[4 + 4 * slot:8 + 4 * slot]
|
|
offset = int.from_bytes(record[:2], "big")
|
|
length, width = record[2:]
|
|
if offset == 0xffff:
|
|
if length:
|
|
raise ValueError("A blank Noto slot contains bitmap bytes")
|
|
bitmap = b""
|
|
else:
|
|
if not length or offset != consumed or base + offset + length > len(data):
|
|
raise ValueError("Invalid or non-contiguous raw Noto glyph")
|
|
bitmap = data[base + offset:base + offset + length]
|
|
consumed += length
|
|
glyphs.append(Glyph(width, bitmap))
|
|
if base + consumed != len(data) or sum(bool(glyph.bitmap) for glyph in glyphs) != 189:
|
|
raise ValueError("Raw Noto glyph coverage/trailing bytes changed")
|
|
fonts.append(Font(f"MeshFontProduction9_{16 if index == 1 else 24}", data, tuple(glyphs)))
|
|
if len(fonts) != 2 or fonts[0].header[0] != 16 or fonts[1].header[0] != 24:
|
|
raise ValueError("Expected ordered Noto 16/24 assets")
|
|
license = re.search(r"/\*(.*?)\*/", body, re.S)
|
|
if not license or "SIL OPEN FONT LICENSE Version 1.1" not in license[1]:
|
|
raise ValueError("Required source Noto copyright/OFL notice is missing")
|
|
return tuple(fonts), license[1].strip()
|
|
|
|
|
|
def chunk_decoder() -> str:
|
|
"""Reuse the tested Arial codec's exact C++ DEFLATE branch and pointer API.
|
|
|
|
Extract at generation time, not with firmware macros: production contains
|
|
neither the RLE decoder nor an experimental-mode dependency. Tests compile
|
|
the result in isolation and check every authoritative raw Noto glyph.
|
|
"""
|
|
api = ARIAL_DECODER[:ARIAL_DECODER.index("// Writes only to caller scratch.")]
|
|
start = ARIAL_DECODER.index("inline Glyph glyph(")
|
|
common = ARIAL_DECODER[start:ARIAL_DECODER.index("#if MESH_ARIAL_EXPERIMENT == 2", start)]
|
|
deflate = ARIAL_DECODER.split("#elif MESH_ARIAL_EXPERIMENT == 3\n", 1)[1].split("#endif\n", 1)[0]
|
|
return (api + "// Caller-owned 512-byte scratch; no cache, heap or mutable global state.\n"
|
|
+ common + deflate + "}\n}}} // namespace mesh::ui::noto")
|
|
|
|
|
|
HEADER_START = '''// Generated by scripts/generate_compact_noto.py; do not edit.
|
|
// MeshFontProduction9: lossless storage of the licensed MeshFontTrial7 bitmaps.
|
|
// All 224 slots/189 drawable glyphs preserved at 12/17 px H cap height;
|
|
// H tops 3/5 px, line boxes 19/28 px. The unused initial Arial10 is not stored.
|
|
// Google Zopfli 1000; glyph-aligned chunks decode to at most 512 bytes.
|
|
// Source rasterizer: Pillow 12.3.0 / FreeType 2.14.3, threshold 128.
|
|
// Nominal source sizes 16/24 px, baselines 15/22 px; existing mode7 placement.
|
|
// Normal builds need no compressor, rasterizer or source TrueType font.
|
|
#pragma once
|
|
#include <stddef.h>
|
|
#include <stdint.h>
|
|
#include "../ota/tinf/tinf.h"
|
|
namespace mesh { namespace ui { namespace noto {
|
|
static constexpr size_t kScratchSize = 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;
|
|
};'''
|
|
|
|
|
|
def generate_header(fonts: tuple[Font, ...], license: str) -> str:
|
|
output = [HEADER_START, "/*\nCompression derivative named MeshFontProduction9.\n"
|
|
"The following notice identifies the upstream rasterized font source.\n"
|
|
+ "\n".join(line.rstrip() for line in license.splitlines()) + "\n*/"]
|
|
for id, font in enumerate(fonts, 1):
|
|
packed, rows, offsets, lengths = chunk_font(font)
|
|
prefix = f"FONT_{id}"
|
|
output.append(f"// Authoritative raw bitmap SHA256: {hashlib.sha256(font.original).hexdigest()}")
|
|
output.append(f"inline const Font& fontStorage{id}() {{")
|
|
output.append(array(prefix + "_DATA", packed))
|
|
output.append(array(prefix + "_DESCRIPTOR", descriptor(font, rows)))
|
|
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, "
|
|
f"{prefix}_OFFSETS, {prefix}_LENGTHS, {len(lengths)}, {len(packed)} }};")
|
|
output.append(f"return {prefix};\n}}")
|
|
output.append(chunk_decoder())
|
|
return "\n\n".join(output) + "\n"
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true", help="Verify the generated header without writing")
|
|
args = parser.parse_args()
|
|
fonts, license = read_fonts()
|
|
header = generate_header(fonts, license)
|
|
if args.check:
|
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != header:
|
|
raise SystemExit("CompactNotoFonts.h is stale; regenerate the production font assets")
|
|
else:
|
|
OUTPUT.write_text(header, encoding="utf-8", newline="\n")
|
|
total = 0
|
|
for font in fonts:
|
|
packed, records, offsets, lengths = chunk_font(font)
|
|
size = len(packed) + len(descriptor(font, records)) + 2 * (len(offsets) + len(lengths))
|
|
total += size
|
|
print(f"{font.name}: raw={len(font.original)}, compressed+metadata={size}, chunks={len(lengths)}")
|
|
print(f"Combined storage={total} bytes (runtime accessor code excluded)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|