You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
294 lines
10 KiB
294 lines
10 KiB
// ---------------------------------------------------------------------------
|
|
// U8G2 font binary format decoder
|
|
// Derived from: https://github.com/olikraus/u8g2/blob/master/csrc/u8g2_font.c
|
|
// https://github.com/olikraus/u8g2/blob/master/tools/font/bdfconv/bdf_rle.c
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const HDR_SIZE = 23;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BitReader - LSB-first, matching u8g2_font_decode_get_unsigned_bits exactly:
|
|
// val = *ptr >> bit_pos
|
|
// if (bit_pos + cnt >= 8): val |= *(ptr+1) << (8 - bit_pos); ptr++
|
|
// val &= (1 << cnt) - 1
|
|
// ---------------------------------------------------------------------------
|
|
class BitReader {
|
|
private ptr: number;
|
|
private bitPos: number;
|
|
|
|
constructor(private data: Uint8Array, startByte: number) {
|
|
this.ptr = startByte;
|
|
this.bitPos = 0;
|
|
}
|
|
|
|
read(cnt: number): number {
|
|
let val = (this.data[this.ptr]! >> this.bitPos) & 0xFF;
|
|
const newPos = this.bitPos + cnt;
|
|
if (newPos >= 8) {
|
|
this.ptr++;
|
|
val |= (this.data[this.ptr]! << (8 - this.bitPos));
|
|
this.bitPos = newPos - 8;
|
|
} else {
|
|
this.bitPos = newPos;
|
|
}
|
|
return val & ((1 << cnt) - 1);
|
|
}
|
|
|
|
// Offset-binary signed: stored as unsigned + (1 << (cnt-1)), so decode = val - (1 << (cnt-1))
|
|
// Matches: v = get_unsigned(cnt); d = 1 << (cnt-1); v -= d;
|
|
readSigned(cnt: number): number {
|
|
return this.read(cnt) - (1 << (cnt - 1));
|
|
}
|
|
}
|
|
|
|
function readU16BE(data: Uint8Array, off: number): number {
|
|
return ((data[off]! << 8) | data[off + 1]!) >>> 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Font header (23 bytes)
|
|
// Offsets 17/18, 19/20, 21/22 are relative to end of header (byte 23)
|
|
// ---------------------------------------------------------------------------
|
|
function parseHeader(font: Uint8Array) {
|
|
return {
|
|
m0: font[2]!,
|
|
m1: font[3]!,
|
|
bitsPerW: font[4]!,
|
|
bitsPerH: font[5]!,
|
|
bitsPerX: font[6]!,
|
|
bitsPerY: font[7]!,
|
|
bitsPerDx: font[8]!,
|
|
ascentA: font[13]! as number,
|
|
// offsets stored as BE uint16, relative to end of 23-byte header
|
|
startUpper: HDR_SIZE + readU16BE(font, 17),
|
|
startLower: HDR_SIZE + readU16BE(font, 19),
|
|
startUnicode: HDR_SIZE + readU16BE(font, 21),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RLE decoder - matches fd_decode / u8g2_font_decode_glyph in the C source.
|
|
//
|
|
// The bitstream is a sequence of (zeros-run, ones-run) pairs, repeated.
|
|
// Each pair:
|
|
// [m0 bits] count of zero pixels
|
|
// [m1 bits] count of one pixels
|
|
// [unary] repetition: read 1-bits until a 0-stop; reps = (number of 1s) + 1
|
|
//
|
|
// This matches bg_rle_compress which encodes pairs and then a unary repeat.
|
|
// ---------------------------------------------------------------------------
|
|
function decodeRLE(
|
|
br: BitReader,
|
|
w: number,
|
|
h: number,
|
|
m0: number,
|
|
m1: number,
|
|
): Uint8Array {
|
|
const total = w * h;
|
|
const bits = new Uint8Array(total);
|
|
let filled = 0;
|
|
|
|
while (filled < total) {
|
|
const zeros = br.read(m0);
|
|
const ones = br.read(m1);
|
|
let reps = 1;
|
|
while (br.read(1) === 1) reps++;
|
|
|
|
for (let r = 0; r < reps && filled < total; r++) {
|
|
for (let p = 0; p < zeros && filled < total; p++) bits[filled++] = 0;
|
|
for (let p = 0; p < ones && filled < total; p++) bits[filled++] = 1;
|
|
}
|
|
}
|
|
return bits;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Glyph result
|
|
// ---------------------------------------------------------------------------
|
|
export interface U8G2GlyphData {
|
|
cols: Uint8Array | Uint32Array; // column words (bit 0 = top row), length = w
|
|
w: number;
|
|
h: number;
|
|
dx: number; // horizontal advance
|
|
yOff: number; // downward offset from top of cell to top of glyph
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Decode the glyph bitstream at byteOffset (after the record header bytes).
|
|
// Field order from C source (u8g2_font.c lines 590-625):
|
|
// glyph_width, glyph_height, x (signed), y (signed), delta_x (signed)
|
|
// ---------------------------------------------------------------------------
|
|
function decodeGlyphAt(
|
|
font: Uint8Array,
|
|
byteOffset: number,
|
|
hdr: ReturnType<typeof parseHeader>,
|
|
): U8G2GlyphData {
|
|
const br = new BitReader(font, byteOffset);
|
|
|
|
const w = br.read(hdr.bitsPerW);
|
|
const h = br.read(hdr.bitsPerH);
|
|
/* x */ br.readSigned(hdr.bitsPerX); // x bearing - consumed but not needed
|
|
const yB = br.readSigned(hdr.bitsPerY);
|
|
const dx = br.readSigned(hdr.bitsPerDx);
|
|
|
|
if (w === 0 || h === 0) {
|
|
// Blank glyph (e.g. space) - advance is stored in dx
|
|
return { cols: new Uint8Array(1), w: 1, h: 0, dx: Math.max(dx, 1), yOff: 0 };
|
|
}
|
|
|
|
// Decode row-major horizontal bitmap
|
|
const bits = decodeRLE(br, w, h, hdr.m0, hdr.m1);
|
|
|
|
// Convert row-major → column words (bit 0 = top row) for rasterizeText
|
|
// Must be Uint32Array - glyphs can be up to 32px tall; Uint8Array truncates rows 8+
|
|
const cols = new Uint32Array(w);
|
|
for (let row = 0; row < h; row++) {
|
|
for (let col = 0; col < w; col++) {
|
|
if (bits[row * w + col]) cols[col] |= (1 << row);
|
|
}
|
|
}
|
|
|
|
// yB: signed distance from baseline upward to bottom of glyph bounding box.
|
|
// ascentA: pixels above baseline for cap-height reference.
|
|
// yOff: distance downward from top of the notional box to top of this glyph.
|
|
const yOff = hdr.ascentA - yB - h;
|
|
|
|
return { cols, w, h, dx, yOff };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Glyph search
|
|
//
|
|
// Record layout (cp < 0x0100):
|
|
// [1 byte] encoding
|
|
// [1 byte] jump = byte distance from START of this record to next record
|
|
// [bitstream ...]
|
|
//
|
|
// Record layout (cp >= 0x0100):
|
|
// [2 bytes BE] encoding
|
|
// [1 byte] jump (same semantics)
|
|
// [bitstream ...]
|
|
//
|
|
// Unicode section is preceded by a lookup table (added in v2.23):
|
|
// pairs of [uint16 BE delta | uint16 BE last-encoding], terminated by last==0xFFFF
|
|
// delta is cumulative byte offset from TABLE START to the glyph sub-block.
|
|
// ---------------------------------------------------------------------------
|
|
export function u8g2Glyph(cp: number, font: Uint8Array): U8G2GlyphData | null {
|
|
if (font.length < HDR_SIZE) return null;
|
|
const hdr = parseHeader(font);
|
|
|
|
try {
|
|
if (cp >= 0x0100) {
|
|
// -- Unicode block ----------------------------------------------------
|
|
const tableBase = hdr.startUnicode;
|
|
if (tableBase + 4 > font.length) return null;
|
|
|
|
// Walk the jump table (BE uint16 pairs) to find the right sub-block.
|
|
// delta is offset from tableBase to start of sub-block.
|
|
let glyphStart = tableBase;
|
|
let pos = tableBase;
|
|
while (pos + 4 <= font.length) {
|
|
const delta = readU16BE(font, pos);
|
|
const lastEncoding = readU16BE(font, pos + 2);
|
|
pos += 4;
|
|
if (lastEncoding === 0xFFFF) {
|
|
glyphStart = tableBase + delta;
|
|
break;
|
|
}
|
|
if (lastEncoding >= cp) {
|
|
glyphStart = tableBase + delta;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Scan glyph records in the sub-block
|
|
let recPos = glyphStart;
|
|
while (recPos + 3 <= font.length) {
|
|
const recStart = recPos;
|
|
const encoding = readU16BE(font, recPos);
|
|
const jump = font[recPos + 2]!;
|
|
if (jump === 0) break;
|
|
recPos += 3; // bitstream starts here
|
|
if (encoding === cp) return decodeGlyphAt(font, recPos, hdr);
|
|
recPos = recStart + jump;
|
|
}
|
|
return null;
|
|
|
|
} else {
|
|
// -- ASCII block (cp < 0x0100) ----------------------------------------
|
|
// Jump-start from the nearest block offset to avoid scanning from byte 0.
|
|
let recPos: number;
|
|
if (cp >= 0x61) recPos = hdr.startLower;
|
|
else if (cp >= 0x41) recPos = hdr.startUpper;
|
|
else recPos = HDR_SIZE;
|
|
|
|
while (recPos + 2 <= font.length) {
|
|
const recStart = recPos;
|
|
const encoding = font[recPos]!;
|
|
const jump = font[recPos + 1]!;
|
|
if (jump === 0) break;
|
|
recPos += 2; // bitstream starts here
|
|
if (encoding === cp) return decodeGlyphAt(font, recPos, hdr);
|
|
recPos = recStart + jump;
|
|
}
|
|
return null;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Text → pixel-image rasteriser
|
|
// ---------------------------------------------------------------------------
|
|
export function rasterizeText(
|
|
text: string,
|
|
cellHeight: number,
|
|
font: { FONT_DATA: Uint8Array },
|
|
): { pixels: Uint8Array; width: number; height: number } {
|
|
const hdr = parseHeader(font.FONT_DATA);
|
|
const glyphs: Array<U8G2GlyphData> = [];
|
|
|
|
for (const char of text) {
|
|
const cp = char.codePointAt(0) ?? 0x3F;
|
|
const g = u8g2Glyph(cp, font.FONT_DATA);
|
|
if (g) glyphs.push(g);
|
|
}
|
|
|
|
if (glyphs.length === 0) {
|
|
return { pixels: new Uint8Array(cellHeight), width: 1, height: cellHeight };
|
|
}
|
|
|
|
// Total width: sum all advances, but last glyph uses its actual pixel width
|
|
const totalW = glyphs.reduce((sum, g, i) =>
|
|
sum + (i < glyphs.length - 1 ? g.dx : g.w), 0);
|
|
|
|
const pixels = new Uint8Array(totalW * cellHeight);
|
|
|
|
// Top-align the full font (ascent + |descent|) within the cell.
|
|
// header byte 14 is descent of 'g' - stored as a negative signed value.
|
|
const descent = font.FONT_DATA[14]! > 127 ? font.FONT_DATA[14]! - 256 : font.FONT_DATA[14]!;
|
|
const fontH = hdr.ascentA - descent; // total font height in pixels
|
|
const topPad = 0;
|
|
const baseline = topPad + hdr.ascentA;
|
|
|
|
let x = 0;
|
|
for (const g of glyphs) {
|
|
// yOff = ascentA - yBearing - h (may be negative if glyph taller than 'A')
|
|
const top = baseline - hdr.ascentA + g.yOff;
|
|
for (let col = 0; col < g.w; col++) {
|
|
const colByte = g.cols[col] ?? 0;
|
|
for (let row = 0; row < g.h; row++) {
|
|
if ((colByte >> row) & 1) {
|
|
const py = top + row;
|
|
if (py >= 0 && py < cellHeight) {
|
|
pixels[py * totalW + x + col] = 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
x += g.dx;
|
|
}
|
|
|
|
return { pixels, width: totalW, height: cellHeight };
|
|
}
|
|
|