From 68ac1148723c864314b481530ccb50f7315445fc Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] fix: restructure the library --- ts/public/bundle.js | 428 ------------------- ts/src/driver.ts | 88 ++++ ts/src/file.ts | 260 ++++++++++++ ts/src/helper.ts | 102 +++++ ts/src/library.ts | 989 +------------------------------------------- ts/src/parser.ts | 254 ++++++++++++ ts/src/renderer.ts | 251 +++++++++++ 7 files changed, 973 insertions(+), 1399 deletions(-) delete mode 100644 ts/public/bundle.js create mode 100644 ts/src/driver.ts create mode 100644 ts/src/file.ts create mode 100644 ts/src/helper.ts create mode 100644 ts/src/parser.ts create mode 100644 ts/src/renderer.ts diff --git a/ts/public/bundle.js b/ts/public/bundle.js deleted file mode 100644 index 1c9cfa8..0000000 --- a/ts/public/bundle.js +++ /dev/null @@ -1,428 +0,0 @@ -// src/library.ts -class BinaryReader { - view; - pos = 0; - constructor(buffer) { - this.view = new DataView(buffer); - } - get offset() { - return this.pos; - } - get byteLength() { - return this.view.byteLength; - } - get remaining() { - return this.view.byteLength - this.pos; - } - readByte() { - this.#assertRemaining(1); - return this.view.getUint8(this.pos++); - } - readUint16() { - this.#assertRemaining(2); - const v = this.view.getUint16(this.pos, true); - this.pos += 2; - return v; - } - readShort() { - return this.readUint16(); - } - readUint32() { - this.#assertRemaining(4); - const v = this.view.getUint32(this.pos, true); - this.pos += 4; - return v; - } - readUint64() { - 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; - } - readBytes(n) { - this.#assertRemaining(n); - const slice = new Uint8Array(this.view.buffer, this.pos, n); - this.pos += n; - return slice; - } - readUtf8(n) { - return new TextDecoder().decode(this.readBytes(n)); - } - seek(pos) { - if (pos < 0 || pos > this.view.byteLength) { - throw new RangeError(`seek(${pos}) out of bounds [0, ${this.view.byteLength}]`); - } - this.pos = pos; - } - #assertRemaining(n) { - if (this.remaining < n) { - throw new RangeError(`BinaryReader: need ${n} byte(s) at offset ${this.pos}, only ${this.remaining} remain`); - } - } -} -var MAGIC = 1330532173; -var ContentTypeByte = { - 0: "static", - 1: "animation", - 2: "text", - 3: "scrolltext" -}; - -class MonoDisplayParser { - parse(buffer) { - const r = new BinaryReader(buffer); - const magic = r.readUint32(); - if (magic !== MAGIC) { - throw new Error(`Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x4F4E4F4D ("MONO")`); - } - const version = r.readByte(); - if (version !== 1) { - throw new Error(`Unsupported format version ${version}`); - } - const contentTypeByte = r.readByte(); - const contentType = ContentTypeByte[contentTypeByte]; - if (!contentType) { - throw new Error(`Unknown content type byte 0x${contentTypeByte.toString(16)}`); - } - const width = r.readUint16(); - const height = r.readUint16(); - switch (contentType) { - case "static": - return this.#parseStatic(r, width, height); - case "animation": - return this.#parseAnimation(r, width, height); - case "text": - return this.#parseText(r); - case "scrolltext": - return this.#parseScrollText(r); - } - } - #parseStatic(r, width, height) { - const raw = r.readBytes(width * height); - return { type: "static", width, height, pixels: new Uint8Array(raw) }; - } - #parseAnimation(r, width, height) { - const frameCount = r.readUint16(); - const frames = []; - 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) { - 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; - return { type: "text", text, fontId, align }; - } - #parseScrollText(r) { - const textLen = r.readUint16(); - const text = r.readUtf8(textLen); - const speedPps = r.readUint16(); - const dirByte = r.readByte(); - const direction = dirByte === 1 ? 1 : 0; - return { type: "scrolltext", text, speedPps, direction }; - } -} - -class MonoDisplayRenderer { - ctx; - opts; - animFrame = 0; - animTimer = null; - scrollX = 0; - scrollRaf = null; - scrollLastTs = null; - constructor(canvas, opts) { - const ctx = canvas.getContext("2d"); - if (!ctx) - throw new Error("Cannot get 2D canvas context"); - this.ctx = ctx; - this.opts = opts; - } - stop() { - if (this.animTimer !== null) { - clearTimeout(this.animTimer); - this.animTimer = null; - } - if (this.scrollRaf !== null) { - cancelAnimationFrame(this.scrollRaf); - this.scrollRaf = null; - } - } - render(content) { - 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, h) { - const s = this.opts.scale; - this.ctx.canvas.width = w * s; - this.ctx.canvas.height = h * s; - } - #renderPixels(pixels, width, height) { - 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) { - this.#resizeCanvas(content.width, content.height); - this.#renderPixels(content.pixels, content.width, content.height); - } - #startAnimation(content) { - 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; - } - this.animTimer = setTimeout(tick, frame.durationMs); - }; - tick(); - } - #renderText(content) { - 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; - 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) { - const ctx = this.ctx; - const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; - ctx.canvas.width = dw * s; - ctx.canvas.height = dh * s; - this.scrollX = content.direction === 1 ? -(content.text.length * 8 * s) : ctx.canvas.width; - const tick = (ts) => { - const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; - this.scrollLastTs = ts; - const { displayWidth: dw2, displayHeight: dh2, scale: s2, offColor, onColor } = this.opts; - const delta = content.speedPps * s2 * dt * (content.direction === 1 ? 1 : -1); - this.scrollX += delta; - const cpCount = [...content.text].length; - const textWidth = cpCount * 8 * s2; - 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; - } - ctx.fillStyle = offColor; - ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.fillStyle = onColor; - const fontSize = Math.max(8, Math.floor(dh2 * s2 * 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); - } -} - -class MonoDisplayDriver { - canvas; - opts; - parser; - renderer = null; - constructor(canvasId, options = {}) { - const el = document.getElementById(canvasId); - if (!el || el.tagName !== "CANVAS") { - throw new Error(`MonoDisplayDriver: element #${canvasId} is not a `); - } - this.canvas = el; - this.opts = { - onColor: options.onColor ?? "#ffffff", - offColor: options.offColor ?? "#000000", - scale: options.scale ?? 1, - displayWidth: options.displayWidth ?? 160, - displayHeight: options.displayHeight ?? 80, - loop: options.loop ?? true, - onError: options.onError ?? ((e) => { - throw e; - }) - }; - this.parser = new MonoDisplayParser; - } - async load(loader) { - try { - const buffer = await loader(); - const content = this.parser.parse(buffer); - 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() { - this.renderer?.stop(); - } -} -function buildBinBuffer(content) { - if (content.type !== "static") { - throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); - } - const headerSize = 4 + 1 + 1 + 2 + 2; - const pixelSize = content.width * content.height; - const buf = new ArrayBuffer(headerSize + pixelSize); - const view = new DataView(buf); - view.setUint32(0, MAGIC, true); - view.setUint8(4, 1); - view.setUint8(5, 0); - view.setUint16(6, content.width, true); - view.setUint16(8, content.height, true); - new Uint8Array(buf, headerSize).set(content.pixels); - return buf; -} -// demo.ts -function makeCheckerboard() { - const W = 160, H = 80; - const pixels = new Uint8Array(W * H); - for (let y = 0;y < H; y++) - for (let x = 0;x < W; x++) - pixels[y * W + x] = (x + y) % 2; - return buildBinBuffer({ type: "static", width: W, height: H, pixels }); -} -function makeAnimation() { - const W = 160, H = 80; - const on = new Uint8Array(W * H).fill(1); - const off = new Uint8Array(W * H).fill(0); - const MAGIC2 = new Uint8Array([77, 79, 78, 79]); - const header = new Uint8Array(10); - header.set(MAGIC2, 0); - header[4] = 1; - header[5] = 1; - new DataView(header.buffer).setUint16(6, W, true); - new DataView(header.buffer).setUint16(8, H, true); - const frameCount = new Uint8Array(2); - new DataView(frameCount.buffer).setUint16(0, 2, true); - function frame(pixels, durationMs) { - const dur = new Uint8Array(4); - new DataView(dur.buffer).setUint32(0, durationMs, true); - const f = new Uint8Array(4 + pixels.byteLength); - f.set(dur, 0); - f.set(pixels, 4); - return f; - } - const parts = [header, frameCount, frame(on, 500), frame(off, 500)]; - const total = parts.reduce((s, p) => s + p.byteLength, 0); - const buf = new Uint8Array(total); - let off2 = 0; - for (const p of parts) { - buf.set(p, off2); - off2 += p.byteLength; - } - return buf.buffer; -} -function makeTextBin(text, align = 1) { - const enc = new TextEncoder().encode(text); - const MAGIC2 = new Uint8Array([77, 79, 78, 79]); - const header = new Uint8Array(10); - header.set(MAGIC2, 0); - header[4] = 1; - header[5] = 2; - const textLen = new Uint8Array(2); - new DataView(textLen.buffer).setUint16(0, enc.byteLength, true); - const tail = new Uint8Array([0, align]); - const total = 10 + 2 + enc.byteLength + 2; - const out = new Uint8Array(total); - let o = 0; - out.set(header, o); - o += 10; - out.set(textLen, o); - o += 2; - out.set(enc, o); - o += enc.byteLength; - out.set(tail, o); - return out.buffer; -} -function makeScrollTextBin(text, speedPps = 60, direction = 0) { - const enc = new TextEncoder().encode(text); - const MAGIC2 = new Uint8Array([77, 79, 78, 79]); - const header = new Uint8Array(10); - header.set(MAGIC2, 0); - header[4] = 1; - header[5] = 3; - const textLen = new Uint8Array(2); - new DataView(textLen.buffer).setUint16(0, enc.byteLength, true); - const speed = new Uint8Array(2); - new DataView(speed.buffer).setUint16(0, speedPps, true); - const total = 10 + 2 + enc.byteLength + 2 + 1; - const out = new Uint8Array(total); - let o = 0; - out.set(header, o); - o += 10; - out.set(textLen, o); - o += 2; - out.set(enc, o); - o += enc.byteLength; - out.set(speed, o); - o += 2; - out[o] = direction; - return out.buffer; -} -var driver = new MonoDisplayDriver("canvas_root", { - onColor: "#33ff66", - offColor: "#0a0a0a" -}); -var demos = { - "Checkerboard (static)": makeCheckerboard, - "Blink (animation)": makeAnimation, - "Text (centered)": () => makeTextBin("Hello, World! \uD83D\uDFE2", 1), - "Scrolltext (left)": () => makeScrollTextBin("MONO DISPLAY — scrolling ticker test — 日本語テスト \uD83D\uDE80 ", 80, 0) -}; -var controls = document.getElementById("controls"); -var active = null; -for (const [label, factory] of Object.entries(demos)) { - const btn = document.createElement("button"); - btn.textContent = label; - btn.onclick = () => { - active?.classList.remove("active"); - btn.classList.add("active"); - active = btn; - driver.load(() => Promise.resolve(factory())); - }; - controls.appendChild(btn); -} -controls.firstElementChild?.click(); diff --git a/ts/src/driver.ts b/ts/src/driver.ts new file mode 100644 index 0000000..ec99d6d --- /dev/null +++ b/ts/src/driver.ts @@ -0,0 +1,88 @@ +// --------------------------------------------------------------------------- +// MonoDisplayRenderer - tick-based canvas renderer +// --------------------------------------------------------------------------- + +import { MonoDisplayParser } from "./parser"; +import { MonoDisplayRenderer } from "./renderer"; + +/** 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 → NxN canvas pixels. + * Keep at 1 and use CSS to stretch for crisp upscaling. + */ + scale?: number; + /** Default display width for elements without inherent size. Default 160. */ + displayWidth?: number; + /** Default display height. Default 80. */ + displayHeight?: number; + /** + * Render rate in ticks per second. All animations and scrolls are driven by this. + * Animation updateInterval, scroll speed, etc. are relative to this tick rate. + * Default 25. + */ + fps?: number; + /** Loop animations. Default true. */ + loop?: boolean; + /** Error handler. Default: throw. */ + onError?: (err: Error) => void; +} + + +// --------------------------------------------------------------------------- +// MonoDisplayDriver - public API surface +// --------------------------------------------------------------------------- + +/** + * Top-level driver. Attach to a canvas by ID, call load() with an async + * factory that returns a .bin ArrayBuffer. + * + * @example + * ```ts + * const driver = new MonoDisplayDriver("canvas_root", { fps: 25 }); + * driver.load(() => loadBinFile("display.bin")); + * ``` + */ +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(`#${canvasId} is not a `); + this.canvas = el as HTMLCanvasElement; + 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(); + } + + async load(loader: () => Promise): Promise { + try { + const buffer = await loader(); + const file = this.parser.parse(buffer); + if (!this.renderer) this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); + this.renderer.render(file); + } catch (err) { + this.opts.onError(err instanceof Error ? err : new Error(String(err))); + } + } + + stop(): void { this.renderer?.stop(); } +} + + + diff --git a/ts/src/file.ts b/ts/src/file.ts new file mode 100644 index 0000000..1e07ecd --- /dev/null +++ b/ts/src/file.ts @@ -0,0 +1,260 @@ +// --------------------------------------------------------------------------- +// MonoDisplayFile - JSON descriptor → binary .bin builder +// --------------------------------------------------------------------------- + +import { packedSize, packPixels, pad32 } from "./helper"; +import { MONOFORMAT_MAGIC_HEADER } from "./parser"; +import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types"; + +/** + * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. + * + * elements_always → Section type 1 (ElementsAlways). + * Pass a DrawElement[] or { flags, elements }. + * Default flags: drawFront=true, drawBack=false, clearBuffer=false. + * + * PLAN: encode ElementsTimespan sections, CustomFont sections. + */ +export class MonoDisplayFile { + private desc: MonoDisplayFileDescriptor; + + constructor(descriptor: MonoDisplayFileDescriptor) { + this.desc = descriptor; + } + + toBuffer(): Uint8Array { + const sections: Uint8Array[] = []; + + // Section 1: elements always + const alwaysDesc = this.desc.elements_always; + if (alwaysDesc) { + const isArray = Array.isArray(alwaysDesc); + const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements; + const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags; + sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements)); + } + + // File header (12 bytes) + const hdrBuf = new ArrayBuffer(12); + const hdrView = new DataView(hdrBuf); + hdrView.setUint32(0, MONOFORMAT_MAGIC_HEADER, true); + hdrView.setUint32(4, 1, true); // version + hdrView.setUint16(8, sections.length, true); + hdrView.setUint16(10, 0, true); // reserved + + return this.#concat(new Uint8Array(hdrBuf), ...sections); + } + + // --- section encoders --- + + #encodeElementsSection( + type: SectionType.ElementsAlways | SectionType.ElementsTimespan, + flags: SectionFlags | undefined, + elements: DrawElement[], + ): Uint8Array { + const flagBits = + (flags?.drawFront ?? true ? 0x01 : 0) | + (flags?.drawBack ?? false ? 0x02 : 0) | + (flags?.clearBuffer ?? false ? 0x04 : 0); + + const encodedEls = elements.map(el => this.#encodeElement(el)); + const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0); + // sectionSize (in header) = 4 (header) + sectionDataSize + const sectionSize = 4 + sectionDataSize; + + const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4) + const v = new DataView(hdr.buffer); + v.setUint8(0, type); + v.setUint8(1, sectionSize & 0xFF); + v.setUint8(2, (sectionSize >> 8) & 0xFF); + v.setUint8(3, (sectionSize >> 16) & 0xFF); + v.setUint16(4, flagBits, true); // flags + v.setUint16(6, elements.length, true); // numElements + + return this.#concat(hdr, ...encodedEls); + } + + // --- element encoders --- + + #encodeElement(el: DrawElement): Uint8Array { + switch (el.type) { + case ElementType.Image2D: return this.#encodeImage2D(el); + case ElementType.Animation: return this.#encodeAnimation(el); + case ElementType.HorizontalScroll: return this.#encodeHScroll(el); + case ElementType.VerticalScroll: return this.#encodeVScroll(el); + case ElementType.Line: return this.#encodeLine(el); + case ElementType.ClippedText: return this.#encodeClippedText(el); + case ElementType.HScrollText: return this.#encodeHScrollText(el); + default: return new Uint8Array(); + } + } + + #encodeImage2D(el: Image2DElement): Uint8Array { + const packed = packPixels(el.pixels, el.width, el.height); + const fixed = 12; // bytes before pixel data + const rawSize = fixed + packed.byteLength; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.Image2D, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint16(10, 0, true); // reserved + out.set(packed, 12); + return out; + } + + #encodeAnimation(el: AnimationElement): Uint8Array { + const frameBytes = packedSize(el.width, el.height); + const fixed = 16; + const rawSize = fixed + el.frames.length * frameBytes; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.Animation, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint16(10, el.frames.length, true); + v.setUint16(12, el.updateInterval ?? 0, true); + v.setUint16(14, 0, true); // reserved + let off = 16; + for (const f of el.frames) { + out.set(packPixels(f.pixels, el.width, el.height), off); + off += frameBytes; + } + return out; + } + + #encodeScrollFlags(f: ScrollElementFlags | undefined): number { + return ( + (f?.endless ? 0x01 : 0) | + (f?.invertDirection ? 0x02 : 0) | + (f?.padStart ? 0x04 : 0) | + (f?.padEnd ? 0x08 : 0) + ); + } + + #encodeHScroll(el: HScrollElement): Uint8Array { + const packed = packPixels(el.pixels, el.contentWidth, el.height); + const fixed = 16; + const rawSize = fixed + packed.byteLength; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.HorizontalScroll, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint16(10, el.contentWidth, true); + v.setUint8( 12, this.#encodeScrollFlags(el.flags)); + v.setUint8( 13, el.scrollSpeed ?? 0); + v.setUint16(14, 0, true); // reserved + out.set(packed, 16); + return out; + } + + #encodeVScroll(el: VScrollElement): Uint8Array { + const packed = packPixels(el.pixels, el.width, el.contentHeight); + const fixed = 16; + const rawSize = fixed + packed.byteLength; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.VerticalScroll, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint16(10, el.contentHeight, true); + v.setUint8( 12, this.#encodeScrollFlags(el.flags)); + v.setUint8( 13, el.scrollSpeed ?? 0); + v.setUint16(14, 0, true); + out.set(packed, 16); + return out; + } + + #encodeLine(el: LineElement): Uint8Array { + // 12 bytes, already 32-bit aligned + const out = new Uint8Array(12); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.Line, true); + v.setUint16(2, el.xOrigin, true); + v.setUint16(4, el.yOrigin, true); + v.setUint16(6, el.xTarget, true); + v.setUint16(8, el.yTarget, true); + v.setUint8(10, el.lineStyle ?? 0); + v.setUint8(11, el.invertPixels ? 1 : 0); + return out; + } + + #encodeClippedText(el: ClippedTextElement): Uint8Array { + const enc = new TextEncoder().encode(el.text); + const fixed = 14; // 2+2+2+2+2+2+2 + const rawSize = fixed + enc.byteLength; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.ClippedText, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint16(10, el.fontIndex ?? 0, true); + v.setUint16(12, enc.byteLength, true); + out.set(enc, 14); + return out; + } + + #encodeHScrollText(el: HScrollTextElement): Uint8Array { + const enc = new TextEncoder().encode(el.text); + const fixed = 16; // 2+2+2+2+2+1+1+2+2 + const rawSize = fixed + enc.byteLength; + const total = rawSize + pad32(rawSize); + const out = new Uint8Array(total); + const v = new DataView(out.buffer); + v.setUint16(0, ElementType.HScrollText, true); + v.setUint16(2, el.xOffset ?? 0, true); + v.setUint16(4, el.yOffset ?? 0, true); + v.setUint16(6, el.width, true); + v.setUint16(8, el.height, true); + v.setUint8( 10, this.#encodeScrollFlags(el.flags)); + v.setUint8( 11, el.scrollSpeed ?? 0); + v.setUint16(12, el.fontIndex ?? 0, true); + v.setUint16(14, enc.byteLength, true); // PLAN: confirm with spec + out.set(enc, 16); + return out; + } + + // --- utilities --- + + #concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((s, p) => s + p.byteLength, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { out.set(p, off); off += p.byteLength; } + return out; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +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 single-element-always .bin buffer containing one Image2D element. + * Convenience for tests; for production use MonoDisplayFile directly. + */ +export function buildBinBuffer(el: Image2DElement): ArrayBuffer { + return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); +} diff --git a/ts/src/helper.ts b/ts/src/helper.ts new file mode 100644 index 0000000..22d3e67 --- /dev/null +++ b/ts/src/helper.ts @@ -0,0 +1,102 @@ +// --------------------------------------------------------------------------- +// Pixel pack/unpack helpers +// --------------------------------------------------------------------------- + +/** + * Pack 1-byte-per-pixel array into 1bpp. + * Excess bits in the last byte are set to 0. + * Size = ceil(width * height / 8). + */ +export function packPixels(pixels: Uint8Array, width: number, height: number): Uint8Array { + const total = width * height; + const packed = new Uint8Array((total + 7) >> 3); + for (let i = 0; i < total; i++) { + if (pixels[i]) packed[i >> 3] |= 1 << (i & 7); + } + return packed; +} + +/** + * Unpack 1bpp data to 1-byte-per-pixel. Excess bits beyond width*height are ignored. + */ +export function unpackPixels(packed: Uint8Array, width: number, height: number): Uint8Array { + const total = width * height; + const pixels = new Uint8Array(total); + for (let i = 0; i < total; i++) { + pixels[i] = (packed[i >> 3] >> (i & 7)) & 1; + } + return pixels; +} + +/** Byte count of packed 1bpp image */ +export function packedSize(width: number, height: number): number { + return (width * height + 7) >> 3; +} + +/** Padding needed to align `byteCount` to a 4-byte boundary */ +export function pad32(byteCount: number): number { + return (4 - (byteCount & 3)) & 3; +} + +// --------------------------------------------------------------------------- +// BinaryReader — little-endian cursor over an ArrayBuffer +// --------------------------------------------------------------------------- + +export class BinaryReader { + private view: DataView; + private pos: number = 0; + + constructor(buffer: ArrayBuffer | ArrayBufferView) { + this.view = buffer instanceof ArrayBuffer + ? new DataView(buffer) + : new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + + get offset(): number { return this.pos; } + get byteLength(): number { return this.view.byteLength; } + get remaining(): number { return this.view.byteLength - this.pos; } + + readByte(): number { this.#need(1); return this.view.getUint8(this.pos++); } + readUint16(): number { this.#need(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; } + readShort(): number { return this.readUint16(); } + readInt16(): number { this.#need(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; } + + readUint24(): number { + this.#need(3); + const v = this.view.getUint8(this.pos) | (this.view.getUint8(this.pos+1) << 8) | (this.view.getUint8(this.pos+2) << 16); + this.pos += 3; + return v; + } + + readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; } + + readUint64(): bigint { + this.#need(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; + } + + readBytes(n: number): Uint8Array { + this.#need(n); + const s = new Uint8Array(this.view.buffer, this.pos, n); + this.pos += n; + return s; + } + + readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); } + + seek(pos: number): void { + if (pos < 0 || pos > this.view.byteLength) { + throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`); + } + this.pos = pos; + } + + #need(n: number): void { + if (this.remaining < n) throw new RangeError( + `BinaryReader: need ${n} byte(s) at offset ${this.pos}, ${this.remaining} remain` + ); + } +} diff --git a/ts/src/library.ts b/ts/src/library.ts index 57dbe80..bef6dc6 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -1,35 +1,35 @@ // ============================================================================= -// MonoDisplay Library — binary format parser + canvas renderer +// MonoDisplay Library - binary format parser + canvas renderer // // Spec: Mono Display File Format Specification (see adjacent .rst) // // File layout (little-endian throughout): // -// [FILE HEADER — 12 bytes] +// [FILE HEADER - 12 bytes] // 4 bytes magic 0xAF 0x7E 0x2B 0x63 // 4 bytes version uint32 LE; 0=illegal, 1=current // 2 bytes numSections uint16 LE // 2 bytes reserved must be 0 // -// [SECTION — repeated numSections times] +// [SECTION - repeated numSections times] // 1 byte sectionType uint8 // 3 bytes sectionSize uint24 LE, INCLUDES these 4 header bytes // sectionSize-4 bytes of section-specific data // to 32-bit boundary (included in sectionSize) // // SECTION TYPES: -// 1 ElementsAlways — flags(u16) numElements(u16) elements… -// 2 ElementsTimespan — flags(u16) numElements(u16) start(u64) end(u64) elements… -// 32 CustomFont — actualSize(u24) reserved(u8) fontData… +// 1 ElementsAlways - flags(u16) numElements(u16) elements... +// 2 ElementsTimespan - flags(u16) numElements(u16) start(u64) end(u64) elements... +// 32 CustomFont - actualSize(u24) reserved(u8) fontData... // // ELEMENT TYPES (uint16, all fields uint16 unless noted, 32-bit aligned): -// 1 Image2D — type xOff yOff w h reserved [packed 1bpp pixels] -// 2 Animation — type xOff yOff w h nFrames updateInterval reserved [N×frame] -// 3 HorizontalScroll — type xOff yOff w h contentW flags(u8) speed(u8) reserved [pixels] -// 4 VerticalScroll — type xOff yOff w h contentH flags(u8) speed(u8) reserved [pixels] -// 5 Line — type xO yO xT yT lineStyle(u8) flags(u8) -// 16 ClippedText — type xOff yOff w h fontIdx textLen [utf8 text + pad] -// 17 HScrollText — type xOff yOff w h flags(u8) speed(u8) fontIdx textLen [utf8 + pad] +// 1 Image2D - type xOff yOff w h reserved [packed 1bpp pixels] +// 2 Animation - type xOff yOff w h nFrames updateInterval reserved [N×frame] +// 3 HorizontalScroll - type xOff yOff w h contentW flags(u8) speed(u8) reserved [pixels] +// 4 VerticalScroll - type xOff yOff w h contentH flags(u8) speed(u8) reserved [pixels] +// 5 Line - type xO yO xT yT lineStyle(u8) flags(u8) +// 16 ClippedText - type xOff yOff w h fontIdx textLen [utf8 text + pad] +// 17 HScrollText - type xOff yOff w h flags(u8) speed(u8) fontIdx textLen [utf8 + pad] // // PIXEL DATA: packed 1bpp, size = ceil(w*h/8). // px_i = y*w + x; byte = data[px_i>>3]; bit = (byte >> (px_i&7)) & 1 @@ -40,961 +40,8 @@ // FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first). // ============================================================================= -export * from "./types.js"; -import { - SectionType, - ElementType, - type SectionFlags, - type ScrollElementFlags, - type Image2DElement, - type AnimationElement, - type HScrollElement, - type VScrollElement, - type LineElement, - type ClippedTextElement, - type HScrollTextElement, - type DrawElement, - type ElementsAlwaysDescriptor, - type MonoDisplayFileDescriptor, - type MonoFormatPixelImage, - type MonoFormatImage2D, - type MonoFormatAnimation, - type MonoFormatScrollFlags, - type MonoFormatHScroll, - type MonoFormatVScroll, - type MonoFormatLine, - type MonoFormatClippedText, - type MonoFormatHScrollText, - type MonoFormatElement, - type MonoFormatSectionFlags, - type MonoFormatElementsAlways, - type MonoFormatElementsTimespan, - type MonoFormatCustomFont, - type MonoFormatSection, - type MonoFormatFile, -} from "./types.js"; - -// --------------------------------------------------------------------------- -// Pixel pack/unpack helpers -// --------------------------------------------------------------------------- - -/** - * Pack 1-byte-per-pixel array into 1bpp. - * Excess bits in the last byte are set to 0. - * Size = ceil(width * height / 8). - */ -function packPixels(pixels: Uint8Array, width: number, height: number): Uint8Array { - const total = width * height; - const packed = new Uint8Array((total + 7) >> 3); - for (let i = 0; i < total; i++) { - if (pixels[i]) packed[i >> 3] |= 1 << (i & 7); - } - return packed; -} - -/** - * Unpack 1bpp data to 1-byte-per-pixel. Excess bits beyond width*height are ignored. - */ -function unpackPixels(packed: Uint8Array, width: number, height: number): Uint8Array { - const total = width * height; - const pixels = new Uint8Array(total); - for (let i = 0; i < total; i++) { - pixels[i] = (packed[i >> 3] >> (i & 7)) & 1; - } - return pixels; -} - -/** Byte count of packed 1bpp image */ -function packedSize(width: number, height: number): number { - return (width * height + 7) >> 3; -} - -/** Padding needed to align `byteCount` to a 4-byte boundary */ -function pad32(byteCount: number): number { - return (4 - (byteCount & 3)) & 3; -} - -// --------------------------------------------------------------------------- -// BinaryReader — little-endian cursor over an ArrayBuffer -// --------------------------------------------------------------------------- - -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; } - - readByte(): number { this.#need(1); return this.view.getUint8(this.pos++); } - readUint16(): number { this.#need(2); const v = this.view.getUint16(this.pos, true); this.pos += 2; return v; } - readShort(): number { return this.readUint16(); } - readInt16(): number { this.#need(2); const v = this.view.getInt16(this.pos, true); this.pos += 2; return v; } - - readUint24(): number { - this.#need(3); - const v = this.view.getUint8(this.pos) | (this.view.getUint8(this.pos+1) << 8) | (this.view.getUint8(this.pos+2) << 16); - this.pos += 3; - return v; - } - - readUint32(): number { this.#need(4); const v = this.view.getUint32(this.pos, true); this.pos += 4; return v; } - - readUint64(): bigint { - this.#need(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; - } - - readBytes(n: number): Uint8Array { - this.#need(n); - const s = new Uint8Array(this.view.buffer, this.pos, n); - this.pos += n; - return s; - } - - readUtf8(n: number): string { return new TextDecoder().decode(this.readBytes(n)); } - - seek(pos: number): void { - if (pos < 0 || pos > this.view.byteLength) { - throw new RangeError(`seek(${pos}) out of [0, ${this.view.byteLength}]`); - } - this.pos = pos; - } - - #need(n: number): void { - if (this.remaining < n) throw new RangeError( - `BinaryReader: need ${n} byte(s) at offset ${this.pos}, ${this.remaining} remain` - ); - } -} - -// --------------------------------------------------------------------------- -// File format constants -// --------------------------------------------------------------------------- - -// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE -const MAGIC = 0x632B7EAF; - -// --------------------------------------------------------------------------- -// MonoDisplayParser -// --------------------------------------------------------------------------- - -export class MonoDisplayParser { - parse(buffer: ArrayBuffer): MonoFormatFile { - const r = new BinaryReader(buffer); - - // File header (12 bytes) - const magic = r.readUint32(); - if (magic !== MAGIC) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`); - - const version = r.readUint32(); - if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`); - - const numSections = r.readUint16(); - r.readUint16(); // reserved - - const sections: MonoFormatSection[] = []; - - for (let i = 0; i < numSections; i++) { - const sectionType = r.readByte(); - const sectionSize = r.readUint24(); // INCLUDES 4-byte header - const sectionStart = r.offset; // start of section DATA - const dataSize = sectionSize - 4; - - switch (sectionType) { - case SectionType.ElementsAlways: - sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false)); - break; - case SectionType.ElementsTimespan: - sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true)); - break; - case SectionType.CustomFont: - sections.push(this.#parseCustomFont(r, dataSize)); - break; - default: - // Unknown — skip - break; - } - - // Seek past full section data (handles padding, unknown fields, future extensions) - r.seek(sectionStart + dataSize); - } - - return { sections }; - } - - #parseFlags(raw: number): MonoFormatSectionFlags { - return { - drawFront: !!(raw & 0x01), - drawBack: !!(raw & 0x02), - clearBuffer: !!(raw & 0x04), - }; - } - - #parseElementsSection( - r: BinaryReader, - type: SectionType.ElementsAlways | SectionType.ElementsTimespan, - hasTimestamp: boolean, - ): MonoFormatElementsAlways | MonoFormatElementsTimespan { - const flags = this.#parseFlags(r.readUint16()); - const numElements = r.readUint16(); - - let startTimestamp = 0n, endTimestamp = 0n; - if (hasTimestamp) { - startTimestamp = r.readUint64(); - endTimestamp = r.readUint64(); - } - - const elements: MonoFormatElement[] = []; - for (let i = 0; i < numElements; i++) { - const el = this.#parseElement(r); - if (el) elements.push(el); - } - - if (hasTimestamp) { - return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements }; - } - return { sectionType: SectionType.ElementsAlways, flags, elements }; - } - - #parseCustomFont(r: BinaryReader, dataSize: number): MonoFormatCustomFont { - const actualSize = r.readUint24(); - r.readByte(); // reserved - const fontData = new Uint8Array(r.readBytes(Math.min(actualSize, dataSize - 4))); - return { sectionType: SectionType.CustomFont, fontData }; - } - - #parseElement(r: BinaryReader): MonoFormatElement | null { - const elementStart = r.offset; - const type = r.readUint16(); - - switch (type) { - case ElementType.Image2D: return this.#parseImage2D(r, elementStart); - case ElementType.Animation: return this.#parseAnimation(r, elementStart); - case ElementType.HorizontalScroll: return this.#parseHScroll(r, elementStart); - case ElementType.VerticalScroll: return this.#parseVScroll(r, elementStart); - case ElementType.Line: return this.#parseLine(r); - case ElementType.ClippedText: return this.#parseClippedText(r, elementStart); - case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart); - default: - // Unknown element type — cannot safely skip without knowing size; stop parsing section elements - return null; - } - } - - #alignTo4(start: number, current: number): void { - // elements are 32-bit aligned; seek past padding if any - const end = start + (((current - start) + 3) & ~3); - // no-op if already aligned (handled by caller via section seek) - } - - #parseImage2D(r: BinaryReader, start: number): MonoFormatImage2D { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - r.readUint16(); // reserved - // Fixed header: 2(type)+2+2+2+2+2 = 12 bytes - const packed = r.readBytes(packedSize(width, height)); - const pixels = unpackPixels(new Uint8Array(packed), width, height); - // Skip padding to 32-bit boundary for the element - const dataRead = 12 + packed.byteLength; - r.seek(start + dataRead + pad32(dataRead)); - return { type: ElementType.Image2D, xOffset, yOffset, image: { pixels, width, height } }; - } - - #parseAnimation(r: BinaryReader, start: number): MonoFormatAnimation { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - const numFrames = r.readUint16(); - const updateInterval = r.readUint16(); - r.readUint16(); // reserved - // Fixed header: 2+2+2+2+2+2+2+2 = 16 bytes - const frameBytes = packedSize(width, height); - const frames: MonoFormatPixelImage[] = []; - for (let f = 0; f < numFrames; f++) { - const packed = r.readBytes(frameBytes); - frames.push({ pixels: unpackPixels(new Uint8Array(packed), width, height), width, height }); - } - const dataRead = 16 + numFrames * frameBytes; - r.seek(start + dataRead + pad32(dataRead)); - return { type: ElementType.Animation, xOffset, yOffset, width, height, updateInterval, frames }; - } - - #parseScrollFlags(raw: number) { - return { - endless: !!(raw & 0x01), - invertDirection: !!(raw & 0x02), - padStart: !!(raw & 0x04), - padEnd: !!(raw & 0x08), - }; - } - - #parseHScroll(r: BinaryReader, start: number): MonoFormatHScroll { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - const contentWidth = r.readUint16(); - const flagsByte = r.readByte(); - const scrollSpeed = r.readByte(); - r.readUint16(); // reserved - // Fixed header: 2+2+2+2+2+2+1+1+2 = 16 bytes - const packed = r.readBytes(packedSize(contentWidth, height)); - const pixels = unpackPixels(new Uint8Array(packed), contentWidth, height); - const dataRead = 16 + packed.byteLength; - r.seek(start + dataRead + pad32(dataRead)); - return { - type: ElementType.HorizontalScroll, xOffset, yOffset, width, height, contentWidth, - scrollSpeed, flags: this.#parseScrollFlags(flagsByte), - content: { pixels, width: contentWidth, height }, - }; - } - - #parseVScroll(r: BinaryReader, start: number): MonoFormatVScroll { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - const contentHeight = r.readUint16(); - const flagsByte = r.readByte(); - const scrollSpeed = r.readByte(); - r.readUint16(); // reserved - const packed = r.readBytes(packedSize(width, contentHeight)); - const pixels = unpackPixels(new Uint8Array(packed), width, contentHeight); - const dataRead = 16 + packed.byteLength; - r.seek(start + dataRead + pad32(dataRead)); - return { - type: ElementType.VerticalScroll, xOffset, yOffset, width, height, contentHeight, - scrollSpeed, flags: this.#parseScrollFlags(flagsByte), - content: { pixels, width, height: contentHeight }, - }; - } - - #parseLine(r: BinaryReader): MonoFormatLine { - const xOrigin = r.readUint16(); - const yOrigin = r.readUint16(); - const xTarget = r.readUint16(); - const yTarget = r.readUint16(); - const lineStyle = r.readByte(); - const flagsByte = r.readByte(); - // 2+2+2+2+2+1+1 = 12 bytes, already 32-bit aligned - return { type: ElementType.Line, xOrigin, yOrigin, xTarget, yTarget, lineStyle, invertPixels: !!(flagsByte & 1) }; - } - - #parseClippedText(r: BinaryReader, start: number): MonoFormatClippedText { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - const fontIndex = r.readUint16(); - const textLen = r.readUint16(); - // Fixed header: 2+2+2+2+2+2+2 = 14 bytes - const text = r.readUtf8(textLen); - const dataRead = 14 + textLen; - r.seek(start + dataRead + pad32(dataRead)); - return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; - } - - #parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText { - const xOffset = r.readUint16(); - const yOffset = r.readUint16(); - const width = r.readUint16(); - const height = r.readUint16(); - const flagsByte = r.readByte(); - const scrollSpeed = r.readByte(); - const fontIndex = r.readUint16(); - const textLen = r.readUint16(); // PLAN: confirm with spec (assumed, not explicit in diagram) - // Fixed header: 2+2+2+2+2+1+1+2+2 = 16 bytes - const text = r.readUtf8(textLen); - const dataRead = 16 + textLen; - r.seek(start + dataRead + pad32(dataRead)); - return { - type: ElementType.HScrollText, xOffset, yOffset, width, height, - scrollSpeed, fontIndex, flags: this.#parseScrollFlags(flagsByte), text, - }; - } -} - -// --------------------------------------------------------------------------- -// MonoDisplayRenderer — tick-based canvas renderer -// --------------------------------------------------------------------------- - -/** 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 → NxN canvas pixels. - * Keep at 1 and use CSS to stretch for crisp upscaling. - */ - scale?: number; - /** Default display width for elements without inherent size. Default 160. */ - displayWidth?: number; - /** Default display height. Default 80. */ - displayHeight?: number; - /** - * Render rate in ticks per second. All animations and scrolls are driven by this. - * Animation updateInterval, scroll speed, etc. are relative to this tick rate. - * Default 25. - */ - fps?: number; - /** Loop animations. Default true. */ - loop?: boolean; - /** Error handler. Default: throw. */ - onError?: (err: Error) => void; -} - -/** - * Renders a MonoFormatFile onto an HTMLCanvasElement. - * Uses setInterval at 1000/fps ms per tick. All element state (animation - * frame counters, scroll positions) is keyed by element index within the file. - * - * PLAN: ClippedText and HScrollText currently render via canvas fillText (stub). - * Replace with U8G2 bitmap font renderer once font loading is implemented. - * PLAN: Line element uses Bresenham stub; lineStyle field is reserved and ignored. - */ -export class MonoDisplayRenderer { - private ctx: CanvasRenderingContext2D; - private opts: Required; - - private tickTimer: number | null = null; - private tickCount: number = 0; - // Per-element state: keyed by stable element index - private animState: Map = new Map(); - private hScrollPos: Map = new Map(); // pixel offset (may be fractional) - private vScrollPos: Map = new Map(); - - 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(): void { - if (this.tickTimer !== null) { - clearInterval(this.tickTimer); - this.tickTimer = null; - } - this.tickCount = 0; - this.animState.clear(); - this.hScrollPos.clear(); - this.vScrollPos.clear(); - } - - render(file: MonoFormatFile): void { - this.stop(); - - const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; - this.ctx.canvas.width = dw * s; - this.ctx.canvas.height = dh * s; - - const tick = () => { - this.#renderFile(file); - this.tickCount++; - }; - - tick(); // immediate first frame - this.tickTimer = setInterval(tick, 1000 / this.opts.fps) as unknown as number; - } - - #renderFile(file: MonoFormatFile): void { - const now = BigInt(Math.floor(Date.now() / 1000)); - let cleared = false; - - let elementId = 0; - - for (const section of file.sections) { - if (section.sectionType === SectionType.ElementsTimespan) { - if (now < section.startTimestamp || now >= section.endTimestamp) { - // Count elements so IDs stay stable even for skipped sections - elementId += section.elements.length; - continue; - } - } - - if (section.sectionType !== SectionType.ElementsAlways && - section.sectionType !== SectionType.ElementsTimespan) { - continue; - } - - const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan; - - if (elSection.flags.clearBuffer && !cleared) { - this.ctx.fillStyle = this.opts.offColor; - this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); - cleared = true; - } - - for (const el of elSection.elements) { - this.#renderElement(el, elementId++); - } - } - } - - #renderElement(el: MonoFormatElement, id: number): void { - switch (el.type) { - case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break; - case ElementType.Animation: this.#renderAnimation(el, id); break; - case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break; - case ElementType.VerticalScroll: this.#renderVScroll(el, id); break; - case ElementType.Line: this.#renderLine(el); break; - case ElementType.ClippedText: this.#renderClippedText(el); break; - case ElementType.HScrollText: this.#renderHScrollText(el, id); break; - } - } - - /** Blit a pixel image onto the canvas at (ox, oy) in display coordinates */ - #blitImage(img: MonoFormatPixelImage, ox: number, oy: number): void { - const { ctx, opts: { onColor, scale: s } } = this; - ctx.fillStyle = onColor; - for (let y = 0; y < img.height; y++) { - for (let x = 0; x < img.width; x++) { - if (img.pixels[y * img.width + x]) { - ctx.fillRect((ox + x) * s, (oy + y) * s, s, s); - } - } - } - } - - /** Blit a clipped sub-region of a pixel image. srcX is fractional scroll offset. */ - #blitClipped( - img: MonoFormatPixelImage, ox: number, oy: number, - viewW: number, viewH: number, srcX: number, srcY: number, - ): void { - const { ctx, opts: { onColor, scale: s } } = this; - ctx.fillStyle = onColor; - for (let y = 0; y < viewH; y++) { - for (let x = 0; x < viewW; x++) { - const sx = Math.floor(srcX) + x; - const sy = Math.floor(srcY) + y; - if (sx < 0 || sx >= img.width || sy < 0 || sy >= img.height) continue; - if (img.pixels[sy * img.width + sx]) { - ctx.fillRect((ox + x) * s, (oy + y) * s, s, s); - } - } - } - } - - #renderAnimation(el: MonoFormatAnimation, id: number): void { - if (el.frames.length === 0) return; - let state = this.animState.get(id); - if (!state) { state = { frame: 0, counter: 0 }; this.animState.set(id, state); } - - this.#blitImage(el.frames[state.frame], el.xOffset, el.yOffset); - - state.counter++; - if (state.counter > el.updateInterval) { - state.counter = 0; - state.frame = (state.frame + 1) % el.frames.length; - if (!this.opts.loop && state.frame === 0) state.frame = el.frames.length - 1; - } - } - - #renderHScroll(el: MonoFormatHScroll, id: number): void { - let pos = this.hScrollPos.get(id) ?? 0; - // Draw visible slice - this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, pos, 0); - // Advance scroll: (SS+1)/16 pixels per tick - const speed = (el.scrollSpeed + 1) / 16; - pos += el.flags.invertDirection ? -speed : speed; - if (el.flags.endless) { - if (pos >= el.contentWidth) pos -= el.contentWidth; - if (pos < 0) pos += el.contentWidth; - } else { - pos = Math.max(0, Math.min(pos, el.contentWidth - el.width)); - } - this.hScrollPos.set(id, pos); - } - - #renderVScroll(el: MonoFormatVScroll, id: number): void { - let pos = this.vScrollPos.get(id) ?? 0; - this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, 0, pos); - const speed = (el.scrollSpeed + 1) / 16; - pos += el.flags.invertDirection ? -speed : speed; - if (el.flags.endless) { - if (pos >= el.contentHeight) pos -= el.contentHeight; - if (pos < 0) pos += el.contentHeight; - } else { - pos = Math.max(0, Math.min(pos, el.contentHeight - el.height)); - } - this.vScrollPos.set(id, pos); - } - - #renderLine(el: MonoFormatLine): void { - // Bresenham's line algorithm - const { ctx, opts: { onColor, offColor, scale: s } } = this; - ctx.fillStyle = el.invertPixels ? offColor : onColor; - let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget]; - const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1; - const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1; - let err = dx + dy; - while (true) { - ctx.fillRect(x0*s, y0*s, s, s); - if (x0 === x1 && y0 === y1) break; - const e2 = 2 * err; - if (e2 >= dy) { err += dy; x0 += sx; } - if (e2 <= dx) { err += dx; y0 += sy; } - } - } - - #renderClippedText(el: MonoFormatClippedText): void { - // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex - const { ctx, opts: { onColor, scale: s } } = this; - ctx.save(); - ctx.beginPath(); - ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); - ctx.clip(); - ctx.fillStyle = onColor; - const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); - ctx.font = `${fontSize}px monospace`; - ctx.textBaseline = "top"; - ctx.fillText(el.text, el.xOffset * s, el.yOffset * s); - ctx.restore(); - } - - #renderHScrollText(el: MonoFormatHScrollText, id: number): void { - // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex - const { ctx, opts: { onColor, scale: s } } = this; - const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); - ctx.font = `${fontSize}px monospace`; - const textPx = ctx.measureText(el.text).width; - let pos = this.hScrollPos.get(id) ?? el.width * s; - - ctx.save(); - ctx.beginPath(); - ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); - ctx.clip(); - ctx.fillStyle = onColor; - ctx.textBaseline = "top"; - ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s); - ctx.restore(); - - const speed = (el.scrollSpeed + 1) / 16 * s; - pos += el.flags.invertDirection ? speed : -speed; - if (el.flags.endless) { - if (pos < -textPx) pos = el.width * s; - if (pos > el.width * s) pos = -textPx; - } - this.hScrollPos.set(id, pos); - } -} - -// --------------------------------------------------------------------------- -// MonoDisplayDriver — public API surface -// --------------------------------------------------------------------------- - -/** - * Top-level driver. Attach to a canvas by ID, call load() with an async - * factory that returns a .bin ArrayBuffer. - * - * @example - * ```ts - * const driver = new MonoDisplayDriver("canvas_root", { fps: 25 }); - * driver.load(() => loadBinFile("display.bin")); - * ``` - */ -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(`#${canvasId} is not a `); - this.canvas = el as HTMLCanvasElement; - 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(); - } - - async load(loader: () => Promise): Promise { - try { - const buffer = await loader(); - const file = this.parser.parse(buffer); - if (!this.renderer) this.renderer = new MonoDisplayRenderer(this.canvas, this.opts); - this.renderer.render(file); - } catch (err) { - this.opts.onError(err instanceof Error ? err : new Error(String(err))); - } - } - - stop(): void { this.renderer?.stop(); } -} - -// --------------------------------------------------------------------------- -// MonoDisplayFile — JSON descriptor → binary .bin builder -// --------------------------------------------------------------------------- - -/** - * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. - * - * elements_always → Section type 1 (ElementsAlways). - * Pass a DrawElement[] or { flags, elements }. - * Default flags: drawFront=true, drawBack=false, clearBuffer=false. - * - * PLAN: encode ElementsTimespan sections, CustomFont sections. - */ -export class MonoDisplayFile { - private desc: MonoDisplayFileDescriptor; - - constructor(descriptor: MonoDisplayFileDescriptor) { - this.desc = descriptor; - } - - toBuffer(): ArrayBuffer { - const sections: Uint8Array[] = []; - - // Section 1: elements always - const alwaysDesc = this.desc.elements_always; - if (alwaysDesc) { - const isArray = Array.isArray(alwaysDesc); - const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements; - const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags; - sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements)); - } - - // File header (12 bytes) - const hdrBuf = new ArrayBuffer(12); - const hdrView = new DataView(hdrBuf); - hdrView.setUint32(0, MAGIC, true); - hdrView.setUint32(4, 1, true); // version - hdrView.setUint16(8, sections.length, true); - hdrView.setUint16(10, 0, true); // reserved - - return this.#concat(new Uint8Array(hdrBuf), ...sections); - } - - // --- section encoders --- - - #encodeElementsSection( - type: SectionType.ElementsAlways | SectionType.ElementsTimespan, - flags: SectionFlags | undefined, - elements: DrawElement[], - ): Uint8Array { - const flagBits = - (flags?.drawFront ?? true ? 0x01 : 0) | - (flags?.drawBack ?? false ? 0x02 : 0) | - (flags?.clearBuffer ?? false ? 0x04 : 0); - - const encodedEls = elements.map(el => this.#encodeElement(el)); - const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0); - // sectionSize (in header) = 4 (header) + sectionDataSize - const sectionSize = 4 + sectionDataSize; - - const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4) - const v = new DataView(hdr.buffer); - v.setUint8(0, type); - v.setUint8(1, sectionSize & 0xFF); - v.setUint8(2, (sectionSize >> 8) & 0xFF); - v.setUint8(3, (sectionSize >> 16) & 0xFF); - v.setUint16(4, flagBits, true); // flags - v.setUint16(6, elements.length, true); // numElements - - return this.#concat(hdr, ...encodedEls); - } - - // --- element encoders --- - - #encodeElement(el: DrawElement): Uint8Array { - switch (el.type) { - case ElementType.Image2D: return this.#encodeImage2D(el); - case ElementType.Animation: return this.#encodeAnimation(el); - case ElementType.HorizontalScroll: return this.#encodeHScroll(el); - case ElementType.VerticalScroll: return this.#encodeVScroll(el); - case ElementType.Line: return this.#encodeLine(el); - case ElementType.ClippedText: return this.#encodeClippedText(el); - case ElementType.HScrollText: return this.#encodeHScrollText(el); - } - } - - #encodeImage2D(el: Image2DElement): Uint8Array { - const packed = packPixels(el.pixels, el.width, el.height); - const fixed = 12; // bytes before pixel data - const rawSize = fixed + packed.byteLength; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.Image2D, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint16(10, 0, true); // reserved - out.set(packed, 12); - return out; - } - - #encodeAnimation(el: AnimationElement): Uint8Array { - const frameBytes = packedSize(el.width, el.height); - const fixed = 16; - const rawSize = fixed + el.frames.length * frameBytes; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.Animation, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint16(10, el.frames.length, true); - v.setUint16(12, el.updateInterval ?? 0, true); - v.setUint16(14, 0, true); // reserved - let off = 16; - for (const f of el.frames) { - out.set(packPixels(f.pixels, el.width, el.height), off); - off += frameBytes; - } - return out; - } - - #encodeScrollFlags(f: ScrollElementFlags | undefined): number { - return ( - (f?.endless ? 0x01 : 0) | - (f?.invertDirection ? 0x02 : 0) | - (f?.padStart ? 0x04 : 0) | - (f?.padEnd ? 0x08 : 0) - ); - } - - #encodeHScroll(el: HScrollElement): Uint8Array { - const packed = packPixels(el.pixels, el.contentWidth, el.height); - const fixed = 16; - const rawSize = fixed + packed.byteLength; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.HorizontalScroll, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint16(10, el.contentWidth, true); - v.setUint8( 12, this.#encodeScrollFlags(el.flags)); - v.setUint8( 13, el.scrollSpeed ?? 0); - v.setUint16(14, 0, true); // reserved - out.set(packed, 16); - return out; - } - - #encodeVScroll(el: VScrollElement): Uint8Array { - const packed = packPixels(el.pixels, el.width, el.contentHeight); - const fixed = 16; - const rawSize = fixed + packed.byteLength; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.VerticalScroll, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint16(10, el.contentHeight, true); - v.setUint8( 12, this.#encodeScrollFlags(el.flags)); - v.setUint8( 13, el.scrollSpeed ?? 0); - v.setUint16(14, 0, true); - out.set(packed, 16); - return out; - } - - #encodeLine(el: LineElement): Uint8Array { - // 12 bytes, already 32-bit aligned - const out = new Uint8Array(12); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.Line, true); - v.setUint16(2, el.xOrigin, true); - v.setUint16(4, el.yOrigin, true); - v.setUint16(6, el.xTarget, true); - v.setUint16(8, el.yTarget, true); - v.setUint8(10, el.lineStyle ?? 0); - v.setUint8(11, el.invertPixels ? 1 : 0); - return out; - } - - #encodeClippedText(el: ClippedTextElement): Uint8Array { - const enc = new TextEncoder().encode(el.text); - const fixed = 14; // 2+2+2+2+2+2+2 - const rawSize = fixed + enc.byteLength; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.ClippedText, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint16(10, el.fontIndex ?? 0, true); - v.setUint16(12, enc.byteLength, true); - out.set(enc, 14); - return out; - } - - #encodeHScrollText(el: HScrollTextElement): Uint8Array { - const enc = new TextEncoder().encode(el.text); - const fixed = 16; // 2+2+2+2+2+1+1+2+2 - const rawSize = fixed + enc.byteLength; - const total = rawSize + pad32(rawSize); - const out = new Uint8Array(total); - const v = new DataView(out.buffer); - v.setUint16(0, ElementType.HScrollText, true); - v.setUint16(2, el.xOffset ?? 0, true); - v.setUint16(4, el.yOffset ?? 0, true); - v.setUint16(6, el.width, true); - v.setUint16(8, el.height, true); - v.setUint8( 10, this.#encodeScrollFlags(el.flags)); - v.setUint8( 11, el.scrollSpeed ?? 0); - v.setUint16(12, el.fontIndex ?? 0, true); - v.setUint16(14, enc.byteLength, true); // PLAN: confirm with spec - out.set(enc, 16); - return out; - } - - // --- utilities --- - - #concat(...parts: Uint8Array[]): Uint8Array { - const total = parts.reduce((s, p) => s + p.byteLength, 0); - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { out.set(p, off); off += p.byteLength; } - return out; - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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 single-element-always .bin buffer containing one Image2D element. - * Convenience for tests; for production use MonoDisplayFile directly. - */ -export function buildBinBuffer(el: Image2DElement): ArrayBuffer { - return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); -} +export * from "./types"; +export * from "./driver"; +export * from "./parser"; +export * from "./file"; +export * from "./helper"; diff --git a/ts/src/parser.ts b/ts/src/parser.ts new file mode 100644 index 0000000..e019930 --- /dev/null +++ b/ts/src/parser.ts @@ -0,0 +1,254 @@ + +// --------------------------------------------------------------------------- +// File format constants +// --------------------------------------------------------------------------- + +import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper"; +import { type MonoFormatFile } from "./library"; +import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types"; + +// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE +export const MONOFORMAT_MAGIC_HEADER = 0x632B7EAF; + +// --------------------------------------------------------------------------- +// MonoDisplayParser +// --------------------------------------------------------------------------- + +export class MonoDisplayParser { + parse(buffer: ArrayBuffer | ArrayBufferView): MonoFormatFile { + const r = new BinaryReader(buffer); + + // File header (12 bytes) + const magic = r.readUint32(); + if (magic !== MONOFORMAT_MAGIC_HEADER) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`); + + const version = r.readUint32(); + if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`); + + const numSections = r.readUint16(); + r.readUint16(); // reserved + + const sections: MonoFormatSection[] = []; + + for (let i = 0; i < numSections; i++) { + const sectionType = r.readByte(); + const sectionSize = r.readUint24(); // INCLUDES 4-byte header + const sectionStart = r.offset; // start of section DATA + const dataSize = sectionSize - 4; + + switch (sectionType) { + case SectionType.ElementsAlways: + sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false)); + break; + case SectionType.ElementsTimespan: + sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true)); + break; + case SectionType.CustomFont: + sections.push(this.#parseCustomFont(r, dataSize)); + break; + default: + // Unknown — skip + break; + } + + // Seek past full section data (handles padding, unknown fields, future extensions) + r.seek(sectionStart + dataSize); + } + + return { sections }; + } + + #parseFlags(raw: number): MonoFormatSectionFlags { + return { + drawFront: !!(raw & 0x01), + drawBack: !!(raw & 0x02), + clearBuffer: !!(raw & 0x04), + }; + } + + #parseElementsSection( + r: BinaryReader, + type: SectionType.ElementsAlways | SectionType.ElementsTimespan, + hasTimestamp: boolean, + ): MonoFormatElementsAlways | MonoFormatElementsTimespan { + const flags = this.#parseFlags(r.readUint16()); + const numElements = r.readUint16(); + + let startTimestamp = 0n, endTimestamp = 0n; + if (hasTimestamp) { + startTimestamp = r.readUint64(); + endTimestamp = r.readUint64(); + } + + const elements: MonoFormatElement[] = []; + for (let i = 0; i < numElements; i++) { + const el = this.#parseElement(r); + if (el) elements.push(el); + } + + if (hasTimestamp) { + return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements }; + } + return { sectionType: SectionType.ElementsAlways, flags, elements }; + } + + #parseCustomFont(r: BinaryReader, dataSize: number): MonoFormatCustomFont { + const actualSize = r.readUint24(); + r.readByte(); // reserved + const fontData = new Uint8Array(r.readBytes(Math.min(actualSize, dataSize - 4))); + return { sectionType: SectionType.CustomFont, fontData }; + } + + #parseElement(r: BinaryReader): MonoFormatElement | null { + const elementStart = r.offset; + const type = r.readUint16(); + + switch (type) { + case ElementType.Image2D: return this.#parseImage2D(r, elementStart); + case ElementType.Animation: return this.#parseAnimation(r, elementStart); + case ElementType.HorizontalScroll: return this.#parseHScroll(r, elementStart); + case ElementType.VerticalScroll: return this.#parseVScroll(r, elementStart); + case ElementType.Line: return this.#parseLine(r); + case ElementType.ClippedText: return this.#parseClippedText(r, elementStart); + case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart); + default: + // Unknown element type — cannot safely skip without knowing size; stop parsing section elements + return null; + } + } + + #alignTo4(start: number, current: number): void { + // elements are 32-bit aligned; seek past padding if any + const end = start + (((current - start) + 3) & ~3); + // no-op if already aligned (handled by caller via section seek) + } + + #parseImage2D(r: BinaryReader, start: number): MonoFormatImage2D { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + r.readUint16(); // reserved + // Fixed header: 2(type)+2+2+2+2+2 = 12 bytes + const packed = r.readBytes(packedSize(width, height)); + const pixels = unpackPixels(new Uint8Array(packed), width, height); + // Skip padding to 32-bit boundary for the element + const dataRead = 12 + packed.byteLength; + r.seek(start + dataRead + pad32(dataRead)); + return { type: ElementType.Image2D, xOffset, yOffset, image: { pixels, width, height } }; + } + + #parseAnimation(r: BinaryReader, start: number): MonoFormatAnimation { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const numFrames = r.readUint16(); + const updateInterval = r.readUint16(); + r.readUint16(); // reserved + // Fixed header: 2+2+2+2+2+2+2+2 = 16 bytes + const frameBytes = packedSize(width, height); + const frames: MonoFormatPixelImage[] = []; + for (let f = 0; f < numFrames; f++) { + const packed = r.readBytes(frameBytes); + frames.push({ pixels: unpackPixels(new Uint8Array(packed), width, height), width, height }); + } + const dataRead = 16 + numFrames * frameBytes; + r.seek(start + dataRead + pad32(dataRead)); + return { type: ElementType.Animation, xOffset, yOffset, width, height, updateInterval, frames }; + } + + #parseScrollFlags(raw: number) { + return { + endless: !!(raw & 0x01), + invertDirection: !!(raw & 0x02), + padStart: !!(raw & 0x04), + padEnd: !!(raw & 0x08), + }; + } + + #parseHScroll(r: BinaryReader, start: number): MonoFormatHScroll { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const contentWidth = r.readUint16(); + const flagsByte = r.readByte(); + const scrollSpeed = r.readByte(); + r.readUint16(); // reserved + // Fixed header: 2+2+2+2+2+2+1+1+2 = 16 bytes + const packed = r.readBytes(packedSize(contentWidth, height)); + const pixels = unpackPixels(new Uint8Array(packed), contentWidth, height); + const dataRead = 16 + packed.byteLength; + r.seek(start + dataRead + pad32(dataRead)); + return { + type: ElementType.HorizontalScroll, xOffset, yOffset, width, height, contentWidth, + scrollSpeed, flags: this.#parseScrollFlags(flagsByte), + content: { pixels, width: contentWidth, height }, + }; + } + + #parseVScroll(r: BinaryReader, start: number): MonoFormatVScroll { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const contentHeight = r.readUint16(); + const flagsByte = r.readByte(); + const scrollSpeed = r.readByte(); + r.readUint16(); // reserved + const packed = r.readBytes(packedSize(width, contentHeight)); + const pixels = unpackPixels(new Uint8Array(packed), width, contentHeight); + const dataRead = 16 + packed.byteLength; + r.seek(start + dataRead + pad32(dataRead)); + return { + type: ElementType.VerticalScroll, xOffset, yOffset, width, height, contentHeight, + scrollSpeed, flags: this.#parseScrollFlags(flagsByte), + content: { pixels, width, height: contentHeight }, + }; + } + + #parseLine(r: BinaryReader): MonoFormatLine { + const xOrigin = r.readUint16(); + const yOrigin = r.readUint16(); + const xTarget = r.readUint16(); + const yTarget = r.readUint16(); + const lineStyle = r.readByte(); + const flagsByte = r.readByte(); + // 2+2+2+2+2+1+1 = 12 bytes, already 32-bit aligned + return { type: ElementType.Line, xOrigin, yOrigin, xTarget, yTarget, lineStyle, invertPixels: !!(flagsByte & 1) }; + } + + #parseClippedText(r: BinaryReader, start: number): MonoFormatClippedText { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const fontIndex = r.readUint16(); + const textLen = r.readUint16(); + // Fixed header: 2+2+2+2+2+2+2 = 14 bytes + const text = r.readUtf8(textLen); + const dataRead = 14 + textLen; + r.seek(start + dataRead + pad32(dataRead)); + return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; + } + + #parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText { + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const flagsByte = r.readByte(); + const scrollSpeed = r.readByte(); + const fontIndex = r.readUint16(); + const textLen = r.readUint16(); // PLAN: confirm with spec (assumed, not explicit in diagram) + // Fixed header: 2+2+2+2+2+1+1+2+2 = 16 bytes + const text = r.readUtf8(textLen); + const dataRead = 16 + textLen; + r.seek(start + dataRead + pad32(dataRead)); + return { + type: ElementType.HScrollText, xOffset, yOffset, width, height, + scrollSpeed, fontIndex, flags: this.#parseScrollFlags(flagsByte), text, + }; + } +} diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts new file mode 100644 index 0000000..9a5f1a8 --- /dev/null +++ b/ts/src/renderer.ts @@ -0,0 +1,251 @@ + +import { + ElementType, + SectionType, + type MonoFormatAnimation, + type MonoFormatClippedText, + type MonoFormatElement, + type MonoFormatElementsAlways, + type MonoFormatElementsTimespan, + type MonoFormatFile, + type MonoFormatHScroll, + type MonoFormatHScrollText, + type MonoFormatLine, + type MonoFormatPixelImage, + type MonoFormatVScroll +} from "./types.js"; +/** + * Renders a MonoFormatFile onto an HTMLCanvasElement. + * Uses setInterval at 1000/fps ms per tick. All element state (animation + * frame counters, scroll positions) is keyed by element index within the file. + * + * PLAN: ClippedText and HScrollText currently render via canvas fillText (stub). + * Replace with U8G2 bitmap font renderer once font loading is implemented. + * PLAN: Line element uses Bresenham stub; lineStyle field is reserved and ignored. + */ +export class MonoDisplayRenderer { + private ctx: CanvasRenderingContext2D; + private opts: Required; + + private tickTimer: number | null = null; + private tickCount: number = 0; + // Per-element state: keyed by stable element index + private animState: Map = new Map(); + private hScrollPos: Map = new Map(); // pixel offset (may be fractional) + private vScrollPos: Map = new Map(); + + 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(): void { + if (this.tickTimer !== null) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + this.tickCount = 0; + this.animState.clear(); + this.hScrollPos.clear(); + this.vScrollPos.clear(); + } + + render(file: MonoFormatFile): void { + this.stop(); + + const { displayWidth: dw, displayHeight: dh, scale: s } = this.opts; + this.ctx.canvas.width = dw * s; + this.ctx.canvas.height = dh * s; + + const tick = () => { + this.#renderFile(file); + this.tickCount++; + }; + + tick(); // immediate first frame + this.tickTimer = setInterval(tick, 1000 / this.opts.fps) as unknown as number; + } + + #renderFile(file: MonoFormatFile): void { + const now = BigInt(Math.floor(Date.now() / 1000)); + let cleared = false; + + let elementId = 0; + + for (const section of file.sections) { + if (section.sectionType === SectionType.ElementsTimespan) { + if (now < section.startTimestamp || now >= section.endTimestamp) { + // Count elements so IDs stay stable even for skipped sections + elementId += section.elements.length; + continue; + } + } + + if (section.sectionType !== SectionType.ElementsAlways && + section.sectionType !== SectionType.ElementsTimespan) { + continue; + } + + const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan; + + if (elSection.flags.clearBuffer && !cleared) { + this.ctx.fillStyle = this.opts.offColor; + this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height); + cleared = true; + } + + for (const el of elSection.elements) { + this.#renderElement(el, elementId++); + } + } + } + + #renderElement(el: MonoFormatElement, id: number): void { + switch (el.type) { + case ElementType.Image2D: this.#blitImage(el.image, el.xOffset, el.yOffset); break; + case ElementType.Animation: this.#renderAnimation(el, id); break; + case ElementType.HorizontalScroll: this.#renderHScroll(el, id); break; + case ElementType.VerticalScroll: this.#renderVScroll(el, id); break; + case ElementType.Line: this.#renderLine(el); break; + case ElementType.ClippedText: this.#renderClippedText(el); break; + case ElementType.HScrollText: this.#renderHScrollText(el, id); break; + } + } + + /** Blit a pixel image onto the canvas at (ox, oy) in display coordinates */ + #blitImage(img: MonoFormatPixelImage, ox: number, oy: number): void { + const { ctx, opts: { onColor, scale: s } } = this; + ctx.fillStyle = onColor; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + if (img.pixels[y * img.width + x]) { + ctx.fillRect((ox + x) * s, (oy + y) * s, s, s); + } + } + } + } + + /** Blit a clipped sub-region of a pixel image. srcX is fractional scroll offset. */ + #blitClipped( + img: MonoFormatPixelImage, ox: number, oy: number, + viewW: number, viewH: number, srcX: number, srcY: number, + ): void { + const { ctx, opts: { onColor, scale: s } } = this; + ctx.fillStyle = onColor; + for (let y = 0; y < viewH; y++) { + for (let x = 0; x < viewW; x++) { + const sx = Math.floor(srcX) + x; + const sy = Math.floor(srcY) + y; + if (sx < 0 || sx >= img.width || sy < 0 || sy >= img.height) continue; + if (img.pixels[sy * img.width + sx]) { + ctx.fillRect((ox + x) * s, (oy + y) * s, s, s); + } + } + } + } + + #renderAnimation(el: MonoFormatAnimation, id: number): void { + if (el.frames.length === 0) return; + let state = this.animState.get(id); + if (!state) { state = { frame: 0, counter: 0 }; this.animState.set(id, state); } + + this.#blitImage(el.frames[state.frame], el.xOffset, el.yOffset); + + state.counter++; + if (state.counter > el.updateInterval) { + state.counter = 0; + state.frame = (state.frame + 1) % el.frames.length; + if (!this.opts.loop && state.frame === 0) state.frame = el.frames.length - 1; + } + } + + #renderHScroll(el: MonoFormatHScroll, id: number): void { + let pos = this.hScrollPos.get(id) ?? 0; + // Draw visible slice + this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, pos, 0); + // Advance scroll: (SS+1)/16 pixels per tick + const speed = (el.scrollSpeed + 1) / 16; + pos += el.flags.invertDirection ? -speed : speed; + if (el.flags.endless) { + if (pos >= el.contentWidth) pos -= el.contentWidth; + if (pos < 0) pos += el.contentWidth; + } else { + pos = Math.max(0, Math.min(pos, el.contentWidth - el.width)); + } + this.hScrollPos.set(id, pos); + } + + #renderVScroll(el: MonoFormatVScroll, id: number): void { + let pos = this.vScrollPos.get(id) ?? 0; + this.#blitClipped(el.content, el.xOffset, el.yOffset, el.width, el.height, 0, pos); + const speed = (el.scrollSpeed + 1) / 16; + pos += el.flags.invertDirection ? -speed : speed; + if (el.flags.endless) { + if (pos >= el.contentHeight) pos -= el.contentHeight; + if (pos < 0) pos += el.contentHeight; + } else { + pos = Math.max(0, Math.min(pos, el.contentHeight - el.height)); + } + this.vScrollPos.set(id, pos); + } + + #renderLine(el: MonoFormatLine): void { + // Bresenham's line algorithm + const { ctx, opts: { onColor, offColor, scale: s } } = this; + ctx.fillStyle = el.invertPixels ? offColor : onColor; + let [x0, y0, x1, y1] = [el.xOrigin, el.yOrigin, el.xTarget, el.yTarget]; + const dx = Math.abs(x1-x0), sx = x0 < x1 ? 1 : -1; + const dy = -Math.abs(y1-y0), sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + while (true) { + ctx.fillRect(x0*s, y0*s, s, s); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + } + } + + #renderClippedText(el: MonoFormatClippedText): void { + // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex + const { ctx, opts: { onColor, scale: s } } = this; + ctx.save(); + ctx.beginPath(); + ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); + ctx.clip(); + ctx.fillStyle = onColor; + const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); + ctx.font = `${fontSize}px monospace`; + ctx.textBaseline = "top"; + ctx.fillText(el.text, el.xOffset * s, el.yOffset * s); + ctx.restore(); + } + + #renderHScrollText(el: MonoFormatHScrollText, id: number): void { + // PLAN: replace with U8G2 bitmap font renderer using el.fontIndex + const { ctx, opts: { onColor, scale: s } } = this; + const fontSize = Math.max(6, Math.floor(el.height * s * 0.75)); + ctx.font = `${fontSize}px monospace`; + const textPx = ctx.measureText(el.text).width; + let pos = this.hScrollPos.get(id) ?? el.width * s; + + ctx.save(); + ctx.beginPath(); + ctx.rect(el.xOffset * s, el.yOffset * s, el.width * s, el.height * s); + ctx.clip(); + ctx.fillStyle = onColor; + ctx.textBaseline = "top"; + ctx.fillText(el.text, el.xOffset * s + pos, el.yOffset * s); + ctx.restore(); + + const speed = (el.scrollSpeed + 1) / 16 * s; + pos += el.flags.invertDirection ? speed : -speed; + if (el.flags.endless) { + if (pos < -textPx) pos = el.width * s; + if (pos > el.width * s) pos = -textPx; + } + this.hScrollPos.set(id, pos); + } +}