Merge remote-tracking branch 'ts-source/main' into feat/typescript

pull/1/head
flop 4 weeks ago
commit 5af50fcd29
  1. 9
      ts/.claude/settings.local.json
  2. 37
      ts/.gitignore
  3. 106
      ts/CLAUDE.md
  4. 15
      ts/README.md
  5. 26
      ts/bun.lock
  6. 225
      ts/index.html
  7. 15
      ts/package.json
  8. 9
      ts/src/browser.ts
  9. 95
      ts/src/driver.ts
  10. 283
      ts/src/file.ts
  11. 216
      ts/src/font.ts
  12. 102
      ts/src/helper.ts
  13. 58
      ts/src/index.ts
  14. 282
      ts/src/parser.ts
  15. 276
      ts/src/renderer.ts
  16. 443
      ts/src/types.ts
  17. 365
      ts/test/library.test.ts
  18. 29
      ts/tsconfig.json

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(bun test:*)",
"Bash(bun build:*)",
"Bash(bun run:*)"
]
}
}

37
ts/.gitignore vendored

@ -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 0x200x7E.
*/
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…
Cancel
Save