From d2f7294650c3276cb8a3a42b2a8ecde9493321d6 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 01/20] initial: README --- ts/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 ts/README.md diff --git a/ts/README.md b/ts/README.md new file mode 100644 index 0000000..e5b9ae7 --- /dev/null +++ b/ts/README.md @@ -0,0 +1,15 @@ +# libmonoformat-ts + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. From 10c1cc6c6cf6654caeba4094bd3a8d00e3806c7f Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 02/20] chore: create good gitignore --- ts/.gitignore | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 ts/.gitignore diff --git a/ts/.gitignore b/ts/.gitignore new file mode 100644 index 0000000..bceeac2 --- /dev/null +++ b/ts/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +public +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +.claude/ +.claude/settings.local.json From 27deb4cbf872d1a831eaa66f5142decdae03dab0 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 03/20] chore: init bun lib --- ts/CLAUDE.md | 106 +++++++ ts/bun.lock | 26 ++ ts/package.json | 11 + ts/src/index.ts | 45 +++ ts/src/library.ts | 592 ++++++++++++++++++++++++++++++++++++++++ ts/test/library.test.ts | 268 ++++++++++++++++++ ts/tsconfig.json | 29 ++ 7 files changed, 1077 insertions(+) create mode 100644 ts/CLAUDE.md create mode 100644 ts/bun.lock create mode 100644 ts/package.json create mode 100644 ts/src/index.ts create mode 100644 ts/src/library.ts create mode 100644 ts/test/library.test.ts create mode 100644 ts/tsconfig.json diff --git a/ts/CLAUDE.md b/ts/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/ts/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/ts/bun.lock b/ts/bun.lock new file mode 100644 index 0000000..434fd0d --- /dev/null +++ b/ts/bun.lock @@ -0,0 +1,26 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "libmonoformat", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], + } +} diff --git a/ts/package.json b/ts/package.json new file mode 100644 index 0000000..05a6735 --- /dev/null +++ b/ts/package.json @@ -0,0 +1,11 @@ +{ + "name": "libmonoformat", + "module": "index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/ts/src/index.ts b/ts/src/index.ts new file mode 100644 index 0000000..63f73c0 --- /dev/null +++ b/ts/src/index.ts @@ -0,0 +1,45 @@ +// ============================================================================= +// index.ts — public entry point +// +// Re-exports everything from library.ts so browser bundlers and module users +// get a single clean import path: +// +// import { MonoDisplayDriver, loadBinFile } from "./index.ts"; +// +// PLAN: if the project splits into sub-packages (parser / renderer / utils), +// this file will aggregate them here so callers never need to update imports. +// ============================================================================= + +export { + // Driver (primary browser-facing API) + MonoDisplayDriver, + + // Parser — useful when you want to parse without rendering + MonoDisplayParser, + + // Low-level binary reader — exposed so power users can parse custom sections + BinaryReader, + + // Renderer — exposed for embedding into custom canvas setups + MonoDisplayRenderer, + + // Convenience helpers + loadBinFile, + buildBinBuffer, +} from "./library"; + +export type { + // Union type for all display content variants + DisplayContent, + DisplayContentType, + + // Concrete content types + StaticFrame, + AnimFrame, + Animation, + TextDisplay, + ScrollText, + + // Driver options bag + MonoDisplayDriverOptions, +} from "./library"; diff --git a/ts/src/library.ts b/ts/src/library.ts new file mode 100644 index 0000000..8ee1bd1 --- /dev/null +++ b/ts/src/library.ts @@ -0,0 +1,592 @@ +// ============================================================================= +// 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; + +/** 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; + /** + * 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)); + } + + /** 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 +// --------------------------------------------------------------------------- + +const MAGIC = 0x4f4e4f4d; // "MONO" as little-endian uint32 + +const ContentTypeByte: Record = { + 0: "static", + 1: "animation", + 2: "text", + 3: "scrolltext", +}; + +// --------------------------------------------------------------------------- +// MonoDisplayParser +// --------------------------------------------------------------------------- + +/** + * Parses a .bin ArrayBuffer into a strongly-typed DisplayContent value. + * + * PLAN: when the real spec lands, update ONLY the parse* private methods. + * The public surface (parse → DisplayContent) stays identical. + */ +export class MonoDisplayParser { + parse(buffer: ArrayBuffer): DisplayContent { + const r = new BinaryReader(buffer); + + // --- header --- + 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) { + // PLAN: add version dispatch here when v2 spec exists + 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(); + + // --- content --- + 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: 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; + private scrollLastTs: 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; + } + } + + 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; + } + this.animTimer = setTimeout(tick, frame.durationMs) 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) => { + const dt = this.scrollLastTs !== null ? (ts - this.scrollLastTs) / 1000 : 0; + this.scrollLastTs = ts; + + const { displayWidth: dw, displayHeight: dh, scale: s, offColor, onColor } = this.opts; + // 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; + } + + 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, + 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 minimal valid .bin ArrayBuffer in code — useful for tests and demos. + * + * PLAN: expand this into a full BinaryWriter helper once the spec is final, + * so integration tests can construct every content type without raw byte arrays. + */ +export function buildBinBuffer(content: DisplayContent): ArrayBuffer { + // PLAN: implement encoding for all four content types + // For now only "static" is written out (enough to round-trip in tests) + if (content.type !== "static") { + throw new Error(`buildBinBuffer: encoding "${content.type}" not yet implemented`); + } + + const headerSize = 4 + 1 + 1 + 2 + 2; // magic + version + type + w + h + 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); // version + view.setUint8(5, 0); // content type = static + view.setUint16(6, content.width, true); + view.setUint16(8, content.height, true); + new Uint8Array(buf, headerSize).set(content.pixels); + + return buf; +} diff --git a/ts/test/library.test.ts b/ts/test/library.test.ts new file mode 100644 index 0000000..0fa5b66 --- /dev/null +++ b/ts/test/library.test.ts @@ -0,0 +1,268 @@ +// ============================================================================= +// library.test.ts — bun test suite for MonoDisplay library +// +// run: bun test src/library.test.ts +// +// Tests are grouped by component. Canvas-dependent renderer tests use a +// minimal mock so they run in Bun (no DOM). Parser + BinaryReader tests are +// pure TypeScript with no mocks needed. +// ============================================================================= + +import { test, expect, describe, mock, beforeEach } from "bun:test"; +import { + BinaryReader, + MonoDisplayParser, + buildBinBuffer, + type StaticFrame, + type Animation, + type TextDisplay, + type ScrollText, +} from "../src/library"; + +// --------------------------------------------------------------------------- +// Helpers to build raw binary buffers for each content type +// (mirrors the format defined in library.ts header comment) +// --------------------------------------------------------------------------- + +const MAGIC_BYTES = new Uint8Array([0x4d, 0x4f, 0x4e, 0x4f]); // "MONO" + +function makeHeader(contentType: number, width: number, height: number): Uint8Array { + const h = new Uint8Array(10); + h.set(MAGIC_BYTES, 0); + h[4] = 1; // version + h[5] = contentType; + new DataView(h.buffer).setUint16(6, width, true); + new DataView(h.buffer).setUint16(8, height, true); + return h; +} + +function concat(...parts: Uint8Array[]): ArrayBuffer { + 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.buffer; +} + +function u16le(n: number): Uint8Array { + const b = new Uint8Array(2); + new DataView(b.buffer).setUint16(0, n, true); + return b; +} + +function u32le(n: number): Uint8Array { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, n, true); + return b; +} + +// --------------------------------------------------------------------------- +// BinaryReader +// --------------------------------------------------------------------------- + +describe("BinaryReader", () => { + test("readByte reads single byte", () => { + const r = new BinaryReader(new Uint8Array([0xAB]).buffer); + expect(r.readByte()).toBe(0xAB); + expect(r.remaining).toBe(0); + }); + + test("readUint16 little-endian", () => { + const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); + expect(r.readUint16()).toBe(0x1234); + }); + + test("readShort is alias for readUint16", () => { + const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); + expect(r.readShort()).toBe(1); + }); + + test("readUint32 little-endian", () => { + const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer); + expect(r.readUint32()).toBe(0x12345678); + }); + + test("readUint64 little-endian", () => { + const bytes = new Uint8Array(8); + new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true); + const r = new BinaryReader(bytes.buffer); + expect(r.readUint64()).toBe(0x0102030405060708n); + }); + + test("offset advances correctly", () => { + const r = new BinaryReader(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer); + r.readByte(); + expect(r.offset).toBe(1); + r.readUint16(); + expect(r.offset).toBe(3); + expect(() => r.readUint32()).toThrow(RangeError); // 3 bytes remain, need 4 + }); + + test("RangeError when buffer exhausted", () => { + const r = new BinaryReader(new Uint8Array([0x01]).buffer); + r.readByte(); + expect(() => r.readByte()).toThrow(RangeError); + }); + + test("seek moves cursor", () => { + const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); + r.seek(2); + expect(r.readByte()).toBe(30); + }); + + test("seek out of bounds throws", () => { + const r = new BinaryReader(new Uint8Array([1, 2]).buffer); + expect(() => r.seek(5)).toThrow(RangeError); + }); + + test("readUtf8 decodes ASCII", () => { + const str = "hello"; + const enc = new TextEncoder().encode(str); + const r = new BinaryReader(enc.buffer); + expect(r.readUtf8(5)).toBe("hello"); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — static content +// --------------------------------------------------------------------------- + +describe("MonoDisplayParser — static", () => { + const parser = new MonoDisplayParser(); + + test("parses 2x2 static frame", () => { + const header = makeHeader(0, 2, 2); + const pixels = new Uint8Array([1, 0, 0, 1]); // diagonal + const buf = concat(header, pixels); + + const result = parser.parse(buf) as StaticFrame; + expect(result.type).toBe("static"); + expect(result.width).toBe(2); + expect(result.height).toBe(2); + expect(result.pixels[0]).toBe(1); + expect(result.pixels[3]).toBe(1); + }); + + test("rejects wrong magic", () => { + const bad = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF, 1, 0, 4, 0, 4, 0]); + expect(() => parser.parse(bad.buffer)).toThrow(/magic/i); + }); + + test("rejects unsupported version", () => { + const buf = makeHeader(0, 1, 1); + buf[4] = 99; // version 99 + expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/version/i); + }); + + test("rejects unknown content type", () => { + const buf = makeHeader(0xFF, 1, 1); + expect(() => parser.parse(concat(buf, new Uint8Array([0])))).toThrow(/content type/i); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — animation +// --------------------------------------------------------------------------- + +describe("MonoDisplayParser — animation", () => { + const parser = new MonoDisplayParser(); + + test("parses 2-frame animation", () => { + const header = makeHeader(1, 2, 2); // type=1 animation + const frameCount = u16le(2); + const frame1 = concat(u32le(100), new Uint8Array([1, 1, 1, 1])); + const frame2 = concat(u32le(200), new Uint8Array([0, 0, 0, 0])); + const buf = concat(header, frameCount, new Uint8Array(frame1), new Uint8Array(frame2)); + + const result = parser.parse(buf) as Animation; + expect(result.type).toBe("animation"); + expect(result.frames.length).toBe(2); + expect(result.frames[0].durationMs).toBe(100); + expect(result.frames[1].durationMs).toBe(200); + }); + + test("empty animation (0 frames) is valid", () => { + const header = makeHeader(1, 4, 4); + const buf = concat(header, u16le(0)); + const result = parser.parse(buf) as Animation; + expect(result.frames.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — text +// --------------------------------------------------------------------------- + +describe("MonoDisplayParser — text", () => { + const parser = new MonoDisplayParser(); + + test("parses text content", () => { + const header = makeHeader(2, 0, 0); // w/h unused for text + const str = "HELLO"; + const enc = new TextEncoder().encode(str); + const payload = concat( + u16le(enc.byteLength), + enc, + new Uint8Array([0]), // fontId + new Uint8Array([1]), // align = center + ); + const buf = concat(header, new Uint8Array(payload)); + + const result = parser.parse(buf) as TextDisplay; + expect(result.type).toBe("text"); + expect(result.text).toBe("HELLO"); + expect(result.align).toBe(1); + expect(result.fontId).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// MonoDisplayParser — scrolltext +// --------------------------------------------------------------------------- + +describe("MonoDisplayParser — scrolltext", () => { + const parser = new MonoDisplayParser(); + + test("parses scrolltext content", () => { + const header = makeHeader(3, 0, 0); + const str = "SCROLL ME"; + const enc = new TextEncoder().encode(str); + const payload = concat( + u16le(enc.byteLength), + enc, + u16le(60), // speedPps = 60 + new Uint8Array([0]), // direction = left + ); + const buf = concat(header, new Uint8Array(payload)); + + const result = parser.parse(buf) as ScrollText; + expect(result.type).toBe("scrolltext"); + expect(result.text).toBe("SCROLL ME"); + expect(result.speedPps).toBe(60); + expect(result.direction).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// buildBinBuffer round-trip +// --------------------------------------------------------------------------- + +describe("buildBinBuffer", () => { + const parser = new MonoDisplayParser(); + + test("static round-trip", () => { + const pixels = new Uint8Array([0, 1, 1, 0]); + const original: StaticFrame = { type: "static", width: 2, height: 2, pixels }; + const buf = buildBinBuffer(original); + const parsed = parser.parse(buf) as StaticFrame; + expect(parsed.type).toBe("static"); + expect(parsed.width).toBe(2); + expect(parsed.height).toBe(2); + expect(Array.from(parsed.pixels)).toEqual([0, 1, 1, 0]); + }); + + test("non-static throws (not implemented yet)", () => { + const anim: Animation = { type: "animation", width: 1, height: 1, frames: [] }; + expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); + }); +}); diff --git a/ts/tsconfig.json b/ts/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/ts/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} From 4d9bb7d26a2b6bca57f0b1196b27cac92514821a Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 04/20] feat: update for browser run --- ts/index.html | 222 +++++++++++++++++++++++ ts/package.json | 6 +- ts/public/bundle.js | 428 ++++++++++++++++++++++++++++++++++++++++++++ ts/src/browser.ts | 9 + ts/src/library.ts | 43 ++++- 5 files changed, 698 insertions(+), 10 deletions(-) create mode 100644 ts/index.html create mode 100644 ts/public/bundle.js create mode 100644 ts/src/browser.ts diff --git a/ts/index.html b/ts/index.html new file mode 100644 index 0000000..73bd9a8 --- /dev/null +++ b/ts/index.html @@ -0,0 +1,222 @@ + + + + + + MonoDisplay Test + + + + +

MonoDisplay — 160 × 80

+ +
+ + +
160 × 80 @ 25fps
+
+ +
+
select a demo
+ + + + + + + + diff --git a/ts/package.json b/ts/package.json index 05a6735..1b8399c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -1,7 +1,11 @@ { "name": "libmonoformat", - "module": "index.ts", + "module": "src/index.ts", "type": "module", + "scripts": { + "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "test": "bun test" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/ts/public/bundle.js b/ts/public/bundle.js new file mode 100644 index 0000000..1c9cfa8 --- /dev/null +++ b/ts/public/bundle.js @@ -0,0 +1,428 @@ +// 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/browser.ts b/ts/src/browser.ts new file mode 100644 index 0000000..51ee591 --- /dev/null +++ b/ts/src/browser.ts @@ -0,0 +1,9 @@ +// browser.ts — IIFE-style browser entry point +// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js +// Attaches everything to window.MonoDisplay so inline From c67c46748656df71b31225d672dd6988454957f1 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 08/20] feat: file encoder schemas --- ts/src/index.ts | 4 + ts/src/library.ts | 265 ++++++++++++++++++++++++++++++---------------- 2 files changed, 178 insertions(+), 91 deletions(-) diff --git a/ts/src/index.ts b/ts/src/index.ts index eaaeec7..e9327d5 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -37,6 +37,10 @@ export type { MonoDisplayFileDescriptor, DisplayElement, Image2DElement, + AnimationFrame, + AnimationElement, + TextElement, + ScrollTextElement, // Union type for all display content variants DisplayContent, diff --git a/ts/src/library.ts b/ts/src/library.ts index 6f19119..b80fad7 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -90,45 +90,78 @@ 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", + /** Positioned 1bpp bitmap. Composited with OR-blend onto the display canvas. */ + Image2D = "Image2D", + /** Multi-frame bitmap animation. Each frame has its own pixel data and duration. */ + Animation = "Animation", + /** Static text rendered with the built-in bitmap font (stub until font spec lands). */ + Text = "Text", + /** Horizontally scrolling text ticker. */ + ScrollText = "ScrollText", } -/** A positioned 1bpp image placed on the display canvas */ +/** Positioned 1bpp image, composited onto the display canvas */ export interface Image2DElement { type: ElementType.Image2D; - /** Raw pixel data — 1 byte per pixel (0=off 1=on), row-major */ + /** 1 byte per pixel (0=off 1=on), row-major */ pixels: Uint8Array; width: number; height: number; - /** Horizontal offset from display left edge. Default 0. */ + /** Pixel offset from left edge. Default 0. */ xoffset?: number; - /** Vertical offset from display top edge. Default 0. */ + /** Pixel offset from top edge. Default 0. */ yoffset?: number; } -// Union for future element types -export type DisplayElement = Image2DElement; -// PLAN: | TextElement | ScrollTextElement | AnimationElement +/** One frame in an AnimationElement */ +export interface AnimationFrame { + pixels: Uint8Array; + durationMs: number; +} + +/** Multi-frame animation section. width/height shared by all frames. */ +export interface AnimationElement { + type: ElementType.Animation; + width: number; + height: number; + frames: AnimationFrame[]; +} + +/** Static text rendered by the driver's bitmap font */ +export interface TextElement { + type: ElementType.Text; + text: string; + /** 0=left 1=center 2=right. Default 0. */ + align?: 0 | 1 | 2; + /** Font index (reserved). Default 0. */ + fontId?: number; +} + +/** Scrolling text ticker */ +export interface ScrollTextElement { + type: ElementType.ScrollText; + text: string; + /** Pixels per second. Default 60. */ + speedPps?: number; + /** 0=scroll left (→off left edge) 1=scroll right. Default 0. */ + direction?: 0 | 1; +} + +export type DisplayElement = + | Image2DElement + | AnimationElement + | TextElement + | ScrollTextElement; /** - * JSON descriptor passed to MonoDisplayFile. + * JSON descriptor for 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). + * elements_always — rendered on every frame regardless of state. + * PLAN: elements_conditional — state-machine-driven visibility (alerts, overrides). * - * 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. + * width / height — display canvas in pixels. Omit to infer from Image2D element bounds. + * Text/ScrollText/Animation ignore canvas size (section has no position). */ export interface MonoDisplayFileDescriptor { width?: number; @@ -139,19 +172,13 @@ export interface MonoDisplayFileDescriptor { /** * 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())); - * ``` + * Dispatch rules (first element_always wins for section type): + * Image2D only → static section (0x01), composited with OR-blend + * Animation → animation section (0x02) + * Text → text section (0x03) + * ScrollText → scrolltext section (0x04) * - * PLAN: toBuffer() currently composites all elements_always into one static frame. - * When animated elements are added, it will produce an animation section instead. + * PLAN: multiple sections per file once spec defines multi-section rendering. */ export class MonoDisplayFile { private desc: MonoDisplayFileDescriptor; @@ -160,46 +187,120 @@ export class MonoDisplayFile { 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 elements = this.desc.elements_always ?? []; + + // Dispatch on first element's type (or default to blank static) + const first = elements[0]; + if (!first || first.type === ElementType.Image2D) { + return this.#encodeStatic(); + } + switch (first.type) { + case ElementType.Animation: return this.#encodeAnimation(first); + case ElementType.Text: return this.#encodeText(first); + case ElementType.ScrollText: return this.#encodeScrollText(first); + } + } + + // --- static (section 0x01) --- + + #encodeStatic(): ArrayBuffer { const { width, height } = this.#resolveSize(); - const canvas = this.#composite(width, height); + const pixels = this.#composite(width, height); + return this.#wrapSection(0x01, (view, off) => { + view.setUint16(off, width, true); off += 2; + view.setUint16(off, height, true); off += 2; + new Uint8Array(view.buffer, off).set(pixels); + return off + pixels.byteLength; + }, 2 + 2 + width * height); + } + + // --- animation (section 0x02) --- + + #encodeAnimation(el: AnimationElement): ArrayBuffer { + const pixelBytes = el.width * el.height; + // width(2) + height(2) + frameCount(2) + frames*(durationMs(4) + pixels(w*h)) + const dataSize = 2 + 2 + 2 + el.frames.length * (4 + pixelBytes); + return this.#wrapSection(0x02, (view, off) => { + view.setUint16(off, el.width, true); off += 2; + view.setUint16(off, el.height, true); off += 2; + view.setUint16(off, el.frames.length, true); off += 2; + for (const frame of el.frames) { + view.setUint32(off, frame.durationMs, true); off += 4; + new Uint8Array(view.buffer, off).set(frame.pixels); + off += pixelBytes; + } + return off; + }, dataSize); + } - // 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 + // --- text (section 0x03) --- + + #encodeText(el: TextElement): ArrayBuffer { + const enc = new TextEncoder().encode(el.text); + // textLen(2) + utf8 + fontId(1) + align(1) + const dataSize = 2 + enc.byteLength + 1 + 1; + return this.#wrapSection(0x03, (view, off) => { + view.setUint16(off, enc.byteLength, true); off += 2; + new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; + view.setUint8(off, el.fontId ?? 0); off += 1; + view.setUint8(off, el.align ?? 0); off += 1; + return off; + }, dataSize); + } + + // --- scrolltext (section 0x04) --- + + #encodeScrollText(el: ScrollTextElement): ArrayBuffer { + const enc = new TextEncoder().encode(el.text); + // textLen(2) + utf8 + speedPps(2) + direction(1) + const dataSize = 2 + enc.byteLength + 2 + 1; + return this.#wrapSection(0x04, (view, off) => { + view.setUint16(off, enc.byteLength, true); off += 2; + new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; + view.setUint16(off, el.speedPps ?? 60, true); off += 2; + view.setUint8(off, el.direction ?? 0); off += 1; + return off; + }, dataSize); + } - const buf = new ArrayBuffer(totalSize); + // --- shared file/section envelope --- + + /** + * Allocates a buffer with a 12-byte file header + 4-byte section header + + * sectionDataSize bytes, then calls write(view, dataOffset) to fill data. + */ + #wrapSection( + sectionType: number, + write: (view: DataView, dataOffset: number) => number, + sectionDataSize: number, + ): ArrayBuffer { + const buf = new ArrayBuffer(12 + 4 + sectionDataSize); 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.setUint32(off, MAGIC, true); off += 4; + view.setUint32(off, 1, true); off += 4; // version + view.setUint16(off, 1, true); off += 2; // numSections + view.setUint16(off, 0, true); off += 2; // reserved + + // Section header: type(1) + size uint24 LE(3) + view.setUint8(off, sectionType); off += 1; + 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); - + write(view, off); return buf; } - /** Determine display dimensions: explicit > inferred from element bounding box > defaults */ + // --- size inference for Image2D compositing --- + #resolveSize(): { width: number; height: number } { - const elements = this.desc.elements_always ?? []; + const elements = (this.desc.elements_always ?? []) + .filter((el): el is Image2DElement => el.type === ElementType.Image2D); let width = this.desc.width; let height = this.desc.height; @@ -217,42 +318,24 @@ export class MonoDisplayFile { 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 - + const canvas = new Uint8Array(width * height); 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; + if (el.type !== ElementType.Image2D) continue; + 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 >= height) continue; + for (let col = 0; col < el.width; col++) { + const destX = ox + col; + if (destX < 0 || destX >= width) continue; + // OR-blend — PLAN: add blend mode enum once spec covers it + if (el.pixels[row * el.width + col]) canvas[destY * width + destX] = 1; + } } } + return canvas; } } From c5071bf1cadcd3a3a4e6cf65f746a71b8acd117c Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 09/20] feat: types from spec --- ts/src/types.ts | 449 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 ts/src/types.ts diff --git a/ts/src/types.ts b/ts/src/types.ts new file mode 100644 index 0000000..168bc48 --- /dev/null +++ b/ts/src/types.ts @@ -0,0 +1,449 @@ +// ============================================================================= +// Mono Display File Format - type definitions +// Spec: Mono Display File Format Specification +// ============================================================================= + +// --------------------------------------------------------------------------- +// Enums +// --------------------------------------------------------------------------- + +export enum FileVersion { + /** Illegal - must not appear in a valid file */ + Illegal = 0, + /** Current specification version */ + V1 = 1, +} + +export enum SectionType { + /** List of drawn elements - always shown */ + ElementsAlways = 1, + /** List of drawn elements - shown only within a POSIX timestamp window */ + ElementsTimespan = 2, + /** U8G2-format custom font used by draw elements in this file */ + CustomFont = 32, +} + +export enum ElementType { + Image2D = 1, + Animation = 2, + HorizontalScroll = 3, + VerticalScroll = 4, + Line = 5, + ClippedText = 16, + HScrollText = 17, + /** + * Current time display. + * NOTE: the spec diagram labels this element "Type: 16", which collides with + * ClippedText. This is assumed to be a spec typo; 18 is used here as the + * probable intended value until the spec is corrected. + */ + CurrentTime = 18, +} + +// --------------------------------------------------------------------------- +// Section flags (section types 1 and 2) +// --------------------------------------------------------------------------- + +/** Bit-field flags for ElementsAlways / ElementsTimespan sections. */ +export interface SectionFlags { + /** Bit 0 - render elements onto the front-side buffer. */ + drawFront?: boolean; + /** Bit 1 - render elements onto the back-side buffer. */ + drawBack?: boolean; + /** Bit 2 - clear targeted buffer(s) before drawing this section's elements. */ + clearBuffer?: boolean; + // Bits 3-15: reserved; writers MUST write 0. +} + +// --------------------------------------------------------------------------- +// Scroll element flags (HorizontalScroll / VerticalScroll / HScrollText) +// --------------------------------------------------------------------------- + +export interface ScrollElementFlags { + /** Bit 0 - wrap content endlessly. */ + endless?: boolean; + /** Bit 1 - reverse scroll direction. */ + invertDirection?: boolean; + /** Bit 2 - pad at start edge (left for H-scroll, top for V-scroll). */ + padStart?: boolean; + /** Bit 3 - pad at end edge (right for H-scroll, bottom for V-scroll). */ + padEnd?: boolean; + // Bits 4-7: reserved. +} + +// --------------------------------------------------------------------------- +// CurrentTime element flags +// --------------------------------------------------------------------------- + +export interface CurrentTimeFlags { + /** Bit 0 - use 12-hour clock (default 24-hour). */ + clock12h?: boolean; + /** Bit 1 - show hours. */ + showHours?: boolean; + /** Bit 2 - show minutes. */ + showMinutes?: boolean; + /** Bit 3 - show seconds. */ + showSeconds?: boolean; + // Bits 4-15: reserved. +} + +// --------------------------------------------------------------------------- +// Line element flags +// --------------------------------------------------------------------------- + +export interface LineFlags { + /** Bit 0 - invert pixel values along the line. */ + invertPixels?: boolean; + // Bits 1-7: reserved. +} + +// --------------------------------------------------------------------------- +// Font index helpers +// --------------------------------------------------------------------------- + +/** + * Font index encoding: + * 0 .. 0x7FFF built-in font (device-specific; may not exist) + * 0x8000+ custom font embedded in this file + * 0x8000 = first CustomFont section + * 0x8001 = second, etc. + */ +export type FontIndex = number; + +export const CUSTOM_FONT_BASE = 0x8000; + +/** Return true when fontIndex refers to a custom (in-file) font. */ +export function isCustomFont(fontIndex: FontIndex): boolean { + return fontIndex >= CUSTOM_FONT_BASE; +} + +/** Extract zero-based position of a custom font within the file. */ +export function customFontOrdinal(fontIndex: FontIndex): number { + return fontIndex - CUSTOM_FONT_BASE; +} + +// --------------------------------------------------------------------------- +// User-facing element descriptors (MonoDisplayFile / builder input) +// Pixels are 1 byte/pixel (0 = off, 1 = on) in the API; packed to 1bpp on +// serialisation. +// --------------------------------------------------------------------------- + +export interface Image2DElement { + type: ElementType.Image2D; + /** Row-major, 1 byte/pixel (0 = off, 1 = on). Packed to 1bpp on write. */ + pixels: Uint8Array; + width: number; + height: number; + xOffset?: number; // default 0 + yOffset?: number; // default 0 +} + +export interface AnimationFrameDescriptor { + /** 1 byte/pixel, same dimensions as the parent AnimationElement. */ + pixels: Uint8Array; +} + +export interface AnimationElement { + type: ElementType.Animation; + width: number; + height: number; + frames: AnimationFrameDescriptor[]; + /** + * Advance frame every (updateInterval + 1) ticks. + * 0 = every tick, 1 = every 2nd tick, ..., 65535 = every 65536th tick. + * Default 0. + */ + updateInterval?: number; + xOffset?: number; + yOffset?: number; +} + +export interface HScrollElement { + type: ElementType.HorizontalScroll; + /** Viewport width in pixels. */ + width: number; + /** Viewport height in pixels. */ + height: number; + /** Scrolling content pixels (contentWidth × height), 1 byte/pixel. */ + pixels: Uint8Array; + contentWidth: number; + /** + * Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. + * Range 0-255. Default 0 (= 1/16 px/tick). + */ + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export interface VScrollElement { + type: ElementType.VerticalScroll; + /** Viewport width in pixels. */ + width: number; + /** Viewport height in pixels. */ + height: number; + /** Scrolling content pixels (width × contentHeight), 1 byte/pixel. */ + pixels: Uint8Array; + contentHeight: number; + /** Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. Default 0. */ + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export interface LineElement { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + /** Reserved by spec; writers MUST write 0. */ + lineStyle?: number; + invertPixels?: boolean; // flags bit 0 +} + +export interface ClippedTextElement { + type: ElementType.ClippedText; + text: string; // UTF-8 + width: number; + height: number; + /** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */ + fontIndex?: FontIndex; + xOffset?: number; + yOffset?: number; +} + +export interface HScrollTextElement { + type: ElementType.HScrollText; + text: string; // UTF-8 + width: number; + height: number; + scrollSpeed?: number; // SS byte, default 0 + fontIndex?: FontIndex; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export interface CurrentTimeElement { + type: ElementType.CurrentTime; + width: number; + height: number; + /** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */ + fontIndex?: FontIndex; + /** + * UTC offset in whole minutes (signed 16-bit integer). + * e.g. UTC+5:30 → 330, UTC-8 → -480. + */ + utcOffsetMinutes?: number; + flags?: CurrentTimeFlags; + xOffset?: number; + yOffset?: number; +} + +export type DrawElement = + | Image2DElement + | AnimationElement + | HScrollElement + | VScrollElement + | LineElement + | ClippedTextElement + | HScrollTextElement + | CurrentTimeElement; + +// --------------------------------------------------------------------------- +// Section descriptors (MonoDisplayFile / builder input) +// --------------------------------------------------------------------------- + +export interface ElementsAlwaysDescriptor { + flags?: SectionFlags; + elements: DrawElement[]; +} + +export interface ElementsTimespanDescriptor { + flags?: SectionFlags; + elements: DrawElement[]; + /** POSIX timestamp (seconds) - section visible when now >= startTimestamp. */ + startTimestamp: bigint; + /** POSIX timestamp (seconds) - section visible when now < endTimestamp. */ + endTimestamp: bigint; +} + +export interface CustomFontDescriptor { + /** Raw U8G2 font data. */ + fontData: Uint8Array; +} + +/** + * Top-level descriptor passed to MonoDisplayFile. + * + * Sections are written in the order they appear here. + * Custom fonts MUST appear before any element that references them. + */ +export interface MonoDisplayFileDescriptor { + /** Section type 1 - drawn every render tick. */ + elements_always?: DrawElement[] | ElementsAlwaysDescriptor; + /** Section type 2 - drawn only within the given timestamp window. */ + elements_timespan?: ElementsTimespanDescriptor[]; + /** Section type 32 - embedded U8G2 custom fonts. */ + custom_fonts?: CustomFontDescriptor[]; +} + +// --------------------------------------------------------------------------- +// MonoFormat types (MonoDisplayParser output → renderer input) +// Pixels are always unpacked (1 byte/pixel) after parsing. +// --------------------------------------------------------------------------- + +export interface MonoFormatPixelImage { + /** Unpacked: 1 byte per pixel, 0 = off, 1 = on, row-major. */ + pixels: Uint8Array; + width: number; + height: number; +} + +export interface MonoFormatImage2D { + type: ElementType.Image2D; + xOffset: number; + yOffset: number; + image: MonoFormatPixelImage; +} + +export interface MonoFormatAnimation { + type: ElementType.Animation; + xOffset: number; + yOffset: number; + width: number; + height: number; + updateInterval: number; + frames: MonoFormatPixelImage[]; +} + +export interface MonoFormatScrollFlags { + endless: boolean; + invertDirection: boolean; + padStart: boolean; + padEnd: boolean; +} + +export interface MonoFormatHScroll { + type: ElementType.HorizontalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentWidth: number; + scrollSpeed: number; + flags: MonoFormatScrollFlags; + content: MonoFormatPixelImage; +} + +export interface MonoFormatVScroll { + type: ElementType.VerticalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentHeight: number; + scrollSpeed: number; + flags: MonoFormatScrollFlags; + content: MonoFormatPixelImage; +} + +export interface MonoFormatLine { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + lineStyle: number; + invertPixels: boolean; +} + +export interface MonoFormatClippedText { + type: ElementType.ClippedText; + xOffset: number; + yOffset: number; + width: number; + height: number; + fontIndex: FontIndex; + text: string; +} + +export interface MonoFormatHScrollText { + type: ElementType.HScrollText; + xOffset: number; + yOffset: number; + width: number; + height: number; + scrollSpeed: number; + fontIndex: FontIndex; + flags: MonoFormatScrollFlags; + text: string; +} + +export interface MonoFormatCurrentTimeFlags { + clock12h: boolean; + showHours: boolean; + showMinutes: boolean; + showSeconds: boolean; +} + +export interface MonoFormatCurrentTime { + type: ElementType.CurrentTime; + xOffset: number; + yOffset: number; + width: number; + height: number; + fontIndex: FontIndex; + /** UTC offset in minutes (signed). */ + utcOffsetMinutes: number; + flags: MonoFormatCurrentTimeFlags; +} + +export type MonoFormatElement = + | MonoFormatImage2D + | MonoFormatAnimation + | MonoFormatHScroll + | MonoFormatVScroll + | MonoFormatLine + | MonoFormatClippedText + | MonoFormatHScrollText + | MonoFormatCurrentTime; + +export interface MonoFormatSectionFlags { + drawFront: boolean; + drawBack: boolean; + clearBuffer: boolean; +} + +export interface MonoFormatElementsAlways { + sectionType: SectionType.ElementsAlways; + flags: MonoFormatSectionFlags; + elements: MonoFormatElement[]; +} + +export interface MonoFormatElementsTimespan { + sectionType: SectionType.ElementsTimespan; + flags: MonoFormatSectionFlags; + startTimestamp: bigint; // POSIX seconds + endTimestamp: bigint; + elements: MonoFormatElement[]; +} + +export interface MonoFormatCustomFont { + sectionType: SectionType.CustomFont; + /** Raw U8G2 font data (actual bytes, not padded). */ + fontData: Uint8Array; +} + +export type MonoFormatSection = + | MonoFormatElementsAlways + | MonoFormatElementsTimespan + | MonoFormatCustomFont; + +/** Top-level MonoDisplayParser output. */ +export interface MonoFormatFile { + sections: MonoFormatSection[]; +} From 40035ea258ff9f9dd357723f45f73f28a73e78d9 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 10/20] first rework --- ts/src/library.ts | 1726 ++++++++++++++++++++++++++------------------- 1 file changed, 1018 insertions(+), 708 deletions(-) diff --git a/ts/src/library.ts b/ts/src/library.ts index b80fad7..5996463 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -1,398 +1,388 @@ // ============================================================================= // 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 +// Spec: Mono Display File Format Specification (see adjacent .rst) // -// [CONTENT — varies by content_type] -// static: -// (width*height) bytes raw 1bpp pixel rows, MSB-first within each byte +// File layout (little-endian throughout): // -// animation: -// 2 bytes frame_count -// per frame: -// 4 bytes duration_ms -// (width*height) bytes pixel rows same as static +// [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 // -// 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 +// [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) // -// 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 +// 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… // -// 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. +// 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] +// +// 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 +// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored. +// +// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries. +// +// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first). // ============================================================================= // --------------------------------------------------------------------------- -// Public types — all exported so browser users can import them +// Enums // --------------------------------------------------------------------------- -/** 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 enum SectionType { + ElementsAlways = 1, + ElementsTimespan = 2, + CustomFont = 32, } -export interface Animation { - type: "animation"; - width: number; - height: number; - frames: AnimFrame[]; +export enum ElementType { + Image2D = 1, + Animation = 2, + HorizontalScroll = 3, + VerticalScroll = 4, + Line = 5, + ClippedText = 16, + HScrollText = 17, + // NOTE: spec shows CurrentTime as type 16 — likely a spec typo; treat as reserved until clarified } -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; -} +// --------------------------------------------------------------------------- +// Section flags (section types 1 and 2) +// --------------------------------------------------------------------------- -export interface ScrollText { - type: "scrolltext"; - text: string; - /** Scroll speed in pixels per second */ - speedPps: number; - /** 0=left 1=right */ - direction: 0 | 1; +export interface SectionFlags { + /** Bit 0: draw on the front side (default true for elements_always) */ + drawFront?: boolean; + /** Bit 1: draw on the back side */ + drawBack?: boolean; + /** Bit 2: clear the draw buffer(s) before drawing elements in this section */ + clearBuffer?: boolean; + // bits 3-15: reserved, must be 0 when writing } -export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText; - // --------------------------------------------------------------------------- -// MonoDisplayFile — JSON-descriptor → binary builder +// Scroll flags (HorizontalScroll / VerticalScroll elements) // --------------------------------------------------------------------------- -export enum ElementType { - /** Positioned 1bpp bitmap. Composited with OR-blend onto the display canvas. */ - Image2D = "Image2D", - /** Multi-frame bitmap animation. Each frame has its own pixel data and duration. */ - Animation = "Animation", - /** Static text rendered with the built-in bitmap font (stub until font spec lands). */ - Text = "Text", - /** Horizontally scrolling text ticker. */ - ScrollText = "ScrollText", +export interface ScrollElementFlags { + endless?: boolean; // bit 0 + invertDirection?: boolean; // bit 1 + padStart?: boolean; // bit 2: padLeft (H) / padTop (V) + padEnd?: boolean; // bit 3: padRight (H) / padBottom (V) } -/** Positioned 1bpp image, composited onto the display canvas */ +// --------------------------------------------------------------------------- +// User-facing element descriptors (MonoDisplayFile input) +// Pixels are always 1 byte/pixel (0=off, 1=on) in the API; packed at write time. +// --------------------------------------------------------------------------- + export interface Image2DElement { type: ElementType.Image2D; - /** 1 byte per pixel (0=off 1=on), row-major */ + /** 1 byte per pixel (0=off 1=on), row-major. Packed to 1bpp in binary. */ pixels: Uint8Array; - width: number; - height: number; - /** Pixel offset from left edge. Default 0. */ - xoffset?: number; - /** Pixel offset from top edge. Default 0. */ - yoffset?: number; + width: number; + height: number; + xOffset?: number; // default 0 + yOffset?: number; // default 0 } -/** One frame in an AnimationElement */ -export interface AnimationFrame { +export interface AnimationFrameDescriptor { + /** 1 byte per pixel, same dimensions as parent AnimationElement */ pixels: Uint8Array; - durationMs: number; } -/** Multi-frame animation section. width/height shared by all frames. */ export interface AnimationElement { type: ElementType.Animation; - width: number; + width: number; height: number; - frames: AnimationFrame[]; + frames: AnimationFrameDescriptor[]; + /** + * Advance frame every (updateInterval+1) ticks. + * 0 = every tick, 1 = every 2nd tick, etc. Default 0. + */ + updateInterval?: number; + xOffset?: number; + yOffset?: number; +} + +export interface HScrollElement { + type: ElementType.HorizontalScroll; + width: number; // viewport width + height: number; // viewport height + /** Pixel content to scroll horizontally; must be (contentWidth × height) pixels */ + pixels: Uint8Array; // 1 byte/pixel + contentWidth: number; + /** + * Scroll speed byte SS; moves (SS+1)/16 pixels per tick. Default 0 (1/16 px/tick). + * Range 0-255. + */ + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export interface VScrollElement { + type: ElementType.VerticalScroll; + width: number; + height: number; // viewport height + pixels: Uint8Array; // 1 byte/pixel + contentHeight: number; + scrollSpeed?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; } -/** Static text rendered by the driver's bitmap font */ -export interface TextElement { - type: ElementType.Text; - text: string; - /** 0=left 1=center 2=right. Default 0. */ - align?: 0 | 1 | 2; - /** Font index (reserved). Default 0. */ - fontId?: number; +export interface LineElement { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + lineStyle?: number; // reserved, default 0 + invertPixels?: boolean; // flags bit 0 } -/** Scrolling text ticker */ -export interface ScrollTextElement { - type: ElementType.ScrollText; - text: string; - /** Pixels per second. Default 60. */ - speedPps?: number; - /** 0=scroll left (→off left edge) 1=scroll right. Default 0. */ - direction?: 0 | 1; +export interface ClippedTextElement { + type: ElementType.ClippedText; + text: string; + width: number; + height: number; + /** 0-32767 built-in font, 0x8000+ custom font in file. Default 0. */ + fontIndex?: number; + xOffset?: number; + yOffset?: number; } -export type DisplayElement = +export interface HScrollTextElement { + type: ElementType.HScrollText; + text: string; + width: number; + height: number; + scrollSpeed?: number; // SS byte, default 0 + fontIndex?: number; + flags?: ScrollElementFlags; + xOffset?: number; + yOffset?: number; +} + +export type DrawElement = | Image2DElement | AnimationElement - | TextElement - | ScrollTextElement; + | HScrollElement + | VScrollElement + | LineElement + | ClippedTextElement + | HScrollTextElement; -/** - * JSON descriptor for MonoDisplayFile. - * - * elements_always — rendered on every frame regardless of state. - * PLAN: elements_conditional — state-machine-driven visibility (alerts, overrides). - * - * width / height — display canvas in pixels. Omit to infer from Image2D element bounds. - * Text/ScrollText/Animation ignore canvas size (section has no position). - */ -export interface MonoDisplayFileDescriptor { - width?: number; - height?: number; - elements_always?: DisplayElement[]; +// --------------------------------------------------------------------------- +// MonoDisplayFile descriptor +// --------------------------------------------------------------------------- + +export interface ElementsAlwaysDescriptor { + flags?: SectionFlags; + elements: DrawElement[]; } /** - * Builds a binary .bin file from a JSON descriptor. + * JSON descriptor passed to MonoDisplayFile. * - * Dispatch rules (first element_always wins for section type): - * Image2D only → static section (0x01), composited with OR-blend - * Animation → animation section (0x02) - * Text → text section (0x03) - * ScrollText → scrolltext section (0x04) + * elements_always — section type 1; drawn on every render tick. + * Pass a DrawElement[] as shorthand (flags default to drawFront=true). * - * PLAN: multiple sections per file once spec defines multi-section rendering. + * PLAN: elements_timespan (section 2), custom_fonts (section 32) */ -export class MonoDisplayFile { - private desc: MonoDisplayFileDescriptor; +export interface MonoDisplayFileDescriptor { + elements_always?: DrawElement[] | ElementsAlwaysDescriptor; +} - constructor(descriptor: MonoDisplayFileDescriptor) { - this.desc = descriptor; - } +// --------------------------------------------------------------------------- +// MonoFormat types (parser output → renderer input) +// Pixels are always unpacked (1 byte/pixel) after parsing. +// --------------------------------------------------------------------------- - toBuffer(): ArrayBuffer { - const elements = this.desc.elements_always ?? []; +export interface MonoFormatPixelImage { + /** Unpacked: 1 byte per pixel, 0=off 1=on, row-major */ + pixels: Uint8Array; + width: number; + height: number; +} - // Dispatch on first element's type (or default to blank static) - const first = elements[0]; - if (!first || first.type === ElementType.Image2D) { - return this.#encodeStatic(); - } - switch (first.type) { - case ElementType.Animation: return this.#encodeAnimation(first); - case ElementType.Text: return this.#encodeText(first); - case ElementType.ScrollText: return this.#encodeScrollText(first); - } - } +export interface MonoFormatImage2D { + type: ElementType.Image2D; + xOffset: number; + yOffset: number; + image: MonoFormatPixelImage; +} - // --- static (section 0x01) --- - - #encodeStatic(): ArrayBuffer { - const { width, height } = this.#resolveSize(); - const pixels = this.#composite(width, height); - return this.#wrapSection(0x01, (view, off) => { - view.setUint16(off, width, true); off += 2; - view.setUint16(off, height, true); off += 2; - new Uint8Array(view.buffer, off).set(pixels); - return off + pixels.byteLength; - }, 2 + 2 + width * height); - } - - // --- animation (section 0x02) --- - - #encodeAnimation(el: AnimationElement): ArrayBuffer { - const pixelBytes = el.width * el.height; - // width(2) + height(2) + frameCount(2) + frames*(durationMs(4) + pixels(w*h)) - const dataSize = 2 + 2 + 2 + el.frames.length * (4 + pixelBytes); - return this.#wrapSection(0x02, (view, off) => { - view.setUint16(off, el.width, true); off += 2; - view.setUint16(off, el.height, true); off += 2; - view.setUint16(off, el.frames.length, true); off += 2; - for (const frame of el.frames) { - view.setUint32(off, frame.durationMs, true); off += 4; - new Uint8Array(view.buffer, off).set(frame.pixels); - off += pixelBytes; - } - return off; - }, dataSize); - } +export interface MonoFormatAnimation { + type: ElementType.Animation; + xOffset: number; + yOffset: number; + width: number; + height: number; + updateInterval: number; + frames: MonoFormatPixelImage[]; +} - // --- text (section 0x03) --- +export interface MonoFormatHScroll { + type: ElementType.HorizontalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentWidth: number; + scrollSpeed: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + content: MonoFormatPixelImage; +} - #encodeText(el: TextElement): ArrayBuffer { - const enc = new TextEncoder().encode(el.text); - // textLen(2) + utf8 + fontId(1) + align(1) - const dataSize = 2 + enc.byteLength + 1 + 1; - return this.#wrapSection(0x03, (view, off) => { - view.setUint16(off, enc.byteLength, true); off += 2; - new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; - view.setUint8(off, el.fontId ?? 0); off += 1; - view.setUint8(off, el.align ?? 0); off += 1; - return off; - }, dataSize); - } +export interface MonoFormatVScroll { + type: ElementType.VerticalScroll; + xOffset: number; + yOffset: number; + width: number; + height: number; + contentHeight: number; + scrollSpeed: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + content: MonoFormatPixelImage; +} - // --- scrolltext (section 0x04) --- +export interface MonoFormatLine { + type: ElementType.Line; + xOrigin: number; + yOrigin: number; + xTarget: number; + yTarget: number; + lineStyle: number; + invertPixels: boolean; +} - #encodeScrollText(el: ScrollTextElement): ArrayBuffer { - const enc = new TextEncoder().encode(el.text); - // textLen(2) + utf8 + speedPps(2) + direction(1) - const dataSize = 2 + enc.byteLength + 2 + 1; - return this.#wrapSection(0x04, (view, off) => { - view.setUint16(off, enc.byteLength, true); off += 2; - new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength; - view.setUint16(off, el.speedPps ?? 60, true); off += 2; - view.setUint8(off, el.direction ?? 0); off += 1; - return off; - }, dataSize); - } +export interface MonoFormatClippedText { + type: ElementType.ClippedText; + xOffset: number; + yOffset: number; + width: number; + height: number; + fontIndex: number; + text: string; +} - // --- shared file/section envelope --- +export interface MonoFormatHScrollText { + type: ElementType.HScrollText; + xOffset: number; + yOffset: number; + width: number; + height: number; + scrollSpeed: number; + fontIndex: number; + flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; + text: string; +} - /** - * Allocates a buffer with a 12-byte file header + 4-byte section header + - * sectionDataSize bytes, then calls write(view, dataOffset) to fill data. - */ - #wrapSection( - sectionType: number, - write: (view: DataView, dataOffset: number) => number, - sectionDataSize: number, - ): ArrayBuffer { - const buf = new ArrayBuffer(12 + 4 + sectionDataSize); - const view = new DataView(buf); - let off = 0; +export type MonoFormatElement = + | MonoFormatImage2D + | MonoFormatAnimation + | MonoFormatHScroll + | MonoFormatVScroll + | MonoFormatLine + | MonoFormatClippedText + | MonoFormatHScrollText; + +export interface MonoFormatSectionFlags { + drawFront: boolean; + drawBack: boolean; + clearBuffer: boolean; +} - // File header - view.setUint32(off, MAGIC, true); off += 4; - view.setUint32(off, 1, true); off += 4; // version - view.setUint16(off, 1, true); off += 2; // numSections - view.setUint16(off, 0, true); off += 2; // reserved +export interface MonoFormatElementsAlways { + sectionType: SectionType.ElementsAlways; + flags: MonoFormatSectionFlags; + elements: MonoFormatElement[]; +} - // Section header: type(1) + size uint24 LE(3) - view.setUint8(off, sectionType); off += 1; - view.setUint8(off, sectionDataSize & 0xFF); - view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF); - view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF); - off += 3; +export interface MonoFormatElementsTimespan { + sectionType: SectionType.ElementsTimespan; + flags: MonoFormatSectionFlags; + startTimestamp: bigint; // POSIX seconds + endTimestamp: bigint; + elements: MonoFormatElement[]; +} - write(view, off); - return buf; - } +export interface MonoFormatCustomFont { + sectionType: SectionType.CustomFont; + /** Raw U8G2 font data */ + fontData: Uint8Array; +} - // --- size inference for Image2D compositing --- +export type MonoFormatSection = MonoFormatElementsAlways | MonoFormatElementsTimespan | MonoFormatCustomFont; - #resolveSize(): { width: number; height: number } { - const elements = (this.desc.elements_always ?? []) - .filter((el): el is Image2DElement => el.type === ElementType.Image2D); +/** Top-level parser output */ +export interface MonoFormatFile { + sections: MonoFormatSection[]; +} - let width = this.desc.width; - let height = this.desc.height; +// --------------------------------------------------------------------------- +// Pixel pack/unpack helpers +// --------------------------------------------------------------------------- - 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; - } +/** + * 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; +} - return { width, height }; - } - - #composite(width: number, height: number): Uint8Array { - const canvas = new Uint8Array(width * height); - for (const el of this.desc.elements_always ?? []) { - if (el.type !== ElementType.Image2D) continue; - 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 >= height) continue; - for (let col = 0; col < el.width; col++) { - const destX = ox + col; - if (destX < 0 || destX >= width) continue; - // OR-blend — PLAN: add blend mode enum once spec covers it - if (el.pixels[row * el.width + col]) canvas[destY * width + destX] = 1; - } - } - } - return canvas; +/** + * 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; } -/** 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; +/** 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 // --------------------------------------------------------------------------- -/** - * 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; @@ -401,420 +391,569 @@ export class BinaryReader { this.view = new DataView(buffer); } - get offset(): number { - return this.pos; - } - - get byteLength(): number { - return this.view.byteLength; - } + get offset(): number { return this.pos; } + get byteLength(): number { return this.view.byteLength; } + get remaining(): number { return this.view.byteLength - this.pos; } - 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; } - /** 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; + 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; } - /** 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; - } + readUint32(): number { this.#need(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.#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; } - /** 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.#need(n); + const s = new Uint8Array(this.view.buffer, this.pos, n); this.pos += n; - return slice; + return s; } - /** Read N bytes and decode as UTF-8 */ - readUtf8(n: number): string { - return new TextDecoder().decode(this.readBytes(n)); - } + 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}]`); + throw new RangeError(`seek(${pos}) out of [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` - ); - } + #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 (real spec) +// File format constants // --------------------------------------------------------------------------- -// File magic: 0xAF 0x7E 0x2B 0x63 (read as uint32 LE = 0x632B7EAF) +// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE 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 { + parse(buffer: ArrayBuffer): MonoFormatFile { const r = new BinaryReader(buffer); - // --- file header (12 bytes) --- + // File header (12 bytes) const magic = r.readUint32(); - if (magic !== MAGIC) { - throw new Error( - `Invalid magic 0x${magic.toString(16).toUpperCase()} — expected 0x632B7EAF` - ); - } + if (magic !== MAGIC) throw new Error(`Bad magic 0x${magic.toString(16)} — 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 version = r.readUint32(); + if (version === 0 || version > 1) throw new Error(`Unsupported version ${version}`); const numSections = r.readUint16(); - r.readUint16(); // reserved — ignored + r.readUint16(); // reserved - if (numSections === 0) { - throw new Error("File contains 0 sections — nothing to render"); - } + const sections: MonoFormatSection[] = []; - // --- 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; + 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; } - // Parse this section; width/height read from section data for bitmap types - const content = this.#parseSection(r, contentType); + // 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), + }; + } - // Seek past any remaining section bytes (handles padding / future fields) - r.seek(sectionStart + sectionSize); + #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(); + } - return content; // return first recognised section + const elements: MonoFormatElement[] = []; + for (let i = 0; i < numElements; i++) { + const el = this.#parseElement(r); + if (el) elements.push(el); } - throw new Error("No recognised sections found in file"); + if (hasTimestamp) { + return { sectionType: SectionType.ElementsTimespan, flags, startTimestamp, endTimestamp, elements }; + } + return { sectionType: SectionType.ElementsAlways, flags, elements }; } - #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); + #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; } } - #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) }; + #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) } - #parseAnimation(r: BinaryReader, width: number, height: number): Animation { - const frameCount = r.readUint16(); - const frames: AnimFrame[] = []; + #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 } }; + } - for (let i = 0; i < frameCount; i++) { - const durationMs = r.readUint32(); - const raw = r.readBytes(width * height); - frames.push({ durationMs, pixels: new Uint8Array(raw) }); + #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 }; + } - return { type: "animation", width, height, 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 }, + }; } - #parseText(r: BinaryReader): TextDisplay { - const textLen = r.readUint16(); + #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 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 }; + const dataRead = 14 + textLen; + r.seek(start + dataRead + pad32(dataRead)); + return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; } - #parseScrollText(r: BinaryReader): ScrollText { - const textLen = r.readUint16(); + #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 speedPps = r.readUint16(); - const dirByte = r.readByte(); - const direction = dirByte === 1 ? 1 : 0 as 0 | 1; - return { type: "scrolltext", text, speedPps, direction }; + 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 — draws DisplayContent onto an HTMLCanvasElement +// 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; +} + /** - * Internal renderer; MonoDisplayDriver owns one instance. + * 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: 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. + * 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; - // 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; + 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.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; + if (this.tickTimer !== null) { + clearInterval(this.tickTimer); + this.tickTimer = null; } - this.scrollLastTs = null; - this.scrollLastDrawTs = null; + this.tickCount = 0; + this.animState.clear(); + this.hScrollPos.clear(); + this.vScrollPos.clear(); } - render(content: DisplayContent): void { + render(file: MonoFormatFile): 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; + + 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++); + } } } - #resizeCanvas(w: number, h: number): void { - const s = this.opts.scale; - this.ctx.canvas.width = w * s; - this.ctx.canvas.height = h * s; + #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; + } } - #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); + /** 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 < height; y++) { - for (let x = 0; x < width; x++) { - if (pixels[y * width + x]) { - ctx.fillRect(x * s, y * s, s, s); + 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); } } } } - #renderStatic(content: StaticFrame): void { - this.#resizeCanvas(content.width, content.height); - this.#renderPixels(content.pixels, content.width, content.height); + /** 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); + } + } + } } - #startAnimation(content: Animation): void { - this.#resizeCanvas(content.width, content.height); - this.animFrame = 0; + #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); } - 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(); + 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; + } } - #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); + #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); } - #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; - } + #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); + } - // --- 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); - } + #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; } + } + } - this.scrollRaf = requestAnimationFrame(tick); - }; + #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(); + } - this.scrollRaf = requestAnimationFrame(tick); + #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); } } @@ -823,86 +962,294 @@ export class MonoDisplayRenderer { // --------------------------------------------------------------------------- /** - * Top-level driver. Attach to a canvas element by ID, then call load() with - * a function that returns a Promise of the .bin file. + * 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"); - * driver.load(() => loadBinFile("default_test.bin")); + * const driver = new MonoDisplayDriver("canvas_root", { fps: 25 }); + * driver.load(() => loadBinFile("display.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 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 `); - } + if (!el || el.tagName !== "CANVAS") throw new Error(`#${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; }), + 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); + 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 current animation / scroll without unloading */ - stop(): void { - this.renderer?.stop(); - } + stop(): void { this.renderer?.stop(); } } // --------------------------------------------------------------------------- -// Helpers exposed for browser convenience +// MonoDisplayFile — JSON descriptor → binary .bin builder // --------------------------------------------------------------------------- /** - * Fetch a .bin file by URL and return its ArrayBuffer. - * Intended as the `loader` argument to driver.load(). + * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. * - * @example - * ```ts - * driver.load(() => loadBinFile("/assets/default_test.bin")); - * ``` + * 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}`); @@ -910,46 +1257,9 @@ export async function loadBinFile(url: string): Promise { } /** - * 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. + * Build a single-element-always .bin buffer containing one Image2D element. + * Convenience for tests; for production use MonoDisplayFile directly. */ -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; +export function buildBinBuffer(el: Image2DElement): ArrayBuffer { + return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); } From db833b48c8cae9e3c77b5ffdb851cecdbb046b2a Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 11/20] feat: use types --- ts/src/library.ts | 331 +++++----------------------------------------- 1 file changed, 33 insertions(+), 298 deletions(-) diff --git a/ts/src/library.ts b/ts/src/library.ts index 5996463..57dbe80 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -40,304 +40,39 @@ // FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first). // ============================================================================= -// --------------------------------------------------------------------------- -// Enums -// --------------------------------------------------------------------------- - -export enum SectionType { - ElementsAlways = 1, - ElementsTimespan = 2, - CustomFont = 32, -} - -export enum ElementType { - Image2D = 1, - Animation = 2, - HorizontalScroll = 3, - VerticalScroll = 4, - Line = 5, - ClippedText = 16, - HScrollText = 17, - // NOTE: spec shows CurrentTime as type 16 — likely a spec typo; treat as reserved until clarified -} - -// --------------------------------------------------------------------------- -// Section flags (section types 1 and 2) -// --------------------------------------------------------------------------- - -export interface SectionFlags { - /** Bit 0: draw on the front side (default true for elements_always) */ - drawFront?: boolean; - /** Bit 1: draw on the back side */ - drawBack?: boolean; - /** Bit 2: clear the draw buffer(s) before drawing elements in this section */ - clearBuffer?: boolean; - // bits 3-15: reserved, must be 0 when writing -} - -// --------------------------------------------------------------------------- -// Scroll flags (HorizontalScroll / VerticalScroll elements) -// --------------------------------------------------------------------------- - -export interface ScrollElementFlags { - endless?: boolean; // bit 0 - invertDirection?: boolean; // bit 1 - padStart?: boolean; // bit 2: padLeft (H) / padTop (V) - padEnd?: boolean; // bit 3: padRight (H) / padBottom (V) -} - -// --------------------------------------------------------------------------- -// User-facing element descriptors (MonoDisplayFile input) -// Pixels are always 1 byte/pixel (0=off, 1=on) in the API; packed at write time. -// --------------------------------------------------------------------------- - -export interface Image2DElement { - type: ElementType.Image2D; - /** 1 byte per pixel (0=off 1=on), row-major. Packed to 1bpp in binary. */ - pixels: Uint8Array; - width: number; - height: number; - xOffset?: number; // default 0 - yOffset?: number; // default 0 -} - -export interface AnimationFrameDescriptor { - /** 1 byte per pixel, same dimensions as parent AnimationElement */ - pixels: Uint8Array; -} - -export interface AnimationElement { - type: ElementType.Animation; - width: number; - height: number; - frames: AnimationFrameDescriptor[]; - /** - * Advance frame every (updateInterval+1) ticks. - * 0 = every tick, 1 = every 2nd tick, etc. Default 0. - */ - updateInterval?: number; - xOffset?: number; - yOffset?: number; -} - -export interface HScrollElement { - type: ElementType.HorizontalScroll; - width: number; // viewport width - height: number; // viewport height - /** Pixel content to scroll horizontally; must be (contentWidth × height) pixels */ - pixels: Uint8Array; // 1 byte/pixel - contentWidth: number; - /** - * Scroll speed byte SS; moves (SS+1)/16 pixels per tick. Default 0 (1/16 px/tick). - * Range 0-255. - */ - scrollSpeed?: number; - flags?: ScrollElementFlags; - xOffset?: number; - yOffset?: number; -} - -export interface VScrollElement { - type: ElementType.VerticalScroll; - width: number; - height: number; // viewport height - pixels: Uint8Array; // 1 byte/pixel - contentHeight: number; - scrollSpeed?: number; - flags?: ScrollElementFlags; - xOffset?: number; - yOffset?: number; -} - -export interface LineElement { - type: ElementType.Line; - xOrigin: number; - yOrigin: number; - xTarget: number; - yTarget: number; - lineStyle?: number; // reserved, default 0 - invertPixels?: boolean; // flags bit 0 -} - -export interface ClippedTextElement { - type: ElementType.ClippedText; - text: string; - width: number; - height: number; - /** 0-32767 built-in font, 0x8000+ custom font in file. Default 0. */ - fontIndex?: number; - xOffset?: number; - yOffset?: number; -} - -export interface HScrollTextElement { - type: ElementType.HScrollText; - text: string; - width: number; - height: number; - scrollSpeed?: number; // SS byte, default 0 - fontIndex?: number; - flags?: ScrollElementFlags; - xOffset?: number; - yOffset?: number; -} - -export type DrawElement = - | Image2DElement - | AnimationElement - | HScrollElement - | VScrollElement - | LineElement - | ClippedTextElement - | HScrollTextElement; - -// --------------------------------------------------------------------------- -// MonoDisplayFile descriptor -// --------------------------------------------------------------------------- - -export interface ElementsAlwaysDescriptor { - flags?: SectionFlags; - elements: DrawElement[]; -} - -/** - * JSON descriptor passed to MonoDisplayFile. - * - * elements_always — section type 1; drawn on every render tick. - * Pass a DrawElement[] as shorthand (flags default to drawFront=true). - * - * PLAN: elements_timespan (section 2), custom_fonts (section 32) - */ -export interface MonoDisplayFileDescriptor { - elements_always?: DrawElement[] | ElementsAlwaysDescriptor; -} - -// --------------------------------------------------------------------------- -// MonoFormat types (parser output → renderer input) -// Pixels are always unpacked (1 byte/pixel) after parsing. -// --------------------------------------------------------------------------- - -export interface MonoFormatPixelImage { - /** Unpacked: 1 byte per pixel, 0=off 1=on, row-major */ - pixels: Uint8Array; - width: number; - height: number; -} - -export interface MonoFormatImage2D { - type: ElementType.Image2D; - xOffset: number; - yOffset: number; - image: MonoFormatPixelImage; -} - -export interface MonoFormatAnimation { - type: ElementType.Animation; - xOffset: number; - yOffset: number; - width: number; - height: number; - updateInterval: number; - frames: MonoFormatPixelImage[]; -} - -export interface MonoFormatHScroll { - type: ElementType.HorizontalScroll; - xOffset: number; - yOffset: number; - width: number; - height: number; - contentWidth: number; - scrollSpeed: number; - flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; - content: MonoFormatPixelImage; -} - -export interface MonoFormatVScroll { - type: ElementType.VerticalScroll; - xOffset: number; - yOffset: number; - width: number; - height: number; - contentHeight: number; - scrollSpeed: number; - flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; - content: MonoFormatPixelImage; -} - -export interface MonoFormatLine { - type: ElementType.Line; - xOrigin: number; - yOrigin: number; - xTarget: number; - yTarget: number; - lineStyle: number; - invertPixels: boolean; -} - -export interface MonoFormatClippedText { - type: ElementType.ClippedText; - xOffset: number; - yOffset: number; - width: number; - height: number; - fontIndex: number; - text: string; -} - -export interface MonoFormatHScrollText { - type: ElementType.HScrollText; - xOffset: number; - yOffset: number; - width: number; - height: number; - scrollSpeed: number; - fontIndex: number; - flags: { endless: boolean; invertDirection: boolean; padStart: boolean; padEnd: boolean }; - text: string; -} - -export type MonoFormatElement = - | MonoFormatImage2D - | MonoFormatAnimation - | MonoFormatHScroll - | MonoFormatVScroll - | MonoFormatLine - | MonoFormatClippedText - | MonoFormatHScrollText; - -export interface MonoFormatSectionFlags { - drawFront: boolean; - drawBack: boolean; - clearBuffer: boolean; -} - -export interface MonoFormatElementsAlways { - sectionType: SectionType.ElementsAlways; - flags: MonoFormatSectionFlags; - elements: MonoFormatElement[]; -} - -export interface MonoFormatElementsTimespan { - sectionType: SectionType.ElementsTimespan; - flags: MonoFormatSectionFlags; - startTimestamp: bigint; // POSIX seconds - endTimestamp: bigint; - elements: MonoFormatElement[]; -} - -export interface MonoFormatCustomFont { - sectionType: SectionType.CustomFont; - /** Raw U8G2 font data */ - fontData: Uint8Array; -} - -export type MonoFormatSection = MonoFormatElementsAlways | MonoFormatElementsTimespan | MonoFormatCustomFont; - -/** Top-level parser output */ -export interface MonoFormatFile { - sections: MonoFormatSection[]; -} +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 From 68ac1148723c864314b481530ccb50f7315445fc Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 12/20] 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); + } +} From 6dc75094c3ff6d7810f8be88ed6d7f4b3e4ef925 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 13/20] feat: remove library.ts and move to index.ts --- ts/src/index.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ ts/src/library.ts | 47 ----------------------------------------------- 2 files changed, 44 insertions(+), 47 deletions(-) delete mode 100644 ts/src/library.ts diff --git a/ts/src/index.ts b/ts/src/index.ts index e9327d5..37ae207 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -10,6 +10,50 @@ // this file will aggregate them here so callers never need to update imports. // ============================================================================= + +// ============================================================================= +// 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] +// 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] +// 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... +// +// 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] +// +// 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 +// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored. +// +// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries. +// +// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first). +// ============================================================================= + + export { // Driver (primary browser-facing API) MonoDisplayDriver, diff --git a/ts/src/library.ts b/ts/src/library.ts deleted file mode 100644 index bef6dc6..0000000 --- a/ts/src/library.ts +++ /dev/null @@ -1,47 +0,0 @@ -// ============================================================================= -// 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] -// 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] -// 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... -// -// 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] -// -// 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 -// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored. -// -// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries. -// -// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first). -// ============================================================================= - -export * from "./types"; -export * from "./driver"; -export * from "./parser"; -export * from "./file"; -export * from "./helper"; From 942d7dff70c3ad14d891349b0aedbc36a4dae61b Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 14/20] feat: update tests + imports --- ts/src/file.ts | 2 +- ts/src/helper.ts | 4 +- ts/src/index.ts | 56 +---- ts/src/parser.ts | 2 +- ts/src/renderer.ts | 3 +- ts/test/library.test.ts | 478 +++++++++++++++++----------------------- ts/tsconfig.json | 2 +- 7 files changed, 219 insertions(+), 328 deletions(-) diff --git a/ts/src/file.ts b/ts/src/file.ts index 1e07ecd..1d240e0 100644 --- a/ts/src/file.ts +++ b/ts/src/file.ts @@ -255,6 +255,6 @@ export async function loadBinFile(url: string): Promise { * 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 { +export function buildBinBuffer(el: Image2DElement): Uint8Array { return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); } diff --git a/ts/src/helper.ts b/ts/src/helper.ts index 22d3e67..1b2f64d 100644 --- a/ts/src/helper.ts +++ b/ts/src/helper.ts @@ -11,7 +11,7 @@ export function packPixels(pixels: Uint8Array, width: number, height: number): U 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); + if (pixels[i]) packed[i >> 3] = (packed[i >> 3] ?? 0) | (1 << (i & 7)); } return packed; } @@ -23,7 +23,7 @@ export function unpackPixels(packed: Uint8Array, width: number, height: number): const total = width * height; const pixels = new Uint8Array(total); for (let i = 0; i < total; i++) { - pixels[i] = (packed[i >> 3] >> (i & 7)) & 1; + pixels[i] = ((packed[i >> 3] ?? 0) >> (i & 7)) & 1; } return pixels; } diff --git a/ts/src/index.ts b/ts/src/index.ts index 37ae207..48ef98f 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -6,11 +6,7 @@ // // import { MonoDisplayDriver, loadBinFile } from "./index.ts"; // -// PLAN: if the project splits into sub-packages (parser / renderer / utils), -// this file will aggregate them here so callers never need to update imports. // ============================================================================= - - // ============================================================================= // MonoDisplay Library - binary format parser + canvas renderer // @@ -54,49 +50,9 @@ // ============================================================================= -export { - // Driver (primary browser-facing API) - MonoDisplayDriver, - - // File builder — JSON descriptor → binary .bin buffer - MonoDisplayFile, - ElementType, - - // Parser — useful when you want to parse without rendering - MonoDisplayParser, - - // Low-level binary reader — exposed so power users can parse custom sections - BinaryReader, - - // Renderer — exposed for embedding into custom canvas setups - MonoDisplayRenderer, - - // Convenience helpers - loadBinFile, - buildBinBuffer, -} from "./library"; - -export type { - // File builder types - MonoDisplayFileDescriptor, - DisplayElement, - Image2DElement, - AnimationFrame, - AnimationElement, - TextElement, - ScrollTextElement, - - // Union type for all display content variants - DisplayContent, - DisplayContentType, - - // Concrete content types - StaticFrame, - AnimFrame, - Animation, - TextDisplay, - ScrollText, - - // Driver options bag - MonoDisplayDriverOptions, -} from "./library"; +export * from "./types"; +export { BinaryReader, packPixels, unpackPixels, packedSize, pad32 } from "./helper"; +export { MonoDisplayParser, MONOFORMAT_MAGIC_HEADER } from "./parser"; +export { MonoDisplayRenderer } from "./renderer"; +export { type MonoDisplayDriverOptions, MonoDisplayDriver } from "./driver"; +export { MonoDisplayFile, loadBinFile, buildBinBuffer } from "./file"; diff --git a/ts/src/parser.ts b/ts/src/parser.ts index e019930..ac62c39 100644 --- a/ts/src/parser.ts +++ b/ts/src/parser.ts @@ -4,7 +4,7 @@ // --------------------------------------------------------------------------- import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper"; -import { type MonoFormatFile } from "./library"; +import { type MonoFormatFile } from "./types"; 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 diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts index 9a5f1a8..5e6d7b5 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -14,6 +14,7 @@ import { type MonoFormatPixelImage, type MonoFormatVScroll } from "./types.js"; +import { type MonoDisplayDriverOptions } from "./driver"; /** * Renders a MonoFormatFile onto an HTMLCanvasElement. * Uses setInterval at 1000/fps ms per tick. All element state (animation @@ -151,7 +152,7 @@ export class MonoDisplayRenderer { 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); + this.#blitImage(el.frames[state.frame]!, el.xOffset, el.yOffset); state.counter++; if (state.counter > el.updateInterval) { diff --git a/ts/test/library.test.ts b/ts/test/library.test.ts index 3041ee4..dc16af9 100644 --- a/ts/test/library.test.ts +++ b/ts/test/library.test.ts @@ -2,9 +2,6 @@ // library.test.ts — bun test suite for MonoDisplay library // // run: bun test -// -// Tests cover BinaryReader primitives and MonoDisplayParser with the real -// binary format: magic 0xAF7E2B63, section-based layout. // ============================================================================= import { test, expect, describe } from "bun:test"; @@ -13,12 +10,12 @@ import { MonoDisplayParser, MonoDisplayFile, ElementType, + SectionType, buildBinBuffer, - type StaticFrame, - type Animation, - type TextDisplay, - type ScrollText, -} from "../src/library"; + type Image2DElement, + type MonoFormatElementsAlways, + type MonoFormatImage2D, +} from "../src/index"; // --------------------------------------------------------------------------- // Low-level buffer helpers @@ -28,7 +25,9 @@ import { const MAGIC_BYTES = new Uint8Array([0xAF, 0x7E, 0x2B, 0x63]); function concat(...parts: (Uint8Array | ArrayBuffer)[]): ArrayBuffer { - const arrays = parts.map(p => p instanceof ArrayBuffer ? new Uint8Array(p) : p); + const arrays = parts.map((p: Uint8Array | ArrayBuffer) => + p instanceof ArrayBuffer ? new Uint8Array(p) : p + ); const total = arrays.reduce((s, a) => s + a.byteLength, 0); const out = new Uint8Array(total); let off = 0; @@ -36,30 +35,11 @@ function concat(...parts: (Uint8Array | ArrayBuffer)[]): ArrayBuffer { return out.buffer; } -function u8(n: number) { return new Uint8Array([n]); } +function u8(n: number) { return new Uint8Array([n]); } function u16le(n: number) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; } function u24le(n: number) { return new Uint8Array([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF]); } function u32le(n: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; } -/** - * Build a well-formed file buffer with a single section. - * sectionType: 0x01=static 0x02=animation 0x03=text 0x04=scrolltext - * sectionData: raw section payload (after section header) - */ -function makeFile(sectionType: number, sectionData: Uint8Array): ArrayBuffer { - const fileHeader = concat( - MAGIC_BYTES, // magic - u32le(1), // version = 1 - u16le(1), // numSections = 1 - u16le(0), // reserved = 0 - ); - const sectionHeader = concat( - u8(sectionType), - u24le(sectionData.byteLength), - ); - return concat(fileHeader, sectionHeader, sectionData); -} - // --------------------------------------------------------------------------- // BinaryReader // --------------------------------------------------------------------------- @@ -159,273 +139,227 @@ describe("MonoDisplayParser — header", () => { const buf = concat(MAGIC_BYTES, u32le(2), u16le(0), u16le(0)); expect(() => parser.parse(buf)).toThrow(/version/i); }); - - test("rejects 0 sections", () => { - const buf = concat(MAGIC_BYTES, u32le(1), u16le(0), u16le(0)); // 0 sections - expect(() => parser.parse(buf)).toThrow(/0 section/i); - }); - - test("skips unknown section type, throws when no recognised section follows", () => { - // Section type 0xFF is unknown - const unknownData = new Uint8Array(4); - const fileHeader = concat(MAGIC_BYTES, u32le(1), u16le(1), u16le(0)); - const secHeader = concat(u8(0xFF), u24le(unknownData.byteLength)); - const buf = concat(fileHeader, secHeader, unknownData); - expect(() => parser.parse(buf)).toThrow(/no recognised/i); - }); -}); - -// --------------------------------------------------------------------------- -// MonoDisplayParser — static section -// --------------------------------------------------------------------------- - -describe("MonoDisplayParser — static", () => { - const parser = new MonoDisplayParser(); - - test("parses 2×2 static frame", () => { - const data = concat(u16le(2), u16le(2), new Uint8Array([1, 0, 0, 1])); - const buf = makeFile(0x01, new Uint8Array(data)); - - const result = parser.parse(buf) as StaticFrame; - expect(result.type).toBe("static"); - expect(result.width).toBe(2); - expect(result.height).toBe(2); - expect(result.pixels[0]).toBe(1); - expect(result.pixels[3]).toBe(1); - }); -}); - -// --------------------------------------------------------------------------- -// MonoDisplayParser — animation section -// --------------------------------------------------------------------------- - -describe("MonoDisplayParser — animation", () => { - const parser = new MonoDisplayParser(); - - test("parses 2-frame animation", () => { - const pixels = new Uint8Array(4); // 2×2 - const data = concat( - u16le(2), u16le(2), // width, height - u16le(2), // frameCount - u32le(100), pixels, // frame 1: 100ms, all-off - u32le(200), new Uint8Array([1,1,1,1]), // frame 2: 200ms, all-on - ); - const buf = makeFile(0x02, new Uint8Array(data)); - - const result = parser.parse(buf) as Animation; - expect(result.type).toBe("animation"); - expect(result.frames.length).toBe(2); - expect(result.frames[0].durationMs).toBe(100); - expect(result.frames[1].durationMs).toBe(200); - }); - - test("empty animation (0 frames) is valid", () => { - const data = concat(u16le(4), u16le(4), u16le(0)); - const buf = makeFile(0x02, new Uint8Array(data)); - const result = parser.parse(buf) as Animation; - expect(result.frames.length).toBe(0); - }); -}); - -// --------------------------------------------------------------------------- -// MonoDisplayParser — text section -// --------------------------------------------------------------------------- - -describe("MonoDisplayParser — text", () => { - const parser = new MonoDisplayParser(); - - test("parses text with align=center", () => { - const enc = new TextEncoder().encode("HELLO"); - const data = concat(u16le(enc.byteLength), enc, u8(0), u8(1)); // fontId=0 align=1 - const buf = makeFile(0x03, new Uint8Array(data)); - - const result = parser.parse(buf) as TextDisplay; - expect(result.type).toBe("text"); - expect(result.text).toBe("HELLO"); - expect(result.align).toBe(1); - expect(result.fontId).toBe(0); - }); - - test("handles multi-byte UTF-8 text", () => { - const str = "日本語"; - const enc = new TextEncoder().encode(str); - const data = concat(u16le(enc.byteLength), enc, u8(0), u8(0)); - const buf = makeFile(0x03, new Uint8Array(data)); - - const result = parser.parse(buf) as TextDisplay; - expect(result.text).toBe(str); - }); }); // --------------------------------------------------------------------------- -// MonoDisplayParser — scrolltext section +// MonoDisplayParser — Image2D round-trip // --------------------------------------------------------------------------- -describe("MonoDisplayParser — scrolltext", () => { +describe("MonoDisplayParser — Image2D round-trip", () => { const parser = new MonoDisplayParser(); - test("parses scrolltext left-direction", () => { - const enc = new TextEncoder().encode("SCROLL ME"); - const data = concat(u16le(enc.byteLength), enc, u16le(60), u8(0)); - const buf = makeFile(0x04, new Uint8Array(data)); - - const result = parser.parse(buf) as ScrollText; - expect(result.type).toBe("scrolltext"); - expect(result.text).toBe("SCROLL ME"); - expect(result.speedPps).toBe(60); - expect(result.direction).toBe(0); - }); - - test("handles multi-codepoint text incl. emoji", () => { - const str = "TICKER 🚀 日本語"; - const enc = new TextEncoder().encode(str); - const data = concat(u16le(enc.byteLength), enc, u16le(80), u8(1)); - const buf = makeFile(0x04, new Uint8Array(data)); - - const result = parser.parse(buf) as ScrollText; - expect(result.text).toBe(str); - expect(result.direction).toBe(1); + /** + * Build a minimal valid file buffer containing one ElementsAlways section + * with one Image2D element. + * + * File header (12 bytes): + * magic u32le + version u32le + numSections u16le + reserved u16le + * + * Section header (4 bytes): + * type u8 + size u24le (INCLUDES 4-byte header) + * + * ElementsAlways section data: + * flags u16le + numElements u16le + * + * Image2D element (12 fixed bytes + packed pixel data): + * type u16le + xOffset u16le + yOffset u16le + width u16le + height u16le + reserved u16le + * + packed 1bpp pixel data + padding to 4-byte boundary + */ + function makeImage2DFile( + width: number, + height: number, + pixels: Uint8Array, + xOffset: number = 0, + yOffset: number = 0, + ): ArrayBuffer { + // Pack pixels to 1bpp + const total = width * height; + const packedLen = (total + 7) >> 3; + const packed = new Uint8Array(packedLen); + for (let i = 0; i < total; i++) { + if (pixels[i]) packed[i >> 3] = (packed[i >> 3] ?? 0) | (1 << (i & 7)); + } + // Padding to 4-byte boundary for element (12 fixed bytes + packed data) + const elementDataLen = 12 + packedLen; + const padLen = (4 - (elementDataLen & 3)) & 3; + const padding = new Uint8Array(padLen); + + // Image2D element bytes + const element = new Uint8Array(concat( + u16le(1), // type = Image2D + u16le(xOffset), + u16le(yOffset), + u16le(width), + u16le(height), + u16le(0), // reserved + packed, + padding, + )); + + // Section data: flags + numElements + element + const sectionData = new Uint8Array(concat( + u16le(0), // flags = 0 + u16le(1), // numElements = 1 + element, + )); + + // Section header: type u8 + size u24le (4 header bytes + data) + const sectionSize = 4 + sectionData.byteLength; + const sectionHeader = new Uint8Array(concat( + u8(1), // SectionType.ElementsAlways = 1 + u24le(sectionSize), + )); + + // File header + const fileHeader = new Uint8Array(concat( + MAGIC_BYTES, + u32le(1), // version = 1 + u16le(1), // numSections = 1 + u16le(0), // reserved + )); + + return concat(fileHeader, sectionHeader, sectionData); + } + + test("sections[0].sectionType === SectionType.ElementsAlways", () => { + const pixels = new Uint8Array([1, 0, 0, 1]); + const buf = makeImage2DFile(2, 2, pixels); + const result = parser.parse(buf); + expect(result.sections.length).toBe(1); + expect(result.sections[0]!.sectionType).toBe(SectionType.ElementsAlways); + }); + + test("elements[0].type === ElementType.Image2D", () => { + const pixels = new Uint8Array([1, 0, 0, 1]); + const buf = makeImage2DFile(2, 2, pixels); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + expect(section.elements.length).toBe(1); + expect(section.elements[0]!.type).toBe(ElementType.Image2D); + }); + + test("correct dimensions after parse", () => { + const pixels = new Uint8Array([0, 1, 1, 0]); + const buf = makeImage2DFile(2, 2, pixels); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + const el = section.elements[0] as MonoFormatImage2D; + expect(el.image.width).toBe(2); + expect(el.image.height).toBe(2); + }); + + test("correct pixel data after round-trip", () => { + // pixels: [1, 0, 0, 1] → top-left and bottom-right are on + const pixels = new Uint8Array([1, 0, 0, 1]); + const buf = makeImage2DFile(2, 2, pixels); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + const el = section.elements[0] as MonoFormatImage2D; + expect(Array.from(el.image.pixels)).toEqual([1, 0, 0, 1]); + }); + + test("xOffset and yOffset are preserved", () => { + const pixels = new Uint8Array([1, 1, 1, 1]); + const buf = makeImage2DFile(2, 2, pixels, 5, 3); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + const el = section.elements[0] as MonoFormatImage2D; + expect(el.xOffset).toBe(5); + expect(el.yOffset).toBe(3); }); }); // --------------------------------------------------------------------------- -// MonoDisplayParser — unknown section skip +// MonoDisplayFile // --------------------------------------------------------------------------- -describe("MonoDisplayParser — section skip", () => { +describe("MonoDisplayFile", () => { const parser = new MonoDisplayParser(); - test("skips unknown section, parses second known section", () => { - // File with 2 sections: 0xFF (unknown) then 0x01 (static 2×2) - const staticData = new Uint8Array(concat(u16le(2), u16le(2), new Uint8Array([0,1,1,0]))); - const unknownData = new Uint8Array(4).fill(0xAA); - - const fileHeader = concat(MAGIC_BYTES, u32le(1), u16le(2), u16le(0)); - const sec1 = concat(u8(0xFF), u24le(unknownData.byteLength), unknownData); - const sec2 = concat(u8(0x01), u24le(staticData.byteLength), staticData); - const buf = concat(fileHeader, sec1, sec2); - - const result = parser.parse(buf) as StaticFrame; - expect(result.type).toBe("static"); - expect(result.pixels[1]).toBe(1); + test("toBuffer() produces parseable output for Image2D element", () => { + const pixels = new Uint8Array([1, 0, 1, 0]); + const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 2, height: 2 }; + const file = new MonoDisplayFile({ elements_always: [el] }); + const buf = file.toBuffer(); + const result = parser.parse(buf); + expect(result.sections.length).toBeGreaterThan(0); + const section = result.sections[0] as MonoFormatElementsAlways; + expect(section.sectionType).toBe(SectionType.ElementsAlways); + expect(section.elements.length).toBe(1); + const parsed = section.elements[0] as MonoFormatImage2D; + expect(parsed.type).toBe(ElementType.Image2D); + expect(Array.from(parsed.image.pixels)).toEqual([1, 0, 1, 0]); + }); + + test("xOffset/yOffset are round-tripped", () => { + const pixels = new Uint8Array([1, 1, 1, 1]); + const el: Image2DElement = { + type: ElementType.Image2D, + pixels, + width: 2, + height: 2, + xOffset: 10, + yOffset: 7, + }; + const file = new MonoDisplayFile({ elements_always: [el] }); + const buf = file.toBuffer(); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + const parsed = section.elements[0] as MonoFormatImage2D; + expect(parsed.xOffset).toBe(10); + expect(parsed.yOffset).toBe(7); + }); + + test("flags.drawFront default is applied", () => { + const pixels = new Uint8Array([0]); + const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 1, height: 1 }; + const file = new MonoDisplayFile({ elements_always: [el] }); + const buf = file.toBuffer(); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + // Default flags should have drawFront = true (flags byte bit 0 = 1) + expect(section.flags.drawFront).toBe(true); + }); + + test("empty elements_always array is valid and produces parseable output", () => { + const file = new MonoDisplayFile({ elements_always: [] }); + const buf = file.toBuffer(); + const result = parser.parse(buf); + expect(result.sections.length).toBeGreaterThan(0); + const section = result.sections[0] as MonoFormatElementsAlways; + expect(section.sectionType).toBe(SectionType.ElementsAlways); + expect(section.elements.length).toBe(0); }); }); // --------------------------------------------------------------------------- -// buildBinBuffer round-trip +// buildBinBuffer // --------------------------------------------------------------------------- describe("buildBinBuffer", () => { const parser = new MonoDisplayParser(); - test("static round-trip", () => { + test("builds a parseable Uint8Array from Image2DElement", () => { const pixels = new Uint8Array([0, 1, 1, 0]); - const original: StaticFrame = { type: "static", width: 2, height: 2, pixels }; - const buf = buildBinBuffer(original); - const parsed = parser.parse(buf) as StaticFrame; - expect(parsed.type).toBe("static"); - expect(parsed.width).toBe(2); - expect(parsed.height).toBe(2); - expect(Array.from(parsed.pixels)).toEqual([0, 1, 1, 0]); - }); - - test("non-static throws (not implemented yet)", () => { - const anim: Animation = { type: "animation", width: 1, height: 1, frames: [] }; - expect(() => buildBinBuffer(anim)).toThrow(/not yet implemented/); - }); -}); - -// --------------------------------------------------------------------------- -// MonoDisplayFile — JSON descriptor builder -// --------------------------------------------------------------------------- - -describe("MonoDisplayFile", () => { - const parser = new MonoDisplayParser(); - - test("single Image2D fills full display", () => { - const pixels = new Uint8Array(4 * 2).fill(1); // 4×2 all-on - const file = new MonoDisplayFile({ - width: 4, height: 2, - elements_always: [{ type: ElementType.Image2D, pixels, width: 4, height: 2 }], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - expect(result.type).toBe("static"); - expect(result.width).toBe(4); - expect(result.height).toBe(2); - expect(result.pixels.every(p => p === 1)).toBe(true); - }); - - test("xoffset / yoffset positions element", () => { - // 6×2 display, 2×2 all-on block placed at xoffset=2 - const block = new Uint8Array(4).fill(1); - const file = new MonoDisplayFile({ - width: 6, height: 2, - elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 2 }], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - // cols 0,1 off — cols 2,3 on — cols 4,5 off - expect(result.pixels[0]).toBe(0); // (0,0) off - expect(result.pixels[2]).toBe(1); // (2,0) on - expect(result.pixels[3]).toBe(1); // (3,0) on - expect(result.pixels[4]).toBe(0); // (4,0) off - }); - - test("two overlapping elements OR-blend", () => { - // 4×1 display: element A sets pixels [0,1,0,0], element B sets [0,0,0,1] - const a = new Uint8Array([0, 1, 0, 0]); - const b = new Uint8Array([0, 0, 0, 1]); - const file = new MonoDisplayFile({ - width: 4, height: 1, - elements_always: [ - { type: ElementType.Image2D, pixels: a, width: 4, height: 1 }, - { type: ElementType.Image2D, pixels: b, width: 4, height: 1 }, - ], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - expect(Array.from(result.pixels)).toEqual([0, 1, 0, 1]); - }); - - test("element clipped at right/bottom edge", () => { - // 4×2 display, 2×2 block placed at xoffset=3 — only 1 col fits - const block = new Uint8Array(4).fill(1); - const file = new MonoDisplayFile({ - width: 4, height: 2, - elements_always: [{ type: ElementType.Image2D, pixels: block, width: 2, height: 2, xoffset: 3 }], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - // col 3 on, col 4+ clipped - expect(result.pixels[3]).toBe(1); // (3,0) on - expect(result.width).toBe(4); // canvas didn't grow - }); - - test("size inferred from element bounding box when omitted", () => { - const pixels = new Uint8Array(3 * 2).fill(1); - const file = new MonoDisplayFile({ - elements_always: [{ type: ElementType.Image2D, pixels, width: 3, height: 2, xoffset: 1, yoffset: 1 }], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - expect(result.width).toBe(4); // 1 + 3 - expect(result.height).toBe(3); // 1 + 2 - }); - - test("empty descriptor produces 160×80 blank static frame", () => { - const file = new MonoDisplayFile({}); - const result = parser.parse(file.toBuffer()) as StaticFrame; - expect(result.width).toBe(160); - expect(result.height).toBe(80); - expect(result.pixels.every(p => p === 0)).toBe(true); - }); - - test("toBuffer round-trips through parser", () => { - const pixels = new Uint8Array(120 * 80); - for (let i = 0; i < pixels.length; i++) pixels[i] = i % 2; - const file = new MonoDisplayFile({ - width: 160, height: 80, - elements_always: [{ type: ElementType.Image2D, pixels, width: 120, height: 80, xoffset: 20 }], - }); - const result = parser.parse(file.toBuffer()) as StaticFrame; - expect(result.width).toBe(160); - expect(result.pixels[20]).toBe(0); // first pixel of element (i=0 → 0) - expect(result.pixels[21]).toBe(1); // second pixel (i=1 → 1) + const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 2, height: 2 }; + const buf = buildBinBuffer(el); + expect(buf).toBeInstanceOf(Uint8Array); + const result = parser.parse(buf); + expect(result.sections.length).toBeGreaterThan(0); + const section = result.sections[0] as MonoFormatElementsAlways; + const parsed = section.elements[0] as MonoFormatImage2D; + expect(Array.from(parsed.image.pixels)).toEqual([0, 1, 1, 0]); + }); + + test("pixel data round-trips correctly", () => { + const pixels = new Uint8Array([1, 0, 0, 0, 0, 0, 0, 1]); // 8 pixels, corners on + const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 8, height: 1 }; + const buf = buildBinBuffer(el); + const result = parser.parse(buf); + const section = result.sections[0] as MonoFormatElementsAlways; + const parsed = section.elements[0] as MonoFormatImage2D; + expect(parsed.image.width).toBe(8); + expect(parsed.image.height).toBe(1); + expect(Array.from(parsed.image.pixels)).toEqual([1, 0, 0, 0, 0, 0, 0, 1]); }); }); diff --git a/ts/tsconfig.json b/ts/tsconfig.json index bfa0fea..be3d138 100644 --- a/ts/tsconfig.json +++ b/ts/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force", From 1cc948a85c9f881dee43511e4da3f266b4e04c85 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 15/20] feat: updated config --- ts/index.html | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ts/index.html b/ts/index.html index b106b67..d5ba974 100644 --- a/ts/index.html +++ b/ts/index.html @@ -135,13 +135,14 @@ label: "Blink (animation)", make() { return new MonoDisplayFile({ - width: W, height: H, elements_always: [{ type: ElementType.Animation, width: W, height: H, + // updateInterval: 0 = advance every tick (25fps → ~400ms/frame at fps=25 with 2 frames) + updateInterval: 12, // every 13 ticks ≈ 500ms per frame at 25fps frames: [ - { pixels: new Uint8Array(W * H).fill(1), durationMs: 200 }, - { pixels: new Uint8Array(W * H).fill(0), durationMs: 200 }, + { pixels: new Uint8Array(W * H).fill(1) }, + { pixels: new Uint8Array(W * H).fill(0) }, ], }], }).toBuffer(); @@ -151,7 +152,12 @@ label: "Text (centered)", make() { return new MonoDisplayFile({ - elements_always: [{ type: ElementType.Text, text: "Hello, World!", align: 1 }], + elements_always: [{ + type: ElementType.ClippedText, + text: "Hello, World!", + xOffset: 0, yOffset: 32, + width: W, height: 16, + }], }).toBuffer(); }, }, @@ -160,10 +166,12 @@ make() { return new MonoDisplayFile({ elements_always: [{ - type: ElementType.ScrollText, + type: ElementType.HScrollText, text: "MONO DISPLAY — scrolling ticker — 🚀 ", - speedPps: 80, - direction: 0, + xOffset: 0, yOffset: 32, + width: W, height: 16, + scrollSpeed: 50, // (50+1)/16 ≈ 3.2 px/tick at 25fps ≈ 80px/s + flags: { endless: true, invertDirection: false }, }], }).toBuffer(); }, From ec21555d3a3308bdfd78690a771347077959848c Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 16/20] feat: updated parser --- ts/src/parser.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ts/src/parser.ts b/ts/src/parser.ts index ac62c39..536609a 100644 --- a/ts/src/parser.ts +++ b/ts/src/parser.ts @@ -20,7 +20,7 @@ export class MonoDisplayParser { // File header (12 bytes) const magic = r.readUint32(); - if (magic !== MONOFORMAT_MAGIC_HEADER) throw new Error(`Bad magic 0x${magic.toString(16)} — expected 0x632B7EAF`); + 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}`); @@ -36,18 +36,19 @@ export class MonoDisplayParser { const sectionStart = r.offset; // start of section DATA const dataSize = sectionSize - 4; + const sectionEnd = sectionStart + dataSize; switch (sectionType) { case SectionType.ElementsAlways: - sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false)); + sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false, sectionEnd)); break; case SectionType.ElementsTimespan: - sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true)); + sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true, sectionEnd)); break; case SectionType.CustomFont: sections.push(this.#parseCustomFont(r, dataSize)); break; default: - // Unknown — skip + // Unknown - skip break; } @@ -70,6 +71,7 @@ export class MonoDisplayParser { r: BinaryReader, type: SectionType.ElementsAlways | SectionType.ElementsTimespan, hasTimestamp: boolean, + sectionEnd: number, ): MonoFormatElementsAlways | MonoFormatElementsTimespan { const flags = this.#parseFlags(r.readUint16()); const numElements = r.readUint16(); @@ -82,8 +84,11 @@ export class MonoDisplayParser { const elements: MonoFormatElement[] = []; for (let i = 0; i < numElements; i++) { + // Stop if fewer than 2 bytes remain in section (can't read element type) + if (r.offset + 2 > sectionEnd) break; const el = this.#parseElement(r); - if (el) elements.push(el); + if (el === null) break; // unknown type - can't determine size, stop + elements.push(el); } if (hasTimestamp) { @@ -112,7 +117,7 @@ export class MonoDisplayParser { 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 + // Unknown element type - cannot safely skip without knowing size; stop parsing section elements return null; } } From 2e102dbba21b85eafddd998ce4de81dde07ac0ac Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 17/20] fix: blink config --- ts/index.html | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/ts/index.html b/ts/index.html index d5ba974..fde17f7 100644 --- a/ts/index.html +++ b/ts/index.html @@ -135,16 +135,18 @@ label: "Blink (animation)", make() { return new MonoDisplayFile({ - elements_always: [{ - type: ElementType.Animation, - width: W, height: H, - // updateInterval: 0 = advance every tick (25fps → ~400ms/frame at fps=25 with 2 frames) - updateInterval: 12, // every 13 ticks ≈ 500ms per frame at 25fps - frames: [ - { pixels: new Uint8Array(W * H).fill(1) }, - { pixels: new Uint8Array(W * H).fill(0) }, - ], - }], + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.Animation, + width: W, height: H, + updateInterval: 12, // every 13 ticks ≈ 500ms per frame at 25fps + frames: [ + { pixels: new Uint8Array(W * H).fill(1) }, + { pixels: new Uint8Array(W * H).fill(0) }, + ], + }], + }, }).toBuffer(); }, }, From 75d3f4963163633e2bc76c3ba2769544e1dc1a9b Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 18/20] fix: other demos --- ts/index.html | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/ts/index.html b/ts/index.html index fde17f7..a2b3345 100644 --- a/ts/index.html +++ b/ts/index.html @@ -154,12 +154,15 @@ label: "Text (centered)", make() { return new MonoDisplayFile({ - elements_always: [{ - type: ElementType.ClippedText, - text: "Hello, World!", - xOffset: 0, yOffset: 32, - width: W, height: 16, - }], + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.ClippedText, + text: "Hello, World!", + xOffset: 0, yOffset: 32, + width: W, height: 16, + }], + }, }).toBuffer(); }, }, @@ -167,14 +170,17 @@ label: "Scrolltext (left)", make() { return new MonoDisplayFile({ - elements_always: [{ - type: ElementType.HScrollText, - text: "MONO DISPLAY — scrolling ticker — 🚀 ", - xOffset: 0, yOffset: 32, - width: W, height: 16, - scrollSpeed: 50, // (50+1)/16 ≈ 3.2 px/tick at 25fps ≈ 80px/s - flags: { endless: true, invertDirection: false }, - }], + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.HScrollText, + text: "MONO DISPLAY — scrolling ticker — 🚀 ", + xOffset: 0, yOffset: 32, + width: W, height: 16, + scrollSpeed: 50, + flags: { endless: true, invertDirection: false }, + }], + }, }).toBuffer(); }, }, From c8a2b03a547c77afc0d531d3fd3bb6193222ee09 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 19/20] feat; add font handling --- ts/src/font.ts | 216 +++++++++++++++++++++++++++++++++++++++++++++ ts/src/renderer.ts | 65 +++++++------- 2 files changed, 250 insertions(+), 31 deletions(-) create mode 100644 ts/src/font.ts diff --git a/ts/src/font.ts b/ts/src/font.ts new file mode 100644 index 0000000..d55b4b2 --- /dev/null +++ b/ts/src/font.ts @@ -0,0 +1,216 @@ +// ============================================================================= +// font.ts — built-in 5×7 bitmap font + text rasteriser +// +// Font encoding: column-major, 5 bytes per glyph (one byte per column). +// Each byte: bit 0 = topmost pixel, bit 6 = bottom pixel (7 rows used). +// Covers ASCII 0x20 (space) … 0x7E (~). Unknown codepoints → replacement glyph. +// +// Custom fonts (U8G2 binary format) accepted but glyph lookup not yet +// implemented — falls back to built-in automatically. +// ============================================================================= + +export const GLYPH_W = 5; // pixel columns per glyph +export const GLYPH_H = 7; // pixel rows per glyph +export const GLYPH_ADVANCE = 6; // pixels advanced per character (glyph + 1px gap) + +// --------------------------------------------------------------------------- +// Built-in 5×7 font data — classic Adafruit GFX / GNU unifont subset +// --------------------------------------------------------------------------- + +// prettier-ignore +const FONT_5x7 = new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' ' + 0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!' + 0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"' + 0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#' + 0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$' + 0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%' + 0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&' + 0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 "'" + 0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '(' + 0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')' + 0x14, 0x08, 0x3E, 0x08, 0x14, // 0x2A '*' + 0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+' + 0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ',' + 0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-' + 0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.' + 0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/' + 0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0' + 0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1' + 0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2' + 0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3' + 0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4' + 0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5' + 0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6' + 0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7' + 0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8' + 0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9' + 0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':' + 0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';' + 0x08, 0x14, 0x22, 0x41, 0x00, // 0x3C '<' + 0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '=' + 0x00, 0x41, 0x22, 0x14, 0x08, // 0x3E '>' + 0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?' + 0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@' + 0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A' + 0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B' + 0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C' + 0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D' + 0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E' + 0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F' + 0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G' + 0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H' + 0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I' + 0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J' + 0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K' + 0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L' + 0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M' + 0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N' + 0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O' + 0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P' + 0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q' + 0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R' + 0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S' + 0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T' + 0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U' + 0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V' + 0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W' + 0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X' + 0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y' + 0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z' + 0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '[' + 0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\' + 0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']' + 0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^' + 0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_' + 0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`' + 0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a' + 0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b' + 0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c' + 0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd' + 0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e' + 0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f' + 0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g' + 0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h' + 0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i' + 0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j' + 0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k' + 0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l' + 0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm' + 0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n' + 0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o' + 0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p' + 0x08, 0x14, 0x14, 0x18, 0x7C, // 0x71 'q' + 0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r' + 0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's' + 0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't' + 0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u' + 0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v' + 0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w' + 0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x' + 0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y' + 0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z' + 0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{' + 0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|' + 0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}' + 0x10, 0x08, 0x08, 0x10, 0x08, // 0x7E '~' +]); + +// Replacement glyph for codepoints outside built-in coverage: filled 5×7 box +// prettier-ignore +const REPLACEMENT_GLYPH = new Uint8Array([0x7F, 0x41, 0x41, 0x41, 0x7F]); + +/** + * Return the 5 column-bytes for a codepoint from the built-in font. + * Returns REPLACEMENT_GLYPH for anything outside ASCII 0x20–0x7E. + */ +export function builtinGlyph(cp: number): Uint8Array { + if (cp >= 0x20 && cp <= 0x7E) { + const off = (cp - 0x20) * GLYPH_W; + return FONT_5x7.subarray(off, off + GLYPH_W); + } + return REPLACEMENT_GLYPH; +} + +// --------------------------------------------------------------------------- +// Custom font (U8G2 binary format) +// --------------------------------------------------------------------------- + +/** + * Look up a glyph in a U8G2 font blob. + * Returns null if the codepoint is not found or the format is unsupported, + * in which case the caller should fall back to builtinGlyph(). + * + * U8G2 font parsing is not yet fully implemented — this is a hook for future + * integration with the u8g2-fonts Rust crate output. + */ +export function u8g2Glyph( + _cp: number, + _fontData: Uint8Array, +): { cols: Uint8Array; w: number; h: number; xOff: number; yOff: number } | null { + // TODO: implement U8G2 binary glyph lookup + return null; +} + +// --------------------------------------------------------------------------- +// Text → pixel-image rasteriser +// --------------------------------------------------------------------------- + +/** + * Rasterise `text` into a 1bpp pixel image. + * Iterates over actual Unicode codepoints (handles surrogate pairs / emoji). + * + * - `cellHeight`: height of the destination cell in pixels; glyphs are + * centred vertically within it. + * - `customFont`: optional raw U8G2 font data; if provided and the glyph is + * found, it is used instead of the built-in font. + * + * Returns a pixel image sized (textWidth × cellHeight) where each byte is 0 + * (off) or 1 (on). + */ +export function rasterizeText( + text: string, + cellHeight: number, + customFont?: Uint8Array, +): { pixels: Uint8Array; width: number; height: number } { + // Collect glyph column data for each codepoint + const glyphs: Array<{ cols: Uint8Array; w: number; h: number; yOff: number }> = []; + + for (const char of text) { // ← real codepoint iteration + const cp = char.codePointAt(0) ?? 0x3F; // '?' fallback + + // Try custom font first, fall back to built-in + const custom = customFont ? u8g2Glyph(cp, customFont) : null; + if (custom) { + glyphs.push({ cols: custom.cols, w: custom.w, h: custom.h, yOff: custom.yOff }); + } else { + glyphs.push({ cols: builtinGlyph(cp), w: GLYPH_W, h: GLYPH_H, yOff: 0 }); + } + } + + // Total pixel width = sum of advances (GLYPH_ADVANCE per glyph, no trailing gap) + const totalW = Math.max(1, glyphs.length * GLYPH_ADVANCE - 1); // remove last gap + const pixels = new Uint8Array(totalW * cellHeight); + + // Vertical centre of glyph within cell + const yBase = Math.max(0, Math.floor((cellHeight - GLYPH_H) / 2)); + + let x = 0; + for (const g of glyphs) { + const top = yBase + g.yOff; + for (let col = 0; col < g.w; col++) { + const colByte = g.cols[col] ?? 0; + for (let row = 0; row < g.h; row++) { + if ((colByte >> row) & 1) { + const py = top + row; + if (py >= 0 && py < cellHeight) { + pixels[py * totalW + x + col] = 1; + } + } + } + } + x += GLYPH_ADVANCE; + } + + return { pixels, width: totalW, height: cellHeight }; +} diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts index 5e6d7b5..b1c5a82 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -15,6 +15,7 @@ import { type MonoFormatVScroll } from "./types.js"; import { type MonoDisplayDriverOptions } from "./driver"; +import { rasterizeText } from "./font"; /** * Renders a MonoFormatFile onto an HTMLCanvasElement. * Uses setInterval at 1000/fps ms per tick. All element state (animation @@ -34,6 +35,8 @@ export class MonoDisplayRenderer { private animState: Map = new Map(); private hScrollPos: Map = new Map(); // pixel offset (may be fractional) private vScrollPos: Map = new Map(); + private customFonts: Uint8Array[] = []; + private textCache: Map = new Map(); constructor(canvas: HTMLCanvasElement, opts: Required) { const ctx = canvas.getContext("2d"); @@ -51,6 +54,7 @@ export class MonoDisplayRenderer { this.animState.clear(); this.hScrollPos.clear(); this.vScrollPos.clear(); + this.textCache.clear(); } render(file: MonoFormatFile): void { @@ -60,6 +64,14 @@ export class MonoDisplayRenderer { this.ctx.canvas.width = dw * s; this.ctx.canvas.height = dh * s; + // Extract custom fonts from file sections (0x8000-indexed) + this.customFonts = []; + for (const section of file.sections) { + if (section.sectionType === SectionType.CustomFont) { + this.customFonts.push(section.fontData); + } + } + const tick = () => { this.#renderFile(file); this.tickCount++; @@ -209,43 +221,34 @@ export class MonoDisplayRenderer { } } + #getTextImage(text: string, height: number, fontIndex: number): MonoFormatPixelImage { + const key = `${text}|${fontIndex}|${height}`; + const cached = this.textCache.get(key); + if (cached) return cached; + const customFont = fontIndex >= 0x8000 ? this.customFonts[fontIndex - 0x8000] : undefined; + const img = rasterizeText(text, height, customFont); + this.textCache.set(key, img); + return img; + } + #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(); + const img = this.#getTextImage(el.text, el.height, el.fontIndex); + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); } #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 img = this.#getTextImage(el.text, el.height, el.fontIndex); + let pos = this.hScrollPos.get(id) ?? 0; + + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, pos, 0); - const speed = (el.scrollSpeed + 1) / 16 * s; - pos += el.flags.invertDirection ? speed : -speed; + const speed = (el.scrollSpeed + 1) / 16; + pos += el.flags.invertDirection ? -speed : speed; if (el.flags.endless) { - if (pos < -textPx) pos = el.width * s; - if (pos > el.width * s) pos = -textPx; + if (pos >= img.width) pos -= img.width; + if (pos < 0) pos += img.width; + } else { + pos = Math.max(0, Math.min(pos, img.width - el.width)); } this.hScrollPos.set(id, pos); } From 6fb4b390adedddff592a45348ef567ad9b659a06 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH 20/20] feat: current Time support --- ts/index.html | 16 ++++++++++++++++ ts/src/driver.ts | 7 +++++++ ts/src/file.ts | 25 ++++++++++++++++++++++++- ts/src/parser.ts | 25 ++++++++++++++++++++++++- ts/src/renderer.ts | 23 ++++++++++++++++++++++- ts/src/types.ts | 6 ------ 6 files changed, 93 insertions(+), 9 deletions(-) diff --git a/ts/index.html b/ts/index.html index a2b3345..30896d1 100644 --- a/ts/index.html +++ b/ts/index.html @@ -184,6 +184,22 @@ }).toBuffer(); }, }, + { + label: "Time", + make() { + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.CurrentTime, + xOffset: 0, yOffset: 32, + width: W, height: 16, + utcOffsetMinutes: 120, + }], + }, + }).toBuffer(); + }, + }, ]; const controls = document.getElementById("controls"); diff --git a/ts/src/driver.ts b/ts/src/driver.ts index ec99d6d..4f424e2 100644 --- a/ts/src/driver.ts +++ b/ts/src/driver.ts @@ -30,6 +30,12 @@ export interface MonoDisplayDriverOptions { loop?: boolean; /** Error handler. Default: throw. */ onError?: (err: Error) => void; + /** + * Clock source for CurrentTime elements. Called once per render tick. + * Default: `() => new Date()` (system time). + * Override to freeze or mock the clock, e.g. `() => new Date('2024-01-01T12:00:00Z')`. + */ + now?: () => Date; } @@ -66,6 +72,7 @@ export class MonoDisplayDriver { fps: options.fps ?? 25, loop: options.loop ?? true, onError: options.onError ?? ((e: Error) => { throw e; }), + now: options.now ?? (() => new Date()), }; this.parser = new MonoDisplayParser(); } diff --git a/ts/src/file.ts b/ts/src/file.ts index 1d240e0..8ea4431 100644 --- a/ts/src/file.ts +++ b/ts/src/file.ts @@ -4,7 +4,7 @@ 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"; +import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type CurrentTimeElement, 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. @@ -85,6 +85,7 @@ export class MonoDisplayFile { case ElementType.Line: return this.#encodeLine(el); case ElementType.ClippedText: return this.#encodeClippedText(el); case ElementType.HScrollText: return this.#encodeHScrollText(el); + case ElementType.CurrentTime: return this.#encodeCurrentTime(el); default: return new Uint8Array(); } } @@ -230,6 +231,28 @@ export class MonoDisplayFile { return out; } + #encodeCurrentTime(el: CurrentTimeElement): Uint8Array { + // Fixed 16 bytes, already 32-bit aligned + const out = new Uint8Array(16); + const v = new DataView(out.buffer); + const f = el.flags; + const flagsByte = + (f?.clock12h ? 0x01 : 0) | + (f?.showHours ?? true ? 0x02 : 0) | + (f?.showMinutes ?? true ? 0x04 : 0) | + (f?.showSeconds ? 0x08 : 0); + v.setUint16( 0, ElementType.CurrentTime, 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.setInt16( 12, el.utcOffsetMinutes ?? 0, true); + v.setUint8( 14, flagsByte); + v.setUint8( 15, 0); // reserved + return out; + } + // --- utilities --- #concat(...parts: Uint8Array[]): Uint8Array { diff --git a/ts/src/parser.ts b/ts/src/parser.ts index 536609a..4b8106f 100644 --- a/ts/src/parser.ts +++ b/ts/src/parser.ts @@ -5,7 +5,7 @@ import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper"; import { type MonoFormatFile } from "./types"; -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"; +import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCurrentTime, 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; @@ -116,6 +116,7 @@ export class MonoDisplayParser { case ElementType.Line: return this.#parseLine(r); case ElementType.ClippedText: return this.#parseClippedText(r, elementStart); case ElementType.HScrollText: return this.#parseHScrollText(r, elementStart); + case ElementType.CurrentTime: return this.#parseCurrentTime(r); default: // Unknown element type - cannot safely skip without knowing size; stop parsing section elements return null; @@ -238,6 +239,28 @@ export class MonoDisplayParser { return { type: ElementType.ClippedText, xOffset, yOffset, width, height, fontIndex, text }; } + #parseCurrentTime(r: BinaryReader): MonoFormatCurrentTime { + // Layout after type field (already consumed): 14 bytes, total element = 16 bytes (aligned) + const xOffset = r.readUint16(); + const yOffset = r.readUint16(); + const width = r.readUint16(); + const height = r.readUint16(); + const fontIndex = r.readUint16(); + const utcOffsetMinutes = r.readInt16(); + const flagsByte = r.readByte(); + r.readByte(); // reserved + return { + type: ElementType.CurrentTime, + xOffset, yOffset, width, height, fontIndex, utcOffsetMinutes, + flags: { + clock12h: !!(flagsByte & 0x01), + showHours: !!(flagsByte & 0x02), + showMinutes: !!(flagsByte & 0x04), + showSeconds: !!(flagsByte & 0x08), + }, + }; + } + #parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText { const xOffset = r.readUint16(); const yOffset = r.readUint16(); diff --git a/ts/src/renderer.ts b/ts/src/renderer.ts index b1c5a82..c2a1e13 100644 --- a/ts/src/renderer.ts +++ b/ts/src/renderer.ts @@ -4,6 +4,7 @@ import { SectionType, type MonoFormatAnimation, type MonoFormatClippedText, + type MonoFormatCurrentTime, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, @@ -82,7 +83,7 @@ export class MonoDisplayRenderer { } #renderFile(file: MonoFormatFile): void { - const now = BigInt(Math.floor(Date.now() / 1000)); + const now = BigInt(Math.floor(this.opts.now().getTime() / 1000)); let cleared = false; let elementId = 0; @@ -124,6 +125,7 @@ export class MonoDisplayRenderer { case ElementType.Line: this.#renderLine(el); break; case ElementType.ClippedText: this.#renderClippedText(el); break; case ElementType.HScrollText: this.#renderHScrollText(el, id); break; + case ElementType.CurrentTime: this.#renderCurrentTime(el); break; } } @@ -252,4 +254,23 @@ export class MonoDisplayRenderer { } this.hScrollPos.set(id, pos); } + + #renderCurrentTime(el: MonoFormatCurrentTime): void { + const d = new Date(this.opts.now().getTime() + el.utcOffsetMinutes * 60_000); + const { clock12h, showHours, showMinutes, showSeconds } = el.flags; + + const parts: string[] = []; + if (showHours) { + const h = d.getUTCHours(); + parts.push(String(clock12h ? (h % 12 || 12) : h).padStart(2, '0')); + } + if (showMinutes) parts.push(String(d.getUTCMinutes()).padStart(2, '0')); + if (showSeconds) parts.push(String(d.getUTCSeconds()).padStart(2, '0')); + + const text = parts.join(':'); + if (!text) return; + + const img = this.#getTextImage(text, el.height, el.fontIndex); + this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); + } } diff --git a/ts/src/types.ts b/ts/src/types.ts index 168bc48..ba6a322 100644 --- a/ts/src/types.ts +++ b/ts/src/types.ts @@ -31,12 +31,6 @@ export enum ElementType { Line = 5, ClippedText = 16, HScrollText = 17, - /** - * Current time display. - * NOTE: the spec diagram labels this element "Type: 16", which collides with - * ClippedText. This is assumed to be a spec typo; 18 is used here as the - * probable intended value until the spec is corrected. - */ CurrentTime = 18, }