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
+
+
+
+
+ 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