// --------------------------------------------------------------------------- // 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, ): 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 = []; 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 }; }