commit
5af50fcd29
@ -0,0 +1,9 @@ |
||||
{ |
||||
"permissions": { |
||||
"allow": [ |
||||
"Bash(bun test:*)", |
||||
"Bash(bun build:*)", |
||||
"Bash(bun run:*)" |
||||
] |
||||
} |
||||
} |
||||
@ -0,0 +1,37 @@ |
||||
# dependencies (bun install) |
||||
node_modules |
||||
|
||||
# output |
||||
out |
||||
dist |
||||
public |
||||
*.tgz |
||||
|
||||
# code coverage |
||||
coverage |
||||
*.lcov |
||||
|
||||
# logs |
||||
logs |
||||
_.log |
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json |
||||
|
||||
# dotenv environment variable files |
||||
.env |
||||
.env.development.local |
||||
.env.test.local |
||||
.env.production.local |
||||
.env.local |
||||
|
||||
# caches |
||||
.eslintcache |
||||
.cache |
||||
*.tsbuildinfo |
||||
|
||||
# IntelliJ based IDEs |
||||
.idea |
||||
|
||||
# Finder (MacOS) folder config |
||||
.DS_Store |
||||
.claude/ |
||||
.claude/settings.local.json |
||||
@ -0,0 +1,106 @@ |
||||
|
||||
Default to using Bun instead of Node.js. |
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>` |
||||
- Use `bun test` instead of `jest` or `vitest` |
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` |
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` |
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` |
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>` |
||||
- Bun automatically loads .env, so don't use dotenv. |
||||
|
||||
## APIs |
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. |
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`. |
||||
- `Bun.redis` for Redis. Don't use `ioredis`. |
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. |
||||
- `WebSocket` is built-in. Don't use `ws`. |
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile |
||||
- Bun.$`ls` instead of execa. |
||||
|
||||
## Testing |
||||
|
||||
Use `bun test` to run tests. |
||||
|
||||
```ts#index.test.ts |
||||
import { test, expect } from "bun:test"; |
||||
|
||||
test("hello world", () => { |
||||
expect(1).toBe(1); |
||||
}); |
||||
``` |
||||
|
||||
## Frontend |
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. |
||||
|
||||
Server: |
||||
|
||||
```ts#index.ts |
||||
import index from "./index.html" |
||||
|
||||
Bun.serve({ |
||||
routes: { |
||||
"/": index, |
||||
"/api/users/:id": { |
||||
GET: (req) => { |
||||
return new Response(JSON.stringify({ id: req.params.id })); |
||||
}, |
||||
}, |
||||
}, |
||||
// optional websocket support |
||||
websocket: { |
||||
open: (ws) => { |
||||
ws.send("Hello, world!"); |
||||
}, |
||||
message: (ws, message) => { |
||||
ws.send(message); |
||||
}, |
||||
close: (ws) => { |
||||
// handle close |
||||
} |
||||
}, |
||||
development: { |
||||
hmr: true, |
||||
console: true, |
||||
} |
||||
}) |
||||
``` |
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. |
||||
|
||||
```html#index.html |
||||
<html> |
||||
<body> |
||||
<h1>Hello, world!</h1> |
||||
<script type="module" src="./frontend.tsx"></script> |
||||
</body> |
||||
</html> |
||||
``` |
||||
|
||||
With the following `frontend.tsx`: |
||||
|
||||
```tsx#frontend.tsx |
||||
import React from "react"; |
||||
import { createRoot } from "react-dom/client"; |
||||
|
||||
// import .css files directly and it works |
||||
import './index.css'; |
||||
|
||||
const root = createRoot(document.body); |
||||
|
||||
export default function Frontend() { |
||||
return <h1>Hello, world!</h1>; |
||||
} |
||||
|
||||
root.render(<Frontend />); |
||||
``` |
||||
|
||||
Then, run index.ts |
||||
|
||||
```sh |
||||
bun --hot ./index.ts |
||||
``` |
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. |
||||
@ -0,0 +1,15 @@ |
||||
# libmonoformat-ts |
||||
|
||||
To install dependencies: |
||||
|
||||
```bash |
||||
bun install |
||||
``` |
||||
|
||||
To run: |
||||
|
||||
```bash |
||||
bun run index.ts |
||||
``` |
||||
|
||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. |
||||
@ -0,0 +1,26 @@ |
||||
{ |
||||
"lockfileVersion": 1, |
||||
"configVersion": 1, |
||||
"workspaces": { |
||||
"": { |
||||
"name": "libmonoformat", |
||||
"devDependencies": { |
||||
"@types/bun": "latest", |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5", |
||||
}, |
||||
}, |
||||
}, |
||||
"packages": { |
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], |
||||
|
||||
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], |
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], |
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], |
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], |
||||
} |
||||
} |
||||
@ -0,0 +1,225 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>MonoDisplay Test</title> |
||||
<style> |
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
||||
|
||||
body { |
||||
background: #111; |
||||
color: #ccc; |
||||
font-family: monospace; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: center; |
||||
min-height: 100vh; |
||||
gap: 20px; |
||||
padding: 20px; |
||||
} |
||||
|
||||
h1 { |
||||
font-size: 14px; |
||||
letter-spacing: 0.15em; |
||||
color: #555; |
||||
text-transform: uppercase; |
||||
} |
||||
|
||||
/* |
||||
* Display container: holds the canvas at a fixed 2:1 aspect ratio (160:80). |
||||
* Canvas internal resolution stays at 160×80 logical pixels. |
||||
* CSS stretches it to fill this box — image-rendering: pixelated keeps crisp edges. |
||||
* To resize the on-screen display: change max-width here. Driver is unaffected. |
||||
*/ |
||||
#display-box { |
||||
width: 100%; |
||||
max-width: 800px; |
||||
aspect-ratio: 2 / 1; |
||||
background: #000; |
||||
border: 2px solid #333; |
||||
border-radius: 4px; |
||||
overflow: hidden; |
||||
position: relative; |
||||
} |
||||
|
||||
#canvas_root { |
||||
width: 100%; |
||||
height: 100%; |
||||
display: block; |
||||
image-rendering: pixelated; |
||||
image-rendering: crisp-edges; /* Firefox */ |
||||
} |
||||
|
||||
#resolution { |
||||
position: absolute; |
||||
bottom: 6px; |
||||
right: 8px; |
||||
font-size: 10px; |
||||
color: #333; |
||||
pointer-events: none; |
||||
} |
||||
|
||||
#controls { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 8px; |
||||
justify-content: center; |
||||
} |
||||
|
||||
#controls button { |
||||
background: #1a1a1a; |
||||
color: #888; |
||||
border: 1px solid #333; |
||||
border-radius: 3px; |
||||
padding: 6px 14px; |
||||
font-family: monospace; |
||||
font-size: 12px; |
||||
cursor: pointer; |
||||
transition: background 0.1s, color 0.1s; |
||||
} |
||||
|
||||
#controls button:hover { background: #222; color: #bbb; } |
||||
#controls button.active { background: #1e3a1e; color: #33ff66; border-color: #33ff66; } |
||||
|
||||
#status { font-size: 11px; color: #444; min-height: 1em; } |
||||
</style> |
||||
</head> |
||||
<body> |
||||
|
||||
<h1>MonoDisplay — 160 × 80</h1> |
||||
|
||||
<div id="display-box"> |
||||
<!-- Internal res = displayWidth × displayHeight (160×80). CSS does the upscaling. --> |
||||
<canvas id="canvas_root" width="160" height="80"></canvas> |
||||
</div> |
||||
|
||||
<div id="controls"></div> |
||||
<div id="status">select a demo</div> |
||||
|
||||
<!-- |
||||
Build the library first: bun run build |
||||
Produces public/mono-display.js — exposes all types on window.MonoDisplay. |
||||
Then serve index.html from any static file server (or file:// directly). |
||||
--> |
||||
<script src="./public/mono-display.js"></script> |
||||
|
||||
<script> |
||||
(function() { |
||||
const { MonoDisplayDriver, MonoDisplayFile, ElementType } = window.MonoDisplay; |
||||
|
||||
const driver = new MonoDisplayDriver("canvas_root", { |
||||
onColor: "#EC0", |
||||
offColor: "#000", |
||||
fps: 25, |
||||
}); |
||||
|
||||
const W = 160, H = 80; |
||||
|
||||
const demos = [ |
||||
{ |
||||
label: "Checkerboard (static)", |
||||
make() { |
||||
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 new MonoDisplayFile({ |
||||
width: W, height: H, |
||||
elements_always: [{ type: ElementType.Image2D, pixels, width: W, height: H }], |
||||
}).toBuffer(); |
||||
}, |
||||
}, |
||||
{ |
||||
label: "Blink (animation)", |
||||
make() { |
||||
return new MonoDisplayFile({ |
||||
elements_always: { |
||||
flags: { drawFront: true, clearBuffer: true }, |
||||
elements: [{ |
||||
type: ElementType.Animation, |
||||
width: W, height: H, |
||||
updateInterval: 12, // every 13 ticks ≈ 500ms per frame at 25fps |
||||
frames: [ |
||||
{ pixels: new Uint8Array(W * H).fill(1) }, |
||||
{ pixels: new Uint8Array(W * H).fill(0) }, |
||||
], |
||||
}], |
||||
}, |
||||
}).toBuffer(); |
||||
}, |
||||
}, |
||||
{ |
||||
label: "Text (centered)", |
||||
make() { |
||||
return new MonoDisplayFile({ |
||||
elements_always: { |
||||
flags: { drawFront: true, clearBuffer: true }, |
||||
elements: [{ |
||||
type: ElementType.ClippedText, |
||||
text: "Hello, World!", |
||||
xOffset: 0, yOffset: 32, |
||||
width: W, height: 16, |
||||
}], |
||||
}, |
||||
}).toBuffer(); |
||||
}, |
||||
}, |
||||
{ |
||||
label: "Scrolltext (left)", |
||||
make() { |
||||
return new MonoDisplayFile({ |
||||
elements_always: { |
||||
flags: { drawFront: true, clearBuffer: true }, |
||||
elements: [{ |
||||
type: ElementType.HScrollText, |
||||
text: "MONO DISPLAY — scrolling ticker — 🚀 ", |
||||
xOffset: 0, yOffset: 32, |
||||
width: W, height: 16, |
||||
scrollSpeed: 50, |
||||
flags: { endless: true, invertDirection: false }, |
||||
}], |
||||
}, |
||||
}).toBuffer(); |
||||
}, |
||||
}, |
||||
{ |
||||
label: "Time", |
||||
make() { |
||||
return new MonoDisplayFile({ |
||||
elements_always: { |
||||
flags: { drawFront: true, clearBuffer: true }, |
||||
elements: [{ |
||||
type: ElementType.CurrentTime, |
||||
xOffset: 0, yOffset: 32, |
||||
width: W, height: 16, |
||||
utcOffsetMinutes: 120, |
||||
}], |
||||
}, |
||||
}).toBuffer(); |
||||
}, |
||||
}, |
||||
]; |
||||
|
||||
const controls = document.getElementById("controls"); |
||||
let activeBtn = null; |
||||
|
||||
for (const demo of demos) { |
||||
const btn = document.createElement("button"); |
||||
btn.textContent = demo.label; |
||||
btn.onclick = () => { |
||||
if (activeBtn) activeBtn.classList.remove("active"); |
||||
btn.classList.add("active"); |
||||
activeBtn = btn; |
||||
driver.load(() => Promise.resolve(demo.make())); |
||||
}; |
||||
controls.appendChild(btn); |
||||
} |
||||
|
||||
controls.firstElementChild.click(); |
||||
})(); |
||||
</script> |
||||
|
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,15 @@ |
||||
{ |
||||
"name": "libmonoformat", |
||||
"module": "src/index.ts", |
||||
"type": "module", |
||||
"scripts": { |
||||
"build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", |
||||
"test": "bun test" |
||||
}, |
||||
"devDependencies": { |
||||
"@types/bun": "latest" |
||||
}, |
||||
"peerDependencies": { |
||||
"typescript": "^5" |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@ |
||||
// browser.ts — IIFE-style browser entry point
|
||||
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
|
||||
// Attaches everything to window.MonoDisplay so inline <script> tags can use it
|
||||
// without ES module syntax.
|
||||
|
||||
import * as MonoDisplay from "./index"; |
||||
|
||||
// Expose on globalThis (= window in browser, globalThis elsewhere)
|
||||
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; |
||||
@ -0,0 +1,95 @@ |
||||
// ---------------------------------------------------------------------------
|
||||
// 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; |
||||
/** |
||||
* Clock source for CurrentTime elements. Called once per render tick. |
||||
* Default: `() => new Date()` (system time). |
||||
* Override to freeze or mock the clock, e.g. `() => new Date('2024-01-01T12:00:00Z')`. |
||||
*/ |
||||
now?: () => Date; |
||||
} |
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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; }), |
||||
now: options.now ?? (() => new Date()), |
||||
}; |
||||
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,283 @@ |
||||
// ---------------------------------------------------------------------------
|
||||
// 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 CurrentTimeElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types"; |
||||
|
||||
/** |
||||
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. |
||||
* |
||||
* 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); |
||||
case ElementType.CurrentTime: return this.#encodeCurrentTime(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; |
||||
} |
||||
|
||||
#encodeCurrentTime(el: CurrentTimeElement): Uint8Array { |
||||
// Fixed 16 bytes, already 32-bit aligned
|
||||
const out = new Uint8Array(16); |
||||
const v = new DataView(out.buffer); |
||||
const f = el.flags; |
||||
const flagsByte = |
||||
(f?.clock12h ? 0x01 : 0) | |
||||
(f?.showHours ?? true ? 0x02 : 0) | |
||||
(f?.showMinutes ?? true ? 0x04 : 0) | |
||||
(f?.showSeconds ? 0x08 : 0); |
||||
v.setUint16( 0, ElementType.CurrentTime, true); |
||||
v.setUint16( 2, el.xOffset ?? 0, true); |
||||
v.setUint16( 4, el.yOffset ?? 0, true); |
||||
v.setUint16( 6, el.width, true); |
||||
v.setUint16( 8, el.height, true); |
||||
v.setUint16(10, el.fontIndex ?? 0, true); |
||||
v.setInt16( 12, el.utcOffsetMinutes ?? 0, true); |
||||
v.setUint8( 14, flagsByte); |
||||
v.setUint8( 15, 0); // reserved
|
||||
return out; |
||||
} |
||||
|
||||
// --- utilities ---
|
||||
|
||||
#concat(...parts: Uint8Array[]): Uint8Array { |
||||
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): Uint8Array { |
||||
return new MonoDisplayFile({ elements_always: [el] }).toBuffer(); |
||||
} |
||||
@ -0,0 +1,216 @@ |
||||
// =============================================================================
|
||||
// font.ts — built-in 5×7 bitmap font + text rasteriser
|
||||
//
|
||||
// Font encoding: column-major, 5 bytes per glyph (one byte per column).
|
||||
// Each byte: bit 0 = topmost pixel, bit 6 = bottom pixel (7 rows used).
|
||||
// Covers ASCII 0x20 (space) … 0x7E (~). Unknown codepoints → replacement glyph.
|
||||
//
|
||||
// Custom fonts (U8G2 binary format) accepted but glyph lookup not yet
|
||||
// implemented — falls back to built-in automatically.
|
||||
// =============================================================================
|
||||
|
||||
export const GLYPH_W = 5; // pixel columns per glyph
|
||||
export const GLYPH_H = 7; // pixel rows per glyph
|
||||
export const GLYPH_ADVANCE = 6; // pixels advanced per character (glyph + 1px gap)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in 5×7 font data — classic Adafruit GFX / GNU unifont subset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// prettier-ignore
|
||||
const FONT_5x7 = new Uint8Array([ |
||||
0x00, 0x00, 0x00, 0x00, 0x00, // 0x20 ' '
|
||||
0x00, 0x00, 0x5F, 0x00, 0x00, // 0x21 '!'
|
||||
0x00, 0x07, 0x00, 0x07, 0x00, // 0x22 '"'
|
||||
0x14, 0x7F, 0x14, 0x7F, 0x14, // 0x23 '#'
|
||||
0x24, 0x2A, 0x7F, 0x2A, 0x12, // 0x24 '$'
|
||||
0x23, 0x13, 0x08, 0x64, 0x62, // 0x25 '%'
|
||||
0x36, 0x49, 0x55, 0x22, 0x50, // 0x26 '&'
|
||||
0x00, 0x05, 0x03, 0x00, 0x00, // 0x27 "'"
|
||||
0x00, 0x1C, 0x22, 0x41, 0x00, // 0x28 '('
|
||||
0x00, 0x41, 0x22, 0x1C, 0x00, // 0x29 ')'
|
||||
0x14, 0x08, 0x3E, 0x08, 0x14, // 0x2A '*'
|
||||
0x08, 0x08, 0x3E, 0x08, 0x08, // 0x2B '+'
|
||||
0x00, 0x50, 0x30, 0x00, 0x00, // 0x2C ','
|
||||
0x08, 0x08, 0x08, 0x08, 0x08, // 0x2D '-'
|
||||
0x00, 0x60, 0x60, 0x00, 0x00, // 0x2E '.'
|
||||
0x20, 0x10, 0x08, 0x04, 0x02, // 0x2F '/'
|
||||
0x3E, 0x51, 0x49, 0x45, 0x3E, // 0x30 '0'
|
||||
0x00, 0x42, 0x7F, 0x40, 0x00, // 0x31 '1'
|
||||
0x42, 0x61, 0x51, 0x49, 0x46, // 0x32 '2'
|
||||
0x21, 0x41, 0x45, 0x4B, 0x31, // 0x33 '3'
|
||||
0x18, 0x14, 0x12, 0x7F, 0x10, // 0x34 '4'
|
||||
0x27, 0x45, 0x45, 0x45, 0x39, // 0x35 '5'
|
||||
0x3C, 0x4A, 0x49, 0x49, 0x30, // 0x36 '6'
|
||||
0x01, 0x71, 0x09, 0x05, 0x03, // 0x37 '7'
|
||||
0x36, 0x49, 0x49, 0x49, 0x36, // 0x38 '8'
|
||||
0x06, 0x49, 0x49, 0x29, 0x1E, // 0x39 '9'
|
||||
0x00, 0x36, 0x36, 0x00, 0x00, // 0x3A ':'
|
||||
0x00, 0x56, 0x36, 0x00, 0x00, // 0x3B ';'
|
||||
0x08, 0x14, 0x22, 0x41, 0x00, // 0x3C '<'
|
||||
0x14, 0x14, 0x14, 0x14, 0x14, // 0x3D '='
|
||||
0x00, 0x41, 0x22, 0x14, 0x08, // 0x3E '>'
|
||||
0x02, 0x01, 0x51, 0x09, 0x06, // 0x3F '?'
|
||||
0x32, 0x49, 0x79, 0x41, 0x3E, // 0x40 '@'
|
||||
0x7E, 0x11, 0x11, 0x11, 0x7E, // 0x41 'A'
|
||||
0x7F, 0x49, 0x49, 0x49, 0x36, // 0x42 'B'
|
||||
0x3E, 0x41, 0x41, 0x41, 0x22, // 0x43 'C'
|
||||
0x7F, 0x41, 0x41, 0x22, 0x1C, // 0x44 'D'
|
||||
0x7F, 0x49, 0x49, 0x49, 0x41, // 0x45 'E'
|
||||
0x7F, 0x09, 0x09, 0x09, 0x01, // 0x46 'F'
|
||||
0x3E, 0x41, 0x49, 0x49, 0x7A, // 0x47 'G'
|
||||
0x7F, 0x08, 0x08, 0x08, 0x7F, // 0x48 'H'
|
||||
0x00, 0x41, 0x7F, 0x41, 0x00, // 0x49 'I'
|
||||
0x20, 0x40, 0x41, 0x3F, 0x01, // 0x4A 'J'
|
||||
0x7F, 0x08, 0x14, 0x22, 0x41, // 0x4B 'K'
|
||||
0x7F, 0x40, 0x40, 0x40, 0x40, // 0x4C 'L'
|
||||
0x7F, 0x02, 0x0C, 0x02, 0x7F, // 0x4D 'M'
|
||||
0x7F, 0x04, 0x08, 0x10, 0x7F, // 0x4E 'N'
|
||||
0x3E, 0x41, 0x41, 0x41, 0x3E, // 0x4F 'O'
|
||||
0x7F, 0x09, 0x09, 0x09, 0x06, // 0x50 'P'
|
||||
0x3E, 0x41, 0x51, 0x21, 0x5E, // 0x51 'Q'
|
||||
0x7F, 0x09, 0x19, 0x29, 0x46, // 0x52 'R'
|
||||
0x46, 0x49, 0x49, 0x49, 0x31, // 0x53 'S'
|
||||
0x01, 0x01, 0x7F, 0x01, 0x01, // 0x54 'T'
|
||||
0x3F, 0x40, 0x40, 0x40, 0x3F, // 0x55 'U'
|
||||
0x1F, 0x20, 0x40, 0x20, 0x1F, // 0x56 'V'
|
||||
0x3F, 0x40, 0x38, 0x40, 0x3F, // 0x57 'W'
|
||||
0x63, 0x14, 0x08, 0x14, 0x63, // 0x58 'X'
|
||||
0x07, 0x08, 0x70, 0x08, 0x07, // 0x59 'Y'
|
||||
0x61, 0x51, 0x49, 0x45, 0x43, // 0x5A 'Z'
|
||||
0x00, 0x7F, 0x41, 0x41, 0x00, // 0x5B '['
|
||||
0x02, 0x04, 0x08, 0x10, 0x20, // 0x5C '\'
|
||||
0x00, 0x41, 0x41, 0x7F, 0x00, // 0x5D ']'
|
||||
0x04, 0x02, 0x01, 0x02, 0x04, // 0x5E '^'
|
||||
0x40, 0x40, 0x40, 0x40, 0x40, // 0x5F '_'
|
||||
0x00, 0x01, 0x02, 0x04, 0x00, // 0x60 '`'
|
||||
0x20, 0x54, 0x54, 0x54, 0x78, // 0x61 'a'
|
||||
0x7F, 0x48, 0x44, 0x44, 0x38, // 0x62 'b'
|
||||
0x38, 0x44, 0x44, 0x44, 0x20, // 0x63 'c'
|
||||
0x38, 0x44, 0x44, 0x48, 0x7F, // 0x64 'd'
|
||||
0x38, 0x54, 0x54, 0x54, 0x18, // 0x65 'e'
|
||||
0x08, 0x7E, 0x09, 0x01, 0x02, // 0x66 'f'
|
||||
0x0C, 0x52, 0x52, 0x52, 0x3E, // 0x67 'g'
|
||||
0x7F, 0x08, 0x04, 0x04, 0x78, // 0x68 'h'
|
||||
0x00, 0x44, 0x7D, 0x40, 0x00, // 0x69 'i'
|
||||
0x20, 0x40, 0x44, 0x3D, 0x00, // 0x6A 'j'
|
||||
0x7F, 0x10, 0x28, 0x44, 0x00, // 0x6B 'k'
|
||||
0x00, 0x41, 0x7F, 0x40, 0x00, // 0x6C 'l'
|
||||
0x7C, 0x04, 0x18, 0x04, 0x78, // 0x6D 'm'
|
||||
0x7C, 0x08, 0x04, 0x04, 0x78, // 0x6E 'n'
|
||||
0x38, 0x44, 0x44, 0x44, 0x38, // 0x6F 'o'
|
||||
0x7C, 0x14, 0x14, 0x14, 0x08, // 0x70 'p'
|
||||
0x08, 0x14, 0x14, 0x18, 0x7C, // 0x71 'q'
|
||||
0x7C, 0x08, 0x04, 0x04, 0x08, // 0x72 'r'
|
||||
0x48, 0x54, 0x54, 0x54, 0x20, // 0x73 's'
|
||||
0x04, 0x3F, 0x44, 0x40, 0x20, // 0x74 't'
|
||||
0x3C, 0x40, 0x40, 0x20, 0x7C, // 0x75 'u'
|
||||
0x1C, 0x20, 0x40, 0x20, 0x1C, // 0x76 'v'
|
||||
0x3C, 0x40, 0x30, 0x40, 0x3C, // 0x77 'w'
|
||||
0x44, 0x28, 0x10, 0x28, 0x44, // 0x78 'x'
|
||||
0x0C, 0x50, 0x50, 0x50, 0x3C, // 0x79 'y'
|
||||
0x44, 0x64, 0x54, 0x4C, 0x44, // 0x7A 'z'
|
||||
0x00, 0x08, 0x36, 0x41, 0x00, // 0x7B '{'
|
||||
0x00, 0x00, 0x7F, 0x00, 0x00, // 0x7C '|'
|
||||
0x00, 0x41, 0x36, 0x08, 0x00, // 0x7D '}'
|
||||
0x10, 0x08, 0x08, 0x10, 0x08, // 0x7E '~'
|
||||
]); |
||||
|
||||
// Replacement glyph for codepoints outside built-in coverage: filled 5×7 box
|
||||
// prettier-ignore
|
||||
const REPLACEMENT_GLYPH = new Uint8Array([0x7F, 0x41, 0x41, 0x41, 0x7F]); |
||||
|
||||
/** |
||||
* Return the 5 column-bytes for a codepoint from the built-in font. |
||||
* Returns REPLACEMENT_GLYPH for anything outside ASCII 0x20–0x7E. |
||||
*/ |
||||
export function builtinGlyph(cp: number): Uint8Array { |
||||
if (cp >= 0x20 && cp <= 0x7E) { |
||||
const off = (cp - 0x20) * GLYPH_W; |
||||
return FONT_5x7.subarray(off, off + GLYPH_W); |
||||
} |
||||
return REPLACEMENT_GLYPH; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom font (U8G2 binary format)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Look up a glyph in a U8G2 font blob. |
||||
* Returns null if the codepoint is not found or the format is unsupported, |
||||
* in which case the caller should fall back to builtinGlyph(). |
||||
* |
||||
* U8G2 font parsing is not yet fully implemented — this is a hook for future |
||||
* integration with the u8g2-fonts Rust crate output. |
||||
*/ |
||||
export function u8g2Glyph( |
||||
_cp: number, |
||||
_fontData: Uint8Array, |
||||
): { cols: Uint8Array; w: number; h: number; xOff: number; yOff: number } | null { |
||||
// TODO: implement U8G2 binary glyph lookup
|
||||
return null; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text → pixel-image rasteriser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Rasterise `text` into a 1bpp pixel image. |
||||
* Iterates over actual Unicode codepoints (handles surrogate pairs / emoji). |
||||
* |
||||
* - `cellHeight`: height of the destination cell in pixels; glyphs are |
||||
* centred vertically within it. |
||||
* - `customFont`: optional raw U8G2 font data; if provided and the glyph is |
||||
* found, it is used instead of the built-in font. |
||||
* |
||||
* Returns a pixel image sized (textWidth × cellHeight) where each byte is 0 |
||||
* (off) or 1 (on). |
||||
*/ |
||||
export function rasterizeText( |
||||
text: string, |
||||
cellHeight: number, |
||||
customFont?: Uint8Array, |
||||
): { pixels: Uint8Array; width: number; height: number } { |
||||
// Collect glyph column data for each codepoint
|
||||
const glyphs: Array<{ cols: Uint8Array; w: number; h: number; yOff: number }> = []; |
||||
|
||||
for (const char of text) { // ← real codepoint iteration
|
||||
const cp = char.codePointAt(0) ?? 0x3F; // '?' fallback
|
||||
|
||||
// Try custom font first, fall back to built-in
|
||||
const custom = customFont ? u8g2Glyph(cp, customFont) : null; |
||||
if (custom) { |
||||
glyphs.push({ cols: custom.cols, w: custom.w, h: custom.h, yOff: custom.yOff }); |
||||
} else { |
||||
glyphs.push({ cols: builtinGlyph(cp), w: GLYPH_W, h: GLYPH_H, yOff: 0 }); |
||||
} |
||||
} |
||||
|
||||
// Total pixel width = sum of advances (GLYPH_ADVANCE per glyph, no trailing gap)
|
||||
const totalW = Math.max(1, glyphs.length * GLYPH_ADVANCE - 1); // remove last gap
|
||||
const pixels = new Uint8Array(totalW * cellHeight); |
||||
|
||||
// Vertical centre of glyph within cell
|
||||
const yBase = Math.max(0, Math.floor((cellHeight - GLYPH_H) / 2)); |
||||
|
||||
let x = 0; |
||||
for (const g of glyphs) { |
||||
const top = yBase + g.yOff; |
||||
for (let col = 0; col < g.w; col++) { |
||||
const colByte = g.cols[col] ?? 0; |
||||
for (let row = 0; row < g.h; row++) { |
||||
if ((colByte >> row) & 1) { |
||||
const py = top + row; |
||||
if (py >= 0 && py < cellHeight) { |
||||
pixels[py * totalW + x + col] = 1; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
x += GLYPH_ADVANCE; |
||||
} |
||||
|
||||
return { pixels, width: totalW, height: cellHeight }; |
||||
} |
||||
@ -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] = (packed[i >> 3] ?? 0) | (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] ?? 0) >> (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` |
||||
); |
||||
} |
||||
} |
||||
@ -0,0 +1,58 @@ |
||||
// =============================================================================
|
||||
// index.ts — public entry point
|
||||
//
|
||||
// Re-exports everything from library.ts so browser bundlers and module users
|
||||
// get a single clean import path:
|
||||
//
|
||||
// import { MonoDisplayDriver, loadBinFile } from "./index.ts";
|
||||
//
|
||||
// =============================================================================
|
||||
// =============================================================================
|
||||
// MonoDisplay Library - binary format parser + canvas renderer
|
||||
//
|
||||
// Spec: Mono Display File Format Specification (see adjacent .rst)
|
||||
//
|
||||
// File layout (little-endian throughout):
|
||||
//
|
||||
// [FILE HEADER - 12 bytes]
|
||||
// 4 bytes magic 0xAF 0x7E 0x2B 0x63
|
||||
// 4 bytes version uint32 LE; 0=illegal, 1=current
|
||||
// 2 bytes numSections uint16 LE
|
||||
// 2 bytes reserved must be 0
|
||||
//
|
||||
// [SECTION - repeated numSections times]
|
||||
// 1 byte sectionType uint8
|
||||
// 3 bytes sectionSize uint24 LE, INCLUDES these 4 header bytes
|
||||
// <data> sectionSize-4 bytes of section-specific data
|
||||
// <pad> to 32-bit boundary (included in sectionSize)
|
||||
//
|
||||
// SECTION TYPES:
|
||||
// 1 ElementsAlways - flags(u16) numElements(u16) elements...
|
||||
// 2 ElementsTimespan - flags(u16) numElements(u16) start(u64) end(u64) elements...
|
||||
// 32 CustomFont - actualSize(u24) reserved(u8) fontData...
|
||||
//
|
||||
// ELEMENT TYPES (uint16, all fields uint16 unless noted, 32-bit aligned):
|
||||
// 1 Image2D - type xOff yOff w h reserved [packed 1bpp pixels]
|
||||
// 2 Animation - type xOff yOff w h nFrames updateInterval reserved [N×frame]
|
||||
// 3 HorizontalScroll - type xOff yOff w h contentW flags(u8) speed(u8) reserved [pixels]
|
||||
// 4 VerticalScroll - type xOff yOff w h contentH flags(u8) speed(u8) reserved [pixels]
|
||||
// 5 Line - type xO yO xT yT lineStyle(u8) flags(u8)
|
||||
// 16 ClippedText - type xOff yOff w h fontIdx textLen [utf8 text + pad]
|
||||
// 17 HScrollText - type xOff yOff w h flags(u8) speed(u8) fontIdx textLen [utf8 + pad]
|
||||
//
|
||||
// PIXEL DATA: packed 1bpp, size = ceil(w*h/8).
|
||||
// px_i = y*w + x; byte = data[px_i>>3]; bit = (byte >> (px_i&7)) & 1
|
||||
// Frames aligned to octet boundary. Excess bits SHOULD be 0, MUST be ignored.
|
||||
//
|
||||
// ALIGNMENT: all sections and elements aligned to 32-bit (4-byte) boundaries.
|
||||
//
|
||||
// FONT INDEX: 0..32767 = built-in; 0x8000+ = custom font in file (0x8000=first).
|
||||
// =============================================================================
|
||||
|
||||
|
||||
export * from "./types"; |
||||
export { BinaryReader, packPixels, unpackPixels, packedSize, pad32 } from "./helper"; |
||||
export { MonoDisplayParser, MONOFORMAT_MAGIC_HEADER } from "./parser"; |
||||
export { MonoDisplayRenderer } from "./renderer"; |
||||
export { type MonoDisplayDriverOptions, MonoDisplayDriver } from "./driver"; |
||||
export { MonoDisplayFile, loadBinFile, buildBinBuffer } from "./file"; |
||||
@ -0,0 +1,282 @@ |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File format constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { packedSize, packPixels, pad32, unpackPixels, BinaryReader } from "./helper"; |
||||
import { type MonoFormatFile } from "./types"; |
||||
import { ElementType, SectionType, type MonoFormatAnimation, type MonoFormatClippedText, type MonoFormatCurrentTime, type MonoFormatCustomFont, type MonoFormatElement, type MonoFormatElementsAlways, type MonoFormatElementsTimespan, type MonoFormatHScroll, type MonoFormatHScrollText, type MonoFormatImage2D, type MonoFormatLine, type MonoFormatPixelImage, type MonoFormatSection, type MonoFormatSectionFlags, type MonoFormatVScroll } from "./types"; |
||||
|
||||
// Magic 0xAF 0x7E 0x2B 0x63 read as uint32 LE
|
||||
export const MONOFORMAT_MAGIC_HEADER = 0x632B7EAF; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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; |
||||
|
||||
const sectionEnd = sectionStart + dataSize; |
||||
switch (sectionType) { |
||||
case SectionType.ElementsAlways: |
||||
sections.push(this.#parseElementsSection(r, SectionType.ElementsAlways, false, sectionEnd)); |
||||
break; |
||||
case SectionType.ElementsTimespan: |
||||
sections.push(this.#parseElementsSection(r, SectionType.ElementsTimespan, true, sectionEnd)); |
||||
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, |
||||
sectionEnd: number, |
||||
): 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++) { |
||||
// Stop if fewer than 2 bytes remain in section (can't read element type)
|
||||
if (r.offset + 2 > sectionEnd) break; |
||||
const el = this.#parseElement(r); |
||||
if (el === null) break; // unknown type - can't determine size, stop
|
||||
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); |
||||
case ElementType.CurrentTime: return this.#parseCurrentTime(r); |
||||
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 }; |
||||
} |
||||
|
||||
#parseCurrentTime(r: BinaryReader): MonoFormatCurrentTime { |
||||
// Layout after type field (already consumed): 14 bytes, total element = 16 bytes (aligned)
|
||||
const xOffset = r.readUint16(); |
||||
const yOffset = r.readUint16(); |
||||
const width = r.readUint16(); |
||||
const height = r.readUint16(); |
||||
const fontIndex = r.readUint16(); |
||||
const utcOffsetMinutes = r.readInt16(); |
||||
const flagsByte = r.readByte(); |
||||
r.readByte(); // reserved
|
||||
return { |
||||
type: ElementType.CurrentTime, |
||||
xOffset, yOffset, width, height, fontIndex, utcOffsetMinutes, |
||||
flags: { |
||||
clock12h: !!(flagsByte & 0x01), |
||||
showHours: !!(flagsByte & 0x02), |
||||
showMinutes: !!(flagsByte & 0x04), |
||||
showSeconds: !!(flagsByte & 0x08), |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
#parseHScrollText(r: BinaryReader, start: number): MonoFormatHScrollText { |
||||
const xOffset = r.readUint16(); |
||||
const yOffset = r.readUint16(); |
||||
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,276 @@ |
||||
|
||||
import { |
||||
ElementType, |
||||
SectionType, |
||||
type MonoFormatAnimation, |
||||
type MonoFormatClippedText, |
||||
type MonoFormatCurrentTime, |
||||
type MonoFormatElement, |
||||
type MonoFormatElementsAlways, |
||||
type MonoFormatElementsTimespan, |
||||
type MonoFormatFile, |
||||
type MonoFormatHScroll, |
||||
type MonoFormatHScrollText, |
||||
type MonoFormatLine, |
||||
type MonoFormatPixelImage, |
||||
type MonoFormatVScroll |
||||
} from "./types.js"; |
||||
import { type MonoDisplayDriverOptions } from "./driver"; |
||||
import { rasterizeText } from "./font"; |
||||
/** |
||||
* Renders a MonoFormatFile onto an HTMLCanvasElement. |
||||
* Uses setInterval at 1000/fps ms per tick. All element state (animation |
||||
* 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(); |
||||
private customFonts: Uint8Array[] = []; |
||||
private textCache: Map<string, MonoFormatPixelImage> = 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(); |
||||
this.textCache.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; |
||||
|
||||
// Extract custom fonts from file sections (0x8000-indexed)
|
||||
this.customFonts = []; |
||||
for (const section of file.sections) { |
||||
if (section.sectionType === SectionType.CustomFont) { |
||||
this.customFonts.push(section.fontData); |
||||
} |
||||
} |
||||
|
||||
const tick = () => { |
||||
this.#renderFile(file); |
||||
this.tickCount++; |
||||
}; |
||||
|
||||
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(this.opts.now().getTime() / 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; |
||||
case ElementType.CurrentTime: this.#renderCurrentTime(el); 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; } |
||||
} |
||||
} |
||||
|
||||
#getTextImage(text: string, height: number, fontIndex: number): MonoFormatPixelImage { |
||||
const key = `${text}|${fontIndex}|${height}`; |
||||
const cached = this.textCache.get(key); |
||||
if (cached) return cached; |
||||
const customFont = fontIndex >= 0x8000 ? this.customFonts[fontIndex - 0x8000] : undefined; |
||||
const img = rasterizeText(text, height, customFont); |
||||
this.textCache.set(key, img); |
||||
return img; |
||||
} |
||||
|
||||
#renderClippedText(el: MonoFormatClippedText): void { |
||||
const img = this.#getTextImage(el.text, el.height, el.fontIndex); |
||||
this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); |
||||
} |
||||
|
||||
#renderHScrollText(el: MonoFormatHScrollText, id: number): void { |
||||
const img = this.#getTextImage(el.text, el.height, el.fontIndex); |
||||
let pos = this.hScrollPos.get(id) ?? 0; |
||||
|
||||
this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, pos, 0); |
||||
|
||||
const speed = (el.scrollSpeed + 1) / 16; |
||||
pos += el.flags.invertDirection ? -speed : speed; |
||||
if (el.flags.endless) { |
||||
if (pos >= img.width) pos -= img.width; |
||||
if (pos < 0) pos += img.width; |
||||
} else { |
||||
pos = Math.max(0, Math.min(pos, img.width - el.width)); |
||||
} |
||||
this.hScrollPos.set(id, pos); |
||||
} |
||||
|
||||
#renderCurrentTime(el: MonoFormatCurrentTime): void { |
||||
const d = new Date(this.opts.now().getTime() + el.utcOffsetMinutes * 60_000); |
||||
const { clock12h, showHours, showMinutes, showSeconds } = el.flags; |
||||
|
||||
const parts: string[] = []; |
||||
if (showHours) { |
||||
const h = d.getUTCHours(); |
||||
parts.push(String(clock12h ? (h % 12 || 12) : h).padStart(2, '0')); |
||||
} |
||||
if (showMinutes) parts.push(String(d.getUTCMinutes()).padStart(2, '0')); |
||||
if (showSeconds) parts.push(String(d.getUTCSeconds()).padStart(2, '0')); |
||||
|
||||
const text = parts.join(':'); |
||||
if (!text) return; |
||||
|
||||
const img = this.#getTextImage(text, el.height, el.fontIndex); |
||||
this.#blitClipped(img, el.xOffset, el.yOffset, el.width, el.height, 0, 0); |
||||
} |
||||
} |
||||
@ -0,0 +1,443 @@ |
||||
// =============================================================================
|
||||
// Mono Display File Format - type definitions
|
||||
// Spec: Mono Display File Format Specification
|
||||
// =============================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum FileVersion { |
||||
/** Illegal - must not appear in a valid file */ |
||||
Illegal = 0, |
||||
/** Current specification version */ |
||||
V1 = 1, |
||||
} |
||||
|
||||
export enum SectionType { |
||||
/** List of drawn elements - always shown */ |
||||
ElementsAlways = 1, |
||||
/** List of drawn elements - shown only within a POSIX timestamp window */ |
||||
ElementsTimespan = 2, |
||||
/** U8G2-format custom font used by draw elements in this file */ |
||||
CustomFont = 32, |
||||
} |
||||
|
||||
export enum ElementType { |
||||
Image2D = 1, |
||||
Animation = 2, |
||||
HorizontalScroll = 3, |
||||
VerticalScroll = 4, |
||||
Line = 5, |
||||
ClippedText = 16, |
||||
HScrollText = 17, |
||||
CurrentTime = 18, |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section flags (section types 1 and 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Bit-field flags for ElementsAlways / ElementsTimespan sections. */ |
||||
export interface SectionFlags { |
||||
/** Bit 0 - render elements onto the front-side buffer. */ |
||||
drawFront?: boolean; |
||||
/** Bit 1 - render elements onto the back-side buffer. */ |
||||
drawBack?: boolean; |
||||
/** Bit 2 - clear targeted buffer(s) before drawing this section's elements. */ |
||||
clearBuffer?: boolean; |
||||
// Bits 3-15: reserved; writers MUST write 0.
|
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scroll element flags (HorizontalScroll / VerticalScroll / HScrollText)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ScrollElementFlags { |
||||
/** Bit 0 - wrap content endlessly. */ |
||||
endless?: boolean; |
||||
/** Bit 1 - reverse scroll direction. */ |
||||
invertDirection?: boolean; |
||||
/** Bit 2 - pad at start edge (left for H-scroll, top for V-scroll). */ |
||||
padStart?: boolean; |
||||
/** Bit 3 - pad at end edge (right for H-scroll, bottom for V-scroll). */ |
||||
padEnd?: boolean; |
||||
// Bits 4-7: reserved.
|
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CurrentTime element flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CurrentTimeFlags { |
||||
/** Bit 0 - use 12-hour clock (default 24-hour). */ |
||||
clock12h?: boolean; |
||||
/** Bit 1 - show hours. */ |
||||
showHours?: boolean; |
||||
/** Bit 2 - show minutes. */ |
||||
showMinutes?: boolean; |
||||
/** Bit 3 - show seconds. */ |
||||
showSeconds?: boolean; |
||||
// Bits 4-15: reserved.
|
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Line element flags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LineFlags { |
||||
/** Bit 0 - invert pixel values along the line. */ |
||||
invertPixels?: boolean; |
||||
// Bits 1-7: reserved.
|
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Font index helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** |
||||
* Font index encoding: |
||||
* 0 .. 0x7FFF built-in font (device-specific; may not exist) |
||||
* 0x8000+ custom font embedded in this file |
||||
* 0x8000 = first CustomFont section |
||||
* 0x8001 = second, etc. |
||||
*/ |
||||
export type FontIndex = number; |
||||
|
||||
export const CUSTOM_FONT_BASE = 0x8000; |
||||
|
||||
/** Return true when fontIndex refers to a custom (in-file) font. */ |
||||
export function isCustomFont(fontIndex: FontIndex): boolean { |
||||
return fontIndex >= CUSTOM_FONT_BASE; |
||||
} |
||||
|
||||
/** Extract zero-based position of a custom font within the file. */ |
||||
export function customFontOrdinal(fontIndex: FontIndex): number { |
||||
return fontIndex - CUSTOM_FONT_BASE; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-facing element descriptors (MonoDisplayFile / builder input)
|
||||
// Pixels are 1 byte/pixel (0 = off, 1 = on) in the API; packed to 1bpp on
|
||||
// serialisation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Image2DElement { |
||||
type: ElementType.Image2D; |
||||
/** Row-major, 1 byte/pixel (0 = off, 1 = on). Packed to 1bpp on write. */ |
||||
pixels: Uint8Array; |
||||
width: number; |
||||
height: number; |
||||
xOffset?: number; // default 0
|
||||
yOffset?: number; // default 0
|
||||
} |
||||
|
||||
export interface AnimationFrameDescriptor { |
||||
/** 1 byte/pixel, same dimensions as the parent AnimationElement. */ |
||||
pixels: Uint8Array; |
||||
} |
||||
|
||||
export interface AnimationElement { |
||||
type: ElementType.Animation; |
||||
width: number; |
||||
height: number; |
||||
frames: AnimationFrameDescriptor[]; |
||||
/** |
||||
* Advance frame every (updateInterval + 1) ticks. |
||||
* 0 = every tick, 1 = every 2nd tick, ..., 65535 = every 65536th tick. |
||||
* Default 0. |
||||
*/ |
||||
updateInterval?: number; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export interface HScrollElement { |
||||
type: ElementType.HorizontalScroll; |
||||
/** Viewport width in pixels. */ |
||||
width: number; |
||||
/** Viewport height in pixels. */ |
||||
height: number; |
||||
/** Scrolling content pixels (contentWidth × height), 1 byte/pixel. */ |
||||
pixels: Uint8Array; |
||||
contentWidth: number; |
||||
/** |
||||
* Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. |
||||
* Range 0-255. Default 0 (= 1/16 px/tick). |
||||
*/ |
||||
scrollSpeed?: number; |
||||
flags?: ScrollElementFlags; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export interface VScrollElement { |
||||
type: ElementType.VerticalScroll; |
||||
/** Viewport width in pixels. */ |
||||
width: number; |
||||
/** Viewport height in pixels. */ |
||||
height: number; |
||||
/** Scrolling content pixels (width × contentHeight), 1 byte/pixel. */ |
||||
pixels: Uint8Array; |
||||
contentHeight: number; |
||||
/** Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. Default 0. */ |
||||
scrollSpeed?: number; |
||||
flags?: ScrollElementFlags; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export interface LineElement { |
||||
type: ElementType.Line; |
||||
xOrigin: number; |
||||
yOrigin: number; |
||||
xTarget: number; |
||||
yTarget: number; |
||||
/** Reserved by spec; writers MUST write 0. */ |
||||
lineStyle?: number; |
||||
invertPixels?: boolean; // flags bit 0
|
||||
} |
||||
|
||||
export interface ClippedTextElement { |
||||
type: ElementType.ClippedText; |
||||
text: string; // UTF-8
|
||||
width: number; |
||||
height: number; |
||||
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */ |
||||
fontIndex?: FontIndex; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export interface HScrollTextElement { |
||||
type: ElementType.HScrollText; |
||||
text: string; // UTF-8
|
||||
width: number; |
||||
height: number; |
||||
scrollSpeed?: number; // SS byte, default 0
|
||||
fontIndex?: FontIndex; |
||||
flags?: ScrollElementFlags; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export interface CurrentTimeElement { |
||||
type: ElementType.CurrentTime; |
||||
width: number; |
||||
height: number; |
||||
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */ |
||||
fontIndex?: FontIndex; |
||||
/** |
||||
* UTC offset in whole minutes (signed 16-bit integer). |
||||
* e.g. UTC+5:30 → 330, UTC-8 → -480. |
||||
*/ |
||||
utcOffsetMinutes?: number; |
||||
flags?: CurrentTimeFlags; |
||||
xOffset?: number; |
||||
yOffset?: number; |
||||
} |
||||
|
||||
export type DrawElement = |
||||
| Image2DElement |
||||
| AnimationElement |
||||
| HScrollElement |
||||
| VScrollElement |
||||
| LineElement |
||||
| ClippedTextElement |
||||
| HScrollTextElement |
||||
| CurrentTimeElement; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section descriptors (MonoDisplayFile / builder input)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ElementsAlwaysDescriptor { |
||||
flags?: SectionFlags; |
||||
elements: DrawElement[]; |
||||
} |
||||
|
||||
export interface ElementsTimespanDescriptor { |
||||
flags?: SectionFlags; |
||||
elements: DrawElement[]; |
||||
/** POSIX timestamp (seconds) - section visible when now >= startTimestamp. */ |
||||
startTimestamp: bigint; |
||||
/** POSIX timestamp (seconds) - section visible when now < endTimestamp. */ |
||||
endTimestamp: bigint; |
||||
} |
||||
|
||||
export interface CustomFontDescriptor { |
||||
/** Raw U8G2 font data. */ |
||||
fontData: Uint8Array; |
||||
} |
||||
|
||||
/** |
||||
* Top-level descriptor passed to MonoDisplayFile. |
||||
* |
||||
* Sections are written in the order they appear here. |
||||
* Custom fonts MUST appear before any element that references them. |
||||
*/ |
||||
export interface MonoDisplayFileDescriptor { |
||||
/** Section type 1 - drawn every render tick. */ |
||||
elements_always?: DrawElement[] | ElementsAlwaysDescriptor; |
||||
/** Section type 2 - drawn only within the given timestamp window. */ |
||||
elements_timespan?: ElementsTimespanDescriptor[]; |
||||
/** Section type 32 - embedded U8G2 custom fonts. */ |
||||
custom_fonts?: CustomFontDescriptor[]; |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoFormat types (MonoDisplayParser output → renderer input)
|
||||
// Pixels are always unpacked (1 byte/pixel) after parsing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MonoFormatPixelImage { |
||||
/** Unpacked: 1 byte per pixel, 0 = off, 1 = on, row-major. */ |
||||
pixels: Uint8Array; |
||||
width: number; |
||||
height: number; |
||||
} |
||||
|
||||
export interface MonoFormatImage2D { |
||||
type: ElementType.Image2D; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
image: MonoFormatPixelImage; |
||||
} |
||||
|
||||
export interface MonoFormatAnimation { |
||||
type: ElementType.Animation; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
updateInterval: number; |
||||
frames: MonoFormatPixelImage[]; |
||||
} |
||||
|
||||
export interface MonoFormatScrollFlags { |
||||
endless: boolean; |
||||
invertDirection: boolean; |
||||
padStart: boolean; |
||||
padEnd: boolean; |
||||
} |
||||
|
||||
export interface MonoFormatHScroll { |
||||
type: ElementType.HorizontalScroll; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
contentWidth: number; |
||||
scrollSpeed: number; |
||||
flags: MonoFormatScrollFlags; |
||||
content: MonoFormatPixelImage; |
||||
} |
||||
|
||||
export interface MonoFormatVScroll { |
||||
type: ElementType.VerticalScroll; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
contentHeight: number; |
||||
scrollSpeed: number; |
||||
flags: MonoFormatScrollFlags; |
||||
content: MonoFormatPixelImage; |
||||
} |
||||
|
||||
export interface MonoFormatLine { |
||||
type: ElementType.Line; |
||||
xOrigin: number; |
||||
yOrigin: number; |
||||
xTarget: number; |
||||
yTarget: number; |
||||
lineStyle: number; |
||||
invertPixels: boolean; |
||||
} |
||||
|
||||
export interface MonoFormatClippedText { |
||||
type: ElementType.ClippedText; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
fontIndex: FontIndex; |
||||
text: string; |
||||
} |
||||
|
||||
export interface MonoFormatHScrollText { |
||||
type: ElementType.HScrollText; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
scrollSpeed: number; |
||||
fontIndex: FontIndex; |
||||
flags: MonoFormatScrollFlags; |
||||
text: string; |
||||
} |
||||
|
||||
export interface MonoFormatCurrentTimeFlags { |
||||
clock12h: boolean; |
||||
showHours: boolean; |
||||
showMinutes: boolean; |
||||
showSeconds: boolean; |
||||
} |
||||
|
||||
export interface MonoFormatCurrentTime { |
||||
type: ElementType.CurrentTime; |
||||
xOffset: number; |
||||
yOffset: number; |
||||
width: number; |
||||
height: number; |
||||
fontIndex: FontIndex; |
||||
/** UTC offset in minutes (signed). */ |
||||
utcOffsetMinutes: number; |
||||
flags: MonoFormatCurrentTimeFlags; |
||||
} |
||||
|
||||
export type MonoFormatElement = |
||||
| MonoFormatImage2D |
||||
| MonoFormatAnimation |
||||
| MonoFormatHScroll |
||||
| MonoFormatVScroll |
||||
| MonoFormatLine |
||||
| MonoFormatClippedText |
||||
| MonoFormatHScrollText |
||||
| MonoFormatCurrentTime; |
||||
|
||||
export interface MonoFormatSectionFlags { |
||||
drawFront: boolean; |
||||
drawBack: boolean; |
||||
clearBuffer: boolean; |
||||
} |
||||
|
||||
export interface MonoFormatElementsAlways { |
||||
sectionType: SectionType.ElementsAlways; |
||||
flags: MonoFormatSectionFlags; |
||||
elements: MonoFormatElement[]; |
||||
} |
||||
|
||||
export interface MonoFormatElementsTimespan { |
||||
sectionType: SectionType.ElementsTimespan; |
||||
flags: MonoFormatSectionFlags; |
||||
startTimestamp: bigint; // POSIX seconds
|
||||
endTimestamp: bigint; |
||||
elements: MonoFormatElement[]; |
||||
} |
||||
|
||||
export interface MonoFormatCustomFont { |
||||
sectionType: SectionType.CustomFont; |
||||
/** Raw U8G2 font data (actual bytes, not padded). */ |
||||
fontData: Uint8Array; |
||||
} |
||||
|
||||
export type MonoFormatSection = |
||||
| MonoFormatElementsAlways |
||||
| MonoFormatElementsTimespan |
||||
| MonoFormatCustomFont; |
||||
|
||||
/** Top-level MonoDisplayParser output. */ |
||||
export interface MonoFormatFile { |
||||
sections: MonoFormatSection[]; |
||||
} |
||||
@ -0,0 +1,365 @@ |
||||
// =============================================================================
|
||||
// library.test.ts — bun test suite for MonoDisplay library
|
||||
//
|
||||
// run: bun test
|
||||
// =============================================================================
|
||||
|
||||
import { test, expect, describe } from "bun:test"; |
||||
import { |
||||
BinaryReader, |
||||
MonoDisplayParser, |
||||
MonoDisplayFile, |
||||
ElementType, |
||||
SectionType, |
||||
buildBinBuffer, |
||||
type Image2DElement, |
||||
type MonoFormatElementsAlways, |
||||
type MonoFormatImage2D, |
||||
} from "../src/index"; |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low-level buffer helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Real magic: 0xAF 0x7E 0x2B 0x63
|
||||
const MAGIC_BYTES = new Uint8Array([0xAF, 0x7E, 0x2B, 0x63]); |
||||
|
||||
function concat(...parts: (Uint8Array | ArrayBuffer)[]): ArrayBuffer { |
||||
const arrays = parts.map((p: Uint8Array | ArrayBuffer) => |
||||
p instanceof ArrayBuffer ? new Uint8Array(p) : p |
||||
); |
||||
const total = arrays.reduce((s, a) => s + a.byteLength, 0); |
||||
const out = new Uint8Array(total); |
||||
let off = 0; |
||||
for (const a of arrays) { out.set(a, off); off += a.byteLength; } |
||||
return out.buffer; |
||||
} |
||||
|
||||
function u8(n: number) { return new Uint8Array([n]); } |
||||
function u16le(n: number) { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, n, true); return b; } |
||||
function u24le(n: number) { return new Uint8Array([n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF]); } |
||||
function u32le(n: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; } |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BinaryReader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("BinaryReader", () => { |
||||
test("readByte", () => { |
||||
const r = new BinaryReader(new Uint8Array([0xAB]).buffer); |
||||
expect(r.readByte()).toBe(0xAB); |
||||
expect(r.remaining).toBe(0); |
||||
}); |
||||
|
||||
test("readUint16 LE", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x34, 0x12]).buffer); |
||||
expect(r.readUint16()).toBe(0x1234); |
||||
}); |
||||
|
||||
test("readShort alias", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x01, 0x00]).buffer); |
||||
expect(r.readShort()).toBe(1); |
||||
}); |
||||
|
||||
test("readUint24 LE", () => { |
||||
// 0x010203 LE → bytes [0x03, 0x02, 0x01]
|
||||
const r = new BinaryReader(new Uint8Array([0x03, 0x02, 0x01]).buffer); |
||||
expect(r.readUint24()).toBe(0x010203); |
||||
}); |
||||
|
||||
test("readUint32 LE", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]).buffer); |
||||
expect(r.readUint32()).toBe(0x12345678); |
||||
}); |
||||
|
||||
test("readUint64 LE", () => { |
||||
const bytes = new Uint8Array(8); |
||||
new DataView(bytes.buffer).setBigUint64(0, 0x0102030405060708n, true); |
||||
const r = new BinaryReader(bytes.buffer); |
||||
expect(r.readUint64()).toBe(0x0102030405060708n); |
||||
}); |
||||
|
||||
test("offset advances through mixed reads", () => { |
||||
const r = new BinaryReader(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer); |
||||
r.readByte(); expect(r.offset).toBe(1); |
||||
r.readUint16(); expect(r.offset).toBe(3); |
||||
expect(() => r.readUint32()).toThrow(RangeError); // only 3 remain
|
||||
}); |
||||
|
||||
test("RangeError on exhaustion", () => { |
||||
const r = new BinaryReader(new Uint8Array([0x01]).buffer); |
||||
r.readByte(); |
||||
expect(() => r.readByte()).toThrow(RangeError); |
||||
}); |
||||
|
||||
test("seek", () => { |
||||
const r = new BinaryReader(new Uint8Array([10, 20, 30]).buffer); |
||||
r.seek(2); |
||||
expect(r.readByte()).toBe(30); |
||||
}); |
||||
|
||||
test("seek out of bounds throws", () => { |
||||
const r = new BinaryReader(new Uint8Array([1, 2]).buffer); |
||||
expect(() => r.seek(5)).toThrow(RangeError); |
||||
}); |
||||
|
||||
test("readUtf8 ASCII", () => { |
||||
const enc = new TextEncoder().encode("hello"); |
||||
const r = new BinaryReader(enc.buffer); |
||||
expect(r.readUtf8(5)).toBe("hello"); |
||||
}); |
||||
|
||||
test("readUtf8 multi-byte codepoints", () => { |
||||
const str = "日本語🚀"; |
||||
const enc = new TextEncoder().encode(str); |
||||
const r = new BinaryReader(enc.buffer); |
||||
expect(r.readUtf8(enc.byteLength)).toBe(str); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — header validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — header", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("rejects wrong magic", () => { |
||||
const bad = new Uint8Array(16).fill(0); |
||||
bad[0] = 0xDE; bad[1] = 0xAD; bad[2] = 0xBE; bad[3] = 0xEF; |
||||
expect(() => parser.parse(bad.buffer)).toThrow(/magic/i); |
||||
}); |
||||
|
||||
test("rejects version 0 (illegal)", () => { |
||||
const buf = concat(MAGIC_BYTES, u32le(0), u16le(0), u16le(0)); |
||||
expect(() => parser.parse(buf)).toThrow(/version/i); |
||||
}); |
||||
|
||||
test("rejects version > 1 (reserved)", () => { |
||||
const buf = concat(MAGIC_BYTES, u32le(2), u16le(0), u16le(0)); |
||||
expect(() => parser.parse(buf)).toThrow(/version/i); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayParser — Image2D round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayParser — Image2D round-trip", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
/** |
||||
* Build a minimal valid file buffer containing one ElementsAlways section |
||||
* with one Image2D element. |
||||
* |
||||
* File header (12 bytes): |
||||
* magic u32le + version u32le + numSections u16le + reserved u16le |
||||
* |
||||
* Section header (4 bytes): |
||||
* type u8 + size u24le (INCLUDES 4-byte header) |
||||
* |
||||
* ElementsAlways section data: |
||||
* flags u16le + numElements u16le |
||||
* |
||||
* Image2D element (12 fixed bytes + packed pixel data): |
||||
* type u16le + xOffset u16le + yOffset u16le + width u16le + height u16le + reserved u16le |
||||
* + packed 1bpp pixel data + padding to 4-byte boundary |
||||
*/ |
||||
function makeImage2DFile( |
||||
width: number, |
||||
height: number, |
||||
pixels: Uint8Array, |
||||
xOffset: number = 0, |
||||
yOffset: number = 0, |
||||
): ArrayBuffer { |
||||
// Pack pixels to 1bpp
|
||||
const total = width * height; |
||||
const packedLen = (total + 7) >> 3; |
||||
const packed = new Uint8Array(packedLen); |
||||
for (let i = 0; i < total; i++) { |
||||
if (pixels[i]) packed[i >> 3] = (packed[i >> 3] ?? 0) | (1 << (i & 7)); |
||||
} |
||||
// Padding to 4-byte boundary for element (12 fixed bytes + packed data)
|
||||
const elementDataLen = 12 + packedLen; |
||||
const padLen = (4 - (elementDataLen & 3)) & 3; |
||||
const padding = new Uint8Array(padLen); |
||||
|
||||
// Image2D element bytes
|
||||
const element = new Uint8Array(concat( |
||||
u16le(1), // type = Image2D
|
||||
u16le(xOffset), |
||||
u16le(yOffset), |
||||
u16le(width), |
||||
u16le(height), |
||||
u16le(0), // reserved
|
||||
packed, |
||||
padding, |
||||
)); |
||||
|
||||
// Section data: flags + numElements + element
|
||||
const sectionData = new Uint8Array(concat( |
||||
u16le(0), // flags = 0
|
||||
u16le(1), // numElements = 1
|
||||
element, |
||||
)); |
||||
|
||||
// Section header: type u8 + size u24le (4 header bytes + data)
|
||||
const sectionSize = 4 + sectionData.byteLength; |
||||
const sectionHeader = new Uint8Array(concat( |
||||
u8(1), // SectionType.ElementsAlways = 1
|
||||
u24le(sectionSize), |
||||
)); |
||||
|
||||
// File header
|
||||
const fileHeader = new Uint8Array(concat( |
||||
MAGIC_BYTES, |
||||
u32le(1), // version = 1
|
||||
u16le(1), // numSections = 1
|
||||
u16le(0), // reserved
|
||||
)); |
||||
|
||||
return concat(fileHeader, sectionHeader, sectionData); |
||||
} |
||||
|
||||
test("sections[0].sectionType === SectionType.ElementsAlways", () => { |
||||
const pixels = new Uint8Array([1, 0, 0, 1]); |
||||
const buf = makeImage2DFile(2, 2, pixels); |
||||
const result = parser.parse(buf); |
||||
expect(result.sections.length).toBe(1); |
||||
expect(result.sections[0]!.sectionType).toBe(SectionType.ElementsAlways); |
||||
}); |
||||
|
||||
test("elements[0].type === ElementType.Image2D", () => { |
||||
const pixels = new Uint8Array([1, 0, 0, 1]); |
||||
const buf = makeImage2DFile(2, 2, pixels); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
expect(section.elements.length).toBe(1); |
||||
expect(section.elements[0]!.type).toBe(ElementType.Image2D); |
||||
}); |
||||
|
||||
test("correct dimensions after parse", () => { |
||||
const pixels = new Uint8Array([0, 1, 1, 0]); |
||||
const buf = makeImage2DFile(2, 2, pixels); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const el = section.elements[0] as MonoFormatImage2D; |
||||
expect(el.image.width).toBe(2); |
||||
expect(el.image.height).toBe(2); |
||||
}); |
||||
|
||||
test("correct pixel data after round-trip", () => { |
||||
// pixels: [1, 0, 0, 1] → top-left and bottom-right are on
|
||||
const pixels = new Uint8Array([1, 0, 0, 1]); |
||||
const buf = makeImage2DFile(2, 2, pixels); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const el = section.elements[0] as MonoFormatImage2D; |
||||
expect(Array.from(el.image.pixels)).toEqual([1, 0, 0, 1]); |
||||
}); |
||||
|
||||
test("xOffset and yOffset are preserved", () => { |
||||
const pixels = new Uint8Array([1, 1, 1, 1]); |
||||
const buf = makeImage2DFile(2, 2, pixels, 5, 3); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const el = section.elements[0] as MonoFormatImage2D; |
||||
expect(el.xOffset).toBe(5); |
||||
expect(el.yOffset).toBe(3); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MonoDisplayFile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MonoDisplayFile", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("toBuffer() produces parseable output for Image2D element", () => { |
||||
const pixels = new Uint8Array([1, 0, 1, 0]); |
||||
const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 2, height: 2 }; |
||||
const file = new MonoDisplayFile({ elements_always: [el] }); |
||||
const buf = file.toBuffer(); |
||||
const result = parser.parse(buf); |
||||
expect(result.sections.length).toBeGreaterThan(0); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
expect(section.sectionType).toBe(SectionType.ElementsAlways); |
||||
expect(section.elements.length).toBe(1); |
||||
const parsed = section.elements[0] as MonoFormatImage2D; |
||||
expect(parsed.type).toBe(ElementType.Image2D); |
||||
expect(Array.from(parsed.image.pixels)).toEqual([1, 0, 1, 0]); |
||||
}); |
||||
|
||||
test("xOffset/yOffset are round-tripped", () => { |
||||
const pixels = new Uint8Array([1, 1, 1, 1]); |
||||
const el: Image2DElement = { |
||||
type: ElementType.Image2D, |
||||
pixels, |
||||
width: 2, |
||||
height: 2, |
||||
xOffset: 10, |
||||
yOffset: 7, |
||||
}; |
||||
const file = new MonoDisplayFile({ elements_always: [el] }); |
||||
const buf = file.toBuffer(); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const parsed = section.elements[0] as MonoFormatImage2D; |
||||
expect(parsed.xOffset).toBe(10); |
||||
expect(parsed.yOffset).toBe(7); |
||||
}); |
||||
|
||||
test("flags.drawFront default is applied", () => { |
||||
const pixels = new Uint8Array([0]); |
||||
const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 1, height: 1 }; |
||||
const file = new MonoDisplayFile({ elements_always: [el] }); |
||||
const buf = file.toBuffer(); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
// Default flags should have drawFront = true (flags byte bit 0 = 1)
|
||||
expect(section.flags.drawFront).toBe(true); |
||||
}); |
||||
|
||||
test("empty elements_always array is valid and produces parseable output", () => { |
||||
const file = new MonoDisplayFile({ elements_always: [] }); |
||||
const buf = file.toBuffer(); |
||||
const result = parser.parse(buf); |
||||
expect(result.sections.length).toBeGreaterThan(0); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
expect(section.sectionType).toBe(SectionType.ElementsAlways); |
||||
expect(section.elements.length).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildBinBuffer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildBinBuffer", () => { |
||||
const parser = new MonoDisplayParser(); |
||||
|
||||
test("builds a parseable Uint8Array from Image2DElement", () => { |
||||
const pixels = new Uint8Array([0, 1, 1, 0]); |
||||
const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 2, height: 2 }; |
||||
const buf = buildBinBuffer(el); |
||||
expect(buf).toBeInstanceOf(Uint8Array); |
||||
const result = parser.parse(buf); |
||||
expect(result.sections.length).toBeGreaterThan(0); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const parsed = section.elements[0] as MonoFormatImage2D; |
||||
expect(Array.from(parsed.image.pixels)).toEqual([0, 1, 1, 0]); |
||||
}); |
||||
|
||||
test("pixel data round-trips correctly", () => { |
||||
const pixels = new Uint8Array([1, 0, 0, 0, 0, 0, 0, 1]); // 8 pixels, corners on
|
||||
const el: Image2DElement = { type: ElementType.Image2D, pixels, width: 8, height: 1 }; |
||||
const buf = buildBinBuffer(el); |
||||
const result = parser.parse(buf); |
||||
const section = result.sections[0] as MonoFormatElementsAlways; |
||||
const parsed = section.elements[0] as MonoFormatImage2D; |
||||
expect(parsed.image.width).toBe(8); |
||||
expect(parsed.image.height).toBe(1); |
||||
expect(Array.from(parsed.image.pixels)).toEqual([1, 0, 0, 0, 0, 0, 0, 1]); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,29 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
// Environment setup & latest features |
||||
"lib": ["ESNext", "DOM"], |
||||
"target": "ESNext", |
||||
"module": "Preserve", |
||||
"moduleDetection": "force", |
||||
"jsx": "react-jsx", |
||||
"allowJs": true, |
||||
|
||||
// Bundler mode |
||||
"moduleResolution": "bundler", |
||||
"allowImportingTsExtensions": true, |
||||
"verbatimModuleSyntax": true, |
||||
"noEmit": true, |
||||
|
||||
// Best practices |
||||
"strict": true, |
||||
"skipLibCheck": true, |
||||
"noFallthroughCasesInSwitch": true, |
||||
"noUncheckedIndexedAccess": true, |
||||
"noImplicitOverride": true, |
||||
|
||||
// Some stricter flags (disabled by default) |
||||
"noUnusedLocals": false, |
||||
"noUnusedParameters": false, |
||||
"noPropertyAccessFromIndexSignature": false |
||||
} |
||||
} |
||||
Loading…
Reference in new issue