// ============================================================================= // MonoDisplay Library — binary format parser + canvas renderer // // Binary format (little-endian throughout): // [FILE HEADER] // 4 bytes magic "MONO" (0x4D 0x4F 0x4E 0x4F) // 1 byte version format version (currently 1) // 1 byte content_type 0=static 1=animation 2=text 3=scrolltext // 2 bytes width display width in pixels // 2 bytes height display height in pixels // // [CONTENT — varies by content_type] // static: // (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte // // animation: // 2 bytes frame_count // per frame: // 4 bytes duration_ms // (width*height) bytes pixel rows same as static // // text: // 2 bytes text_len // text_len bytes UTF-8 string (no null terminator) // 1 byte font_id reserved, ignored for now // 1 byte align 0=left 1=center 2=right // // scrolltext: // 2 bytes text_len // text_len bytes UTF-8 string // 2 bytes speed_pps pixels per second (scroll rate) // 1 byte direction 0=left 1=right // // PLAN: once the real spec arrives, swap out header offsets / field widths here. // All parsing is centralised in MonoDisplayParser.parse() so callers never // touch raw offsets. // ============================================================================= // --------------------------------------------------------------------------- // Public types — all exported so browser users can import them // --------------------------------------------------------------------------- /** Supported content types embedded in a .bin display file */ export type DisplayContentType = "static" | "animation" | "text" | "scrolltext"; /** A single monochrome bitmap. Pixels are row-major, 1 byte per pixel (0/1). */ export interface StaticFrame { type: "static"; width: number; height: number; /** Unpacked: 1 byte per pixel, 0 = off, 1 = on */ pixels: Uint8Array; } /** One frame inside an animation */ export interface AnimFrame { /** How long this frame is displayed */ durationMs: number; pixels: Uint8Array; } export interface Animation { type: "animation"; width: number; height: number; frames: AnimFrame[]; } export interface TextDisplay { type: "text"; text: string; /** 0=left 1=center 2=right — used by renderer */ align: 0 | 1 | 2; /** Reserved font index; renderer maps this to an embedded bitmap font */ fontId: number; } export interface ScrollText { type: "scrolltext"; text: string; /** Scroll speed in pixels per second */ speedPps: number; /** 0=left 1=right */ direction: 0 | 1; } export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; // --------------------------------------------------------------------------- // MonoDisplayFile — JSON-descriptor → binary builder // --------------------------------------------------------------------------- /** * Element types that can appear in a display descriptor. * PLAN: extend with Text, ScrollText, Animation once section spec is complete. */ export enum ElementType { /** A 1bpp rectangular bitmap placed at an (xoffset, yoffset) position */ Image2D = "Image2D", // PLAN: Text = "Text", // PLAN: ScrollText = "ScrollText", // PLAN: Animation = "Animation", } /** A positioned 1bpp image placed on the display canvas */ export interface Image2DElement { type: ElementType.Image2D; /** Raw pixel data — 1 byte per pixel (0=off 1=on), row-major */ pixels: Uint8Array; width: number; height: number; /** Horizontal offset from display left edge. Default 0. */ xoffset?: number; /** Vertical offset from display top edge. Default 0. */ yoffset?: number; } // Union for future element types export type DisplayElement = Image2DElement; // PLAN: | TextElement | ScrollTextElement | AnimationElement /** * JSON descriptor passed to MonoDisplayFile. * * elements_always — rendered on every frame regardless of state. * PLAN: elements_conditional — rendered only when a named condition is active * (e.g. for state-machine-driven displays, ticker overrides, alerts). * * width / height — display canvas size in pixels. * If omitted, inferred as the bounding box of all elements. * Explicit values are required if elements don't fill the display. */ export interface MonoDisplayFileDescriptor { width?: number; height?: number; elements_always?: DisplayElement[]; } /** * Builds a binary .bin file from a JSON descriptor. * * Usage: * ```ts * const file = new MonoDisplayFile({ * width: 160, height: 80, * elements_always: [ * { type: ElementType.Image2D, pixels: myUint8Array, width: 120, height: 80, xoffset: 20 } * ] * }); * driver.load(() => Promise.resolve(file.toBuffer())); * ``` * * PLAN: toBuffer() currently composites all elements_always into one static frame. * When animated elements are added, it will produce an animation section instead. */ export class MonoDisplayFile { private desc: MonoDisplayFileDescriptor; constructor(descriptor: MonoDisplayFileDescriptor) { this.desc = descriptor; } /** * Serialize to a valid .bin ArrayBuffer that MonoDisplayParser can read. * Composites all elements_always onto a single static frame. */ toBuffer(): ArrayBuffer { const { width, height } = this.#resolveSize(); const canvas = this.#composite(width, height); // Encode as static section (0x01): width(u16) height(u16) pixels[w*h] const sectionDataSize = 2 + 2 + width * height; const totalSize = 12 + 4 + sectionDataSize; // fileHeader + sectionHeader + data const buf = new ArrayBuffer(totalSize); const view = new DataView(buf); let off = 0; // File header view.setUint32(off, MAGIC, true); off += 4; // magic view.setUint32(off, 1, true); off += 4; // version = 1 view.setUint16(off, 1, true); off += 2; // numSections = 1 view.setUint16(off, 0, true); off += 2; // reserved = 0 // Section header: type(1) + size uint24(3) view.setUint8(off, 0x01); off += 1; // static section view.setUint8(off, sectionDataSize & 0xFF); view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); off += 3; // Section data view.setUint16(off, width, true); off += 2; view.setUint16(off, height, true); off += 2; new Uint8Array(buf, off).set(canvas); return buf; } /** Determine display dimensions: explicit > inferred from element bounding box > defaults */ #resolveSize(): { width: number; height: number } { const elements = this.desc.elements_always ?? []; let width = this.desc.width; let height = this.desc.height; if (width === undefined || height === undefined) { let maxX = 0, maxY = 0; for (const el of elements) { maxX = Math.max(maxX, (el.xoffset ?? 0) + el.width); maxY = Math.max(maxY, (el.yoffset ?? 0) + el.height); } width ??= maxX || 160; height ??= maxY || 80; } return { width, height }; } /** Composite all elements_always onto a blank canvas (0=off, 1=on) */ #composite(width: number, height: number): Uint8Array { const canvas = new Uint8Array(width * height); // starts all-off for (const el of this.desc.elements_always ?? []) { this.#blitElement(el, canvas, width, height); } return canvas; } /** Blit one element onto the canvas, clipping to display bounds */ #blitElement(el: DisplayElement, canvas: Uint8Array, canvasW: number, canvasH: number): void { if (el.type !== ElementType.Image2D) { // PLAN: dispatch other element types here throw new Error(`MonoDisplayFile: element type "${el.type}" not yet implemented`); } const ox = el.xoffset ?? 0; const oy = el.yoffset ?? 0; for (let row = 0; row < el.height; row++) { const destY = oy + row; if (destY < 0 || destY >= canvasH) continue; // clip top/bottom for (let col = 0; col < el.width; col++) { const destX = ox + col; if (destX < 0 || destX >= canvasW) continue; // clip left/right const srcIdx = row * el.width + col; const destIdx = destY * canvasW + destX; // OR-blend: any element can turn a pixel on; none can turn it off // PLAN: add blend mode (OR / XOR / replace) to Image2DElement once spec covers it if (el.pixels[srcIdx]) canvas[destIdx] = 1; } } } } /** Optional constructor arguments for MonoDisplayDriver */ export interface MonoDisplayDriverOptions { /** CSS colour for "on" pixels; default "#ffffff" */ onColor?: string; /** CSS colour for "off" pixels / background; default "#000000" */ offColor?: string; /** * Scale factor: each logical pixel becomes NxN canvas pixels. * Default 1. When CSS stretches the canvas for display, keep this at 1 * and let the browser handle upscaling (use image-rendering: pixelated). */ scale?: number; /** * Logical display width in pixels. Used as canvas resolution baseline for * content types that have no inherent size (text, scrolltext). * Also used when static/animation content overflows this width — clipped. * Default 160. */ displayWidth?: number; /** * Logical display height in pixels. Default 80. */ displayHeight?: number; /** * If true, driver loops animations forever. Default true. * Set false to stop after one pass. */ loop?: boolean; /** * Target render rate in frames per second for continuous content * (scrolltext, animation). Default 25. The browser rAF / setTimeout loop * skips drawing if the frame would land earlier than 1000/fps ms after the * previous draw, keeping CPU/GPU load close to the target rate. * Note: animation frame *duration* from the file takes precedence when it * is longer than the render interval (slow animations stay slow). */ fps?: number; /** * Callback fired whenever the driver encounters a parse or render error. * If omitted, errors are thrown. */ onError?: (err: Error) => void; } // --------------------------------------------------------------------------- // BinaryReader — little-endian cursor over an ArrayBuffer // --------------------------------------------------------------------------- /** * Stateful cursor that reads little-endian primitives from an ArrayBuffer. * * PLAN: if the spec ever needs signed types (int16, int32) or floats, add * readInt16 / readFloat32 etc. here — all other code stays unchanged. */ export class BinaryReader { private view: DataView; private pos: number = 0; constructor(buffer: ArrayBuffer) { this.view = new DataView(buffer); } get offset(): number { return this.pos; } get byteLength(): number { return this.view.byteLength; } get remaining(): number { return this.view.byteLength - this.pos; } /** Read one unsigned 8-bit integer */ readByte(): number { this.#assertRemaining(1); return this.view.getUint8(this.pos++); } /** Read unsigned 16-bit integer, little-endian */ readUint16(): number { this.#assertRemaining(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; } /** Alias kept for API clarity ("short" in spec language) */ readShort(): number { return this.readUint16(); } /** Read unsigned 32-bit integer, little-endian */ readUint32(): number { this.#assertRemaining(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; } /** * Read unsigned 64-bit integer, little-endian. * Returns bigint because JS number cannot represent all uint64 values. */ readUint64(): bigint { this.#assertRemaining(8); const lo = BigInt(this.view.getUint32(this.pos, true)); const hi = BigInt(this.view.getUint32(this.pos + 4, true)); this.pos += 8; return (hi << 32n) | lo; } /** Read N raw bytes as a Uint8Array (zero-copy view) */ readBytes(n: number): Uint8Array { this.#assertRemaining(n); const slice = new Uint8Array(this.view.buffer, this.pos, n); this.pos += n; return slice; } /** Read N bytes and decode as UTF-8 */ readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); } /** Read unsigned 24-bit integer, little-endian (3-byte field in section headers) */ readUint24(): number { this.#assertRemaining(3); const lo = this.view.getUint8(this.pos); const mid = this.view.getUint8(this.pos + 1); const hi = this.view.getUint8(this.pos + 2); this.pos += 3; return lo | (mid << 8) | (hi << 16); } /** Move cursor to absolute byte position */ seek(pos: number): void { if (pos < 0 || pos > this.view.byteLength) { throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); } this.pos = pos; } #assertRemaining(n: number): void { if (this.remaining < n) { throw new RangeError( `BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain` ); } } } // --------------------------------------------------------------------------- // File format constants (real spec) // --------------------------------------------------------------------------- // File magic: 0xAF 0x7E 0x2B 0x63 (read as uint32 LE = 0x632B7EAF) const MAGIC = 0x632B7EAF; /** * File header layout (12 bytes total): * * offset size field * ────── ──── ───────────────────────────────────────────── * 0 4 Magic (0xAF 0x7E 0x2B 0x63) * 4 4 Version (uint32 LE; 0=illegal, 1=current, 2+=reserved) * PLAN: spec shows full 4-byte row — confirm if uint8+padding or uint32 * 8 2 Number of sections (uint16 LE) * 10 2 Reserved (must be 0) * * Section header layout (4 bytes): * * offset size field * ────── ──── ───────────────────────────────────────────── * 0 1 Section type (uint8) * 1 3 Section data size in bytes (uint24 LE), excludes header * * Section types (placeholder values — PLAN: confirm with full spec): * 0x01 static frame width(u16) height(u16) pixels[w*h] * 0x02 animation width(u16) height(u16) frameCount(u16) frames… * 0x03 text textLen(u16) utf8… fontId(u8) align(u8) * 0x04 scrolltext textLen(u16) utf8… speedPps(u16) direction(u8) */ const SectionType: Record = { 0x01: "static", 0x02: "animation", 0x03: "text", 0x04: "scrolltext", }; // --------------------------------------------------------------------------- // MonoDisplayParser // --------------------------------------------------------------------------- /** * Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. * * Returns the first recognised section's content. Files with numSections=0 * throw. Multiple sections: only the first supported type is returned for now. * PLAN: expose all sections as DisplayContent[] once the full spec is clear. */ export class MonoDisplayParser { parse(buffer: ArrayBuffer): DisplayContent { const r = new BinaryReader(buffer); // --- file header (12 bytes) --- const magic = r.readUint32(); if (magic !== MAGIC) { throw new Error( `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x632B7EAF` ); } const version = r.readUint32(); // PLAN: confirm uint32 vs uint8+3pad in spec if (version === 0 || version > 1) { throw new Error(`Unsupported format version ${version}`); } const numSections = r.readUint16(); r.readUint16(); // reserved — ignored if (numSections === 0) { throw new Error("File contains 0 sections — nothing to render"); } // --- section loop --- for (let i = 0; i < numSections; i++) { // section header (4 bytes) const sectionType = r.readByte(); const sectionSize = r.readUint24(); const sectionStart = r.offset; const contentType = SectionType[sectionType]; if (!contentType) { // Unknown section — skip it and continue to next r.seek(sectionStart + sectionSize); continue; } // Parse this section; width/height read from section data for bitmap types const content = this.#parseSection(r, contentType); // Seek past any remaining section bytes (handles padding / future fields) r.seek(sectionStart + sectionSize); return content; // return first recognised section } throw new Error("No recognised sections found in file"); } #parseSection(r: BinaryReader, contentType: DisplayContentType): DisplayContent { switch (contentType) { case "static": { const width = r.readUint16(); const height = r.readUint16(); return this.#parseStatic(r, width, height); } case "animation": { const width = r.readUint16(); const height = r.readUint16(); return this.#parseAnimation(r, width, height); } case "text": return this.#parseText(r); case "scrolltext": return this.#parseScrollText(r); } } #parseStatic(r: BinaryReader, width: number, height: number): StaticFrame { // PLAN: spec may use packed 1bpp; for now each byte = 1 pixel (0 or 1) const raw = r.readBytes(width * height); return { type: "static", width, height, pixels: new Uint8Array(raw) }; } #parseAnimation(r: BinaryReader, width: number, height: number): Animation { const frameCount = r.readUint16(); const frames: AnimFrame[] = []; for (let i = 0; i < frameCount; i++) { const durationMs = r.readUint32(); const raw = r.readBytes(width * height); frames.push({ durationMs, pixels: new Uint8Array(raw) }); } return { type: "animation", width, height, frames }; } #parseText(r: BinaryReader): TextDisplay { const textLen = r.readUint16(); const text = r.readUtf8(textLen); const fontId = r.readByte(); const alignByte = r.readByte(); const align = (alignByte === 1 ? 1 : alignByte === 2 ? 2 : 0) as 0 | 1 | 2; return { type: "text", text, fontId, align }; } #parseScrollText(r: BinaryReader): ScrollText { const textLen = r.readUint16(); const text = r.readUtf8(textLen); const speedPps = r.readUint16(); const dirByte = r.readByte(); const direction = dirByte === 1 ? 1 : 0 as 0 | 1; return { type: "scrolltext", text, speedPps, direction }; } } // --------------------------------------------------------------------------- // MonoDisplayRenderer — draws DisplayContent onto an HTMLCanvasElement // --------------------------------------------------------------------------- /** * Internal renderer; MonoDisplayDriver owns one instance. * * PLAN: text/scrolltext rendering currently draws a placeholder box. * Replace #renderText / #renderScrollText with a real embedded bitmap font * (5x7 or similar) once the font spec is finalised. */ export class MonoDisplayRenderer { private ctx: CanvasRenderingContext2D; private opts: Required; // Animation state private animFrame: number = 0; private animTimer: number | null = null; // ScrollText state private scrollX: number = 0; private scrollRaf: number | null = null; // Tracks last rAF timestamp for motion integration (runs every rAF) private scrollLastTs: number | null = null; // Tracks last actual draw timestamp for fps throttle private scrollLastDrawTs: number | null = null; constructor(canvas: HTMLCanvasElement, opts: Required) { const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Cannot get 2D canvas context"); this.ctx = ctx; this.opts = opts; } /** Stop any running animation / scroll before loading new content */ stop(): void { if (this.animTimer !== null) { clearTimeout(this.animTimer); this.animTimer = null; } if (this.scrollRaf !== null) { cancelAnimationFrame(this.scrollRaf); this.scrollRaf = null; } this.scrollLastTs = null; this.scrollLastDrawTs = null; } render(content: DisplayContent): void { this.stop(); switch (content.type) { case "static": this.#renderStatic(content); break; case "animation": this.#startAnimation(content); break; case "text": this.#renderText(content); break; case "scrolltext": this.#startScrollText(content); break; } } #resizeCanvas(w: number, h: number): void { const s = this.opts.scale; this.ctx.canvas.width = w * s; this.ctx.canvas.height = h * s; } #renderPixels(pixels: Uint8Array, width: number, height: number): void { const { onColor, offColor, scale: s } = this.opts; const ctx = this.ctx; ctx.fillStyle = offColor; ctx.fillRect(0, 0, width * s, height * s); ctx.fillStyle = onColor; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { if (pixels[y * width + x]) { ctx.fillRect(x * s, y * s, s, s); } } } } #renderStatic(content: StaticFrame): void { this.#resizeCanvas(content.width, content.height); this.#renderPixels(content.pixels, content.width, content.height); } #startAnimation(content: Animation): void { this.#resizeCanvas(content.width, content.height); this.animFrame = 0; const tick = () => { if (content.frames.length === 0) return; const frame = content.frames[this.animFrame]; this.#renderPixels(frame.pixels, content.width, content.height); this.animFrame++; if (this.animFrame >= content.frames.length) { if (!this.opts.loop) return; this.animFrame = 0; } // Clamp to fps budget: never draw faster than 1000/fps ms per frame const minInterval = 1000 / this.opts.fps; this.animTimer = setTimeout(tick, Math.max(frame.durationMs, minInterval)) as unknown as number; }; tick(); } #renderText(content: TextDisplay): void { // PLAN: replace with bitmap font renderer; align/fontId will be honoured then const ctx = this.ctx; const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; ctx.canvas.width = dw * s; ctx.canvas.height = dh * s; ctx.fillStyle = offColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = onColor; // Scale font to roughly fill display height; real bitmap font will replace this const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); ctx.font = `${fontSize}px monospace`; ctx.textBaseline = "middle"; const padding = 4 * s; ctx.fillText(content.text, padding, ctx.canvas.height / 2); } #startScrollText(content: ScrollText): void { // PLAN: replace inner draw with bitmap font; direction/speed wired in const ctx = this.ctx; const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; ctx.canvas.width = dw * s; ctx.canvas.height = dh * s; // Start position: off-screen on the entry side this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; const tick = (ts: number) => { // --- motion integration runs every rAF for smooth position accumulation --- const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; this.scrollLastTs = ts; const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor, fps } = this.opts; const frameInterval = 1000 / fps; // e.g. 40ms at 25fps // speedPps is in logical pixels/s; scale to canvas pixels const delta = content.speedPps * s * dt * (content.direction === 1 ? 1 : -1); this.scrollX += delta; // codepoint count (not UTF-16 units) for width estimate const cpCount = [...content.text].length; const textWidth = cpCount * 8 * s; // rough 8px/cp until bitmap font lands if (content.direction === 0 && this.scrollX < -textWidth) { this.scrollX = ctx.canvas.width; } else if (content.direction === 1 && this.scrollX > ctx.canvas.width) { this.scrollX = -textWidth; } // --- draw only when fps budget allows (~25Hz) --- const sinceLastDraw = this.scrollLastDrawTs !== null ? ts - this.scrollLastDrawTs : Infinity; if (sinceLastDraw >= frameInterval) { this.scrollLastDrawTs = ts; ctx.fillStyle = offColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.fillStyle = onColor; const fontSize = Math.max(8, Math.floor(dh * s * 0.6)); ctx.font = `${fontSize}px monospace`; ctx.textBaseline = "middle"; ctx.fillText(content.text, this.scrollX, ctx.canvas.height / 2); } this.scrollRaf = requestAnimationFrame(tick); }; this.scrollRaf = requestAnimationFrame(tick); } } // --------------------------------------------------------------------------- // MonoDisplayDriver — public API surface // --------------------------------------------------------------------------- /** * Top-level driver. Attach to a canvas element by ID, then call load() with * a function that returns a Promise of the .bin file. * * @example * ```ts * const driver = new MonoDisplayDriver("canvas_root"); * driver.load(() => loadBinFile("default_test.bin")); * ``` * * PLAN: add driver.stop() / driver.play() / driver.setContent(DisplayContent) * to allow external playback control without reloading. */ export class MonoDisplayDriver { private canvas: HTMLCanvasElement; private opts: Required; private parser: MonoDisplayParser; private renderer: MonoDisplayRenderer | null = null; constructor(canvasId: string, options: MonoDisplayDriverOptions = {}) { const el = document.getElementById(canvasId); if (!el || el.tagName !== "CANVAS") { throw new Error(`MonoDisplayDriver: element #${canvasId} is not a `); } this.canvas = el as HTMLCanvasElement; // Apply defaults this.opts = { onColor: options.onColor ?? "#ffffff", offColor: options.offColor ?? "#000000", scale: options.scale ?? 1, displayWidth: options.displayWidth ?? 160, displayHeight: options.displayHeight ?? 80, fps: options.fps ?? 25, loop: options.loop ?? true, onError: options.onError ?? ((e: Error) => { throw e; }), }; this.parser = new MonoDisplayParser(); } /** * Load binary data from the provided async factory, parse, and render. * Calling load() again stops any current playback and starts fresh. * * @param loader Async function returning the raw .bin ArrayBuffer */ async load(loader: () => Promise): Promise { try { const buffer = await loader(); const content = this.parser.parse(buffer); // Lazy-create renderer on first load, reuse after if (!this.renderer) { this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); } this.renderer.render(content); } catch (err) { this.opts.onError(err instanceof Error ? err : new Error(String(err))); } } /** Stop current animation / scroll without unloading */ stop(): void { this.renderer?.stop(); } } // --------------------------------------------------------------------------- // Helpers exposed for browser convenience // --------------------------------------------------------------------------- /** * Fetch a .bin file by URL and return its ArrayBuffer. * Intended as the `loader` argument to driver.load(). * * @example * ```ts * driver.load(() => loadBinFile("/assets/default_test.bin")); * ``` */ export async function loadBinFile(url: string): Promise { const res = await fetch(url); if (!res.ok) throw new Error(`loadBinFile: HTTP ${res.status} for ${url}`); return res.arrayBuffer(); } /** * Build a valid .bin ArrayBuffer for testing and demos. * Encodes one section using the real file format. * * PLAN: expand into a full BinaryWriter for all section types once spec is complete. * Currently only "static" section is encoded; others throw. */ export function buildBinBuffer(content: DisplayContent): ArrayBuffer { if (content.type !== "static") { throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); } // Section data: width(u16) + height(u16) + pixels[w*h] const sectionDataSize = 2 + 2 + content.width * content.height; // File header: magic(4) + version(4) + numSections(2) + reserved(2) = 12 // Section header: type(1) + size(3) = 4 const totalSize = 12 + 4 + sectionDataSize; const buf = new ArrayBuffer(totalSize); const view = new DataView(buf); let off = 0; // File header view.setUint32(off, MAGIC, true); off += 4; // magic view.setUint32(off, 1, true); off += 4; // version = 1 view.setUint16(off, 1, true); off += 2; // numSections = 1 view.setUint16(off, 0, true); off += 2; // reserved = 0 // Section header view.setUint8(off, 0x01); off += 1; // section type = static // uint24 LE for section size view.setUint8(off, sectionDataSize & 0xFF); view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); off += 3; // Section data view.setUint16(off, content.width, true); off += 2; view.setUint16(off, content.height, true); off += 2; new Uint8Array(buf, off).set(content.pixels); return buf; }