parent
db833b48c8
commit
68ac114872
@ -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 <canvas>`); |
|
||||||
} |
|
||||||
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(); |
|
||||||
@ -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<MonoDisplayDriverOptions>; |
||||||
|
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 <canvas>`); |
||||||
|
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<ArrayBuffer>): Promise<void> { |
||||||
|
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(); } |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -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<ArrayBufferLike> { |
||||||
|
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<ArrayBuffer> { |
||||||
|
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(); |
||||||
|
} |
||||||
@ -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` |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -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<MonoDisplayDriverOptions>; |
||||||
|
|
||||||
|
private tickTimer: number | null = null; |
||||||
|
private tickCount: number = 0; |
||||||
|
// Per-element state: keyed by stable element index
|
||||||
|
private animState: Map<number, { frame: number; counter: number }> = new Map(); |
||||||
|
private hScrollPos: Map<number, number> = new Map(); // pixel offset (may be fractional)
|
||||||
|
private vScrollPos: Map<number, number> = new Map(); |
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) { |
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue