const ESC = 0x1b const GS = 0x1d /** * ESC/POS byte builder โ€” pure, so receipt layout is testable without a * printer. Spike scope is ASCII text mode; Indic scripts rasterize in * bit-image mode later (09-UX ยง5.3 print policy). */ export class EscPos { private bytes: number[] = [] init(): this { this.bytes.push(ESC, 0x40) return this } /** Non-ASCII collapses to '?' in text mode โ€” raster path handles scripts. */ text(s: string): this { for (const ch of s) { const code = ch.codePointAt(0)! this.bytes.push(code >= 0x20 && code <= 0x7e ? code : 0x3f) } return this } line(s = ''): this { return this.text(s).newline() } newline(): this { this.bytes.push(0x0a) return this } align(where: 'left' | 'center' | 'right'): this { this.bytes.push(ESC, 0x61, where === 'left' ? 0 : where === 'center' ? 1 : 2) return this } bold(on: boolean): this { this.bytes.push(ESC, 0x45, on ? 1 : 0) return this } /** Character cell multiplier 1โ€“8 each axis (GS !). */ size(w: number, h: number): this { this.bytes.push(GS, 0x21, ((w - 1) << 4) | (h - 1)) return this } feed(lines: number): this { this.bytes.push(ESC, 0x64, lines) return this } /** Partial cut with pre-feed (GS V 66). */ cut(): this { this.bytes.push(GS, 0x56, 66, 3) return this } /** Cash-drawer kick pulse on pin 2 (ESC p). Fires on payment, not on print. */ drawerKick(): this { this.bytes.push(ESC, 0x70, 0, 25, 250) return this } build(): Uint8Array { return Uint8Array.from(this.bytes) } } /** Left/right column row padded to exactly `width` chars; left side truncates. */ export function twoCol(left: string, right: string, width: number): string { const room = width - right.length - 1 const l = left.length > room ? left.slice(0, Math.max(0, room)) : left return l + ' '.repeat(Math.max(1, width - l.length - right.length)) + right } export function hr(width: number): string { return '-'.repeat(width) }