feat: render the panel image as a 1-bit 800x480 BMP or PNG

With the move to BYOS there is no TRMNL cloud to interpret a Liquid
template, so the application draws the screen itself.

The pipeline is satori (flexbox to SVG) then resvg (SVG to pixels) then
a hand-written 1-bit encoder. Chromium was the alternative and was
rejected: half a gigabyte and a real memory appetite in the runtime
image, for a screen of eight blocks. Playwright still handles the E2E
tests, in its own pinned image, never in the application one.

The payoff is testability. The layout is asserted on the element tree
and the geometry on the SVG text, so a rendering regression shows up as
a readable diff instead of a pixel comparison. The BMP and PNG encoders
are verified field by field against their specifications, including an
independent CRC-32 for the PNG: a device rejecting a malformed image is
expensive to debug from a shop window.

Two properties are pinned because the battery depends on them: the same
payload must produce byte-identical output, and changed hours must
produce different output. The filename handed to the device is a hash of
these bytes, and the firmware skips the redraw when it is unchanged.

The fonts are vendored into public/fonts and the logo into public/brand,
both committed. Rendering must not depend on an install tree, a CDN or
the network, or the bytes drift and the panel wakes for nothing.

`npm run screen:preview` writes a real 1-bit image plus a magnified view,
which is where clipping and thin strokes give themselves up. That is how
the week strip was caught clipping and condensed.

`npm run brand` rebuilds the assets: it locates the wordmark band in the
shop logo rather than hard-coding offsets a future revision would break,
and thresholds it with the panel's own encoder. sharp is a devDependency
used only there; nothing at runtime needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 17:51:04 +02:00
co-authored by Claude Opus 5
parent 72aff310d6
commit fccccbd118
20 changed files with 1262 additions and 2 deletions
+153
View File
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest';
import { encodeBmp, encodePng, toBitmap, type Bitmap } from './encode';
/** Builds RGBA pixels from a picture drawn with '#' for ink and '.' for paper. */
function pixels(rows: string[]): { rgba: Buffer; width: number; height: number } {
const height = rows.length;
const width = rows[0]?.length ?? 0;
const rgba = Buffer.alloc(width * height * 4);
rows.forEach((row, y) => {
[...row].forEach((cell, x) => {
const value = cell === '#' ? 0 : 255;
const offset = (y * width + x) * 4;
rgba[offset] = value;
rgba[offset + 1] = value;
rgba[offset + 2] = value;
rgba[offset + 3] = 255;
});
});
return { rgba, width, height };
}
describe('toBitmap', () => {
it('packs eight pixels into one byte, most significant bit first', () => {
const { rgba, width, height } = pixels(['#.......']);
const bitmap = toBitmap(rgba, width, height);
// Black at x=0, white elsewhere: 0111 1111.
expect(bitmap.bits[0]).toBe(0b0111_1111);
});
it('pads the final byte of a row that is not a multiple of eight', () => {
const { rgba, width, height } = pixels(['###']);
const bitmap = toBitmap(rgba, width, height);
expect(bitmap.bits).toHaveLength(1);
expect(bitmap.bits[0]).toBe(0);
});
it('treats mid-grey either side of the threshold', () => {
const rgba = Buffer.from([126, 126, 126, 255, 128, 128, 128, 255]);
const bitmap = toBitmap(rgba, 2, 1);
// The darker pixel becomes ink, the lighter one paper.
expect(bitmap.bits[0]).toBe(0b0100_0000);
});
it('treats a transparent pixel as paper, not as ink', () => {
const rgba = Buffer.from([0, 0, 0, 0]);
expect(toBitmap(rgba, 1, 1).bits[0]).toBe(0b1000_0000);
});
it('inverts on request', () => {
const { rgba, width, height } = pixels(['#.......']);
expect(toBitmap(rgba, width, height, { invert: true }).bits[0]).toBe(0b1000_0000);
});
it('honours a custom threshold', () => {
const rgba = Buffer.from([200, 200, 200, 255]);
expect(toBitmap(rgba, 1, 1, { threshold: 220 }).bits[0]).toBe(0);
});
});
describe('encodeBmp', () => {
const bitmap: Bitmap = toBitmap(...(() => {
const { rgba, width, height } = pixels(['#.......', '.......#']);
return [rgba, width, height] as const;
})());
it('writes a BMP signature and a 62-byte pixel offset', () => {
const bmp = encodeBmp(bitmap);
expect(bmp.subarray(0, 2).toString('ascii')).toBe('BM');
// 14-byte file header + 40-byte info header + two palette entries.
expect(bmp.readUInt32LE(10)).toBe(62);
});
it('declares one bit per pixel and no compression', () => {
const bmp = encodeBmp(bitmap);
expect(bmp.readUInt32LE(14)).toBe(40); // info header size
expect(bmp.readInt32LE(18)).toBe(8); // width
expect(bmp.readInt32LE(22)).toBe(2); // height
expect(bmp.readUInt16LE(26)).toBe(1); // planes
expect(bmp.readUInt16LE(28)).toBe(1); // bits per pixel
expect(bmp.readUInt32LE(30)).toBe(0); // BI_RGB
expect(bmp.readUInt32LE(46)).toBe(2); // palette entries
});
it('pads every row to four bytes', () => {
const bmp = encodeBmp(bitmap);
// Two rows of one byte each, padded to four: eight bytes of pixel data.
expect(bmp.readUInt32LE(34)).toBe(8);
expect(bmp.length).toBe(62 + 8);
expect(bmp.readUInt32LE(2)).toBe(bmp.length);
});
it('stores rows bottom-up, as the format requires', () => {
const bmp = encodeBmp(bitmap);
// The last source row lands first in the file.
expect(bmp[62]).toBe(0b1111_1110);
expect(bmp[66]).toBe(0b0111_1111);
});
it('pads a 800-pixel row to exactly 100 bytes', () => {
const wide = toBitmap(Buffer.alloc(800 * 4, 255), 800, 1);
const bmp = encodeBmp(wide);
// 800 bits = 100 bytes, already a multiple of four.
expect(bmp.readUInt32LE(34)).toBe(100);
});
});
describe('encodePng', () => {
const bitmap = toBitmap(...(() => {
const { rgba, width, height } = pixels(['#.......', '.......#']);
return [rgba, width, height] as const;
})());
it('writes the PNG signature', () => {
const png = encodePng(bitmap);
expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
});
it('declares a one-bit greyscale image of the right size', () => {
const png = encodePng(bitmap);
expect(png.subarray(12, 16).toString('ascii')).toBe('IHDR');
expect(png.readUInt32BE(16)).toBe(8); // width
expect(png.readUInt32BE(20)).toBe(2); // height
expect(png.readUInt8(24)).toBe(1); // bit depth
expect(png.readUInt8(25)).toBe(0); // greyscale
});
it('ends with an IEND chunk', () => {
const png = encodePng(bitmap);
expect(png.subarray(png.length - 8, png.length - 4).toString('ascii')).toBe('IEND');
});
it('writes a CRC a decoder will accept', () => {
// An independent CRC-32 implementation: a decoder rejects the file if ours
// is wrong, and a device rejecting the image is expensive to debug.
const reference = (data: Buffer): number => {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
};
const png = encodePng(bitmap);
const length = png.readUInt32BE(8);
// Chunk body is the type plus the data; the CRC follows it.
const body = png.subarray(12, 12 + 4 + length);
expect(png.readUInt32BE(12 + 4 + length)).toBe(reference(body));
});
});