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