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