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));
});
});
+205
View File
@@ -0,0 +1,205 @@
/**
* Turns the rendered SVG into what the panel actually consumes: a 1-bit
* 800x480 image.
*
* The firmware on this kit references `.bmp`, but the documentation is not
* explicit, so both encoders live here and `DEVICE_IMAGE_FORMAT` chooses. The
* bit-packing is shared, so whichever format wins shows identical pixels.
*
* There is deliberately no dithering. The source is flat black on flat white;
* dithering would only fuzz the text edges and cost contrast on a panel that
* has none to spare.
*/
import { deflateSync } from 'node:zlib';
import { Resvg } from '@resvg/resvg-js';
import { SCREEN_HEIGHT, SCREEN_WIDTH } from './layout';
export type Bitmap = {
width: number;
height: number;
/** One bit per pixel, MSB first, row-major. A set bit is white. */
bits: Uint8Array;
};
/** Mid-grey. Anti-aliased glyph edges fall either side of it. */
export const DEFAULT_THRESHOLD = 127;
export type EncodeOptions = {
threshold?: number;
/**
* Flip black and white. The knob to reach for if the panel ever comes out
* as a negative; the pixels are otherwise correct.
*/
invert?: boolean;
};
/** Rasterises the SVG at its natural size and returns RGBA pixels. */
export function rasterise(svg: string): { width: number; height: number; rgba: Buffer } {
const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: SCREEN_WIDTH } });
const rendered = resvg.render();
return { width: rendered.width, height: rendered.height, rgba: rendered.pixels };
}
/** Thresholds RGBA pixels into one bit each, no dithering. */
export function toBitmap(
rgba: Buffer,
width: number,
height: number,
{ threshold = DEFAULT_THRESHOLD, invert = false }: EncodeOptions = {},
): Bitmap {
const bytesPerRow = Math.ceil(width / 8);
const bits = new Uint8Array(bytesPerRow * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 4;
const red = rgba[offset] ?? 0;
const green = rgba[offset + 1] ?? 0;
const blue = rgba[offset + 2] ?? 0;
const alpha = rgba[offset + 3] ?? 255;
// Composite over white first: the panel is paper, so anything
// transparent is paper too, not ink.
const opacity = alpha / 255;
const over = (channel: number): number => channel * opacity + 255 * (1 - opacity);
const luminance = 0.299 * over(red) + 0.587 * over(green) + 0.114 * over(blue);
const isWhite = invert ? luminance <= threshold : luminance > threshold;
if (isWhite) {
const index = y * bytesPerRow + (x >> 3);
bits[index] = (bits[index] ?? 0) | (0b1000_0000 >> (x & 7));
}
}
}
return { width, height, bits };
}
/**
* A 1-bit BMP: 14-byte file header, 40-byte info header, a two-colour palette,
* then bottom-up rows padded to four bytes. Uncompressed on purpose — the
* firmware reads it straight through.
*/
export function encodeBmp(bitmap: Bitmap): Buffer {
const { width, height, bits } = bitmap;
const sourceRowBytes = Math.ceil(width / 8);
const rowBytes = Math.ceil(width / 32) * 4;
const pixelBytes = rowBytes * height;
const offset = 14 + 40 + 8;
const buffer = Buffer.alloc(offset + pixelBytes);
buffer.write('BM', 0, 'ascii');
buffer.writeUInt32LE(buffer.length, 2);
buffer.writeUInt32LE(0, 6);
buffer.writeUInt32LE(offset, 10);
buffer.writeUInt32LE(40, 14);
buffer.writeInt32LE(width, 18);
buffer.writeInt32LE(height, 22);
buffer.writeUInt16LE(1, 26);
buffer.writeUInt16LE(1, 28);
buffer.writeUInt32LE(0, 30);
buffer.writeUInt32LE(pixelBytes, 34);
buffer.writeInt32LE(2835, 38);
buffer.writeInt32LE(2835, 42);
buffer.writeUInt32LE(2, 46);
buffer.writeUInt32LE(2, 50);
// Palette, BGRA. Index 0 is black, index 1 is white.
buffer.writeUInt32LE(0x00000000, 54);
buffer.writeUInt32LE(0x00ffffff, 58);
// BMP rows run bottom to top.
for (let y = 0; y < height; y += 1) {
const source = (height - 1 - y) * sourceRowBytes;
const target = offset + y * rowBytes;
for (let byte = 0; byte < sourceRowBytes; byte += 1) {
buffer[target + byte] = bits[source + byte] ?? 0;
}
}
return buffer;
}
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** A 1-bit greyscale PNG, for firmwares that prefer it to BMP. */
export function encodePng(bitmap: Bitmap): Buffer {
const { width, height, bits } = bitmap;
const rowBytes = Math.ceil(width / 8);
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header.writeUInt8(1, 8); // bit depth
header.writeUInt8(0, 9); // greyscale
header.writeUInt8(0, 10);
header.writeUInt8(0, 11);
header.writeUInt8(0, 12);
// Every scanline carries a leading filter byte; "none" keeps it readable.
const raw = Buffer.alloc((rowBytes + 1) * height);
for (let y = 0; y < height; y += 1) {
const target = y * (rowBytes + 1);
raw[target] = 0;
for (let byte = 0; byte < rowBytes; byte += 1) {
raw[target + 1 + byte] = bits[y * rowBytes + byte] ?? 0;
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk('IHDR', header),
chunk('IDAT', deflateSync(raw, { level: 9 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
function chunk(type: string, data: Buffer): Buffer {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([length, body, crc]);
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
/** The whole pipeline: SVG in, panel-ready bytes out. */
export function encodeScreen(
svg: string,
format: 'bmp' | 'png',
options: EncodeOptions = {},
): Buffer {
const { width, height, rgba } = rasterise(svg);
const bitmap = toBitmap(rgba, width, height, options);
if (bitmap.width !== SCREEN_WIDTH || bitmap.height !== SCREEN_HEIGHT) {
throw new Error(
`Rendered ${bitmap.width}x${bitmap.height}, expected ${SCREEN_WIDTH}x${SCREEN_HEIGHT}`,
);
}
return format === 'png' ? encodePng(bitmap) : encodeBmp(bitmap);
}
+39
View File
@@ -0,0 +1,39 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import type { Font } from 'satori';
import { SANS, SERIF } from './layout';
/**
* Font binaries for the panel renderer, read from `public/fonts` rather than
* from `node_modules`.
*
* The rendered pixels feed a content hash that decides whether the e-ink panel
* redraws, so they have to be reproducible. Vendored files make that true; an
* install tree or a CDN would not.
*/
const FILES: { file: string; name: string; weight: Font['weight'] }[] = [
{ file: 'inter-400.woff', name: SANS, weight: 400 },
{ file: 'inter-600.woff', name: SANS, weight: 600 },
{ file: 'inter-700.woff', name: SANS, weight: 700 },
{ file: 'source-serif-600.woff', name: SERIF, weight: 600 },
];
let cached: Font[] | null = null;
export async function loadScreenFonts(): Promise<Font[]> {
if (cached) {
return cached;
}
const directory = path.join(process.cwd(), 'public', 'fonts');
cached = await Promise.all(
FILES.map(async ({ file, name, weight }) => ({
name,
data: await readFile(path.join(directory, file)),
weight,
style: 'normal' as const,
})),
);
return cached;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { h } from './h';
describe('h', () => {
it('omits children entirely when there are none', () => {
expect(h('div', { style: {} })).toEqual({ type: 'div', props: { style: {} } });
});
it('accepts null props', () => {
expect(h('div', null, 'text')).toEqual({ type: 'div', props: { children: 'text' } });
});
it('unwraps a single child', () => {
expect(h('div', null, 'only').props.children).toBe('only');
});
it('keeps several children as a list', () => {
expect(h('div', null, 'a', 'b').props.children).toEqual(['a', 'b']);
});
it('flattens nested arrays and drops empty branches', () => {
// `condition && node` and `condition ? node : null` are the two shapes the
// renderer uses to leave a block out; neither may reach satori.
expect(h('div', null, ['a', ['b', null]], false, null, 'c').props.children).toEqual([
'a',
'b',
'c',
]);
});
it('keeps a zero, which is a legitimate child', () => {
expect(h('div', null, 0).props.children).toBe(0);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* A minimal element factory for satori.
*
* satori consumes `{ type, props }` trees, which is exactly what JSX would
* produce. Building them directly keeps the renderer out of the JSX transform
* entirely: no pragma, no React import, and the same file runs unchanged under
* Vitest, under `next build` and under the preview script.
*/
export type ElementNode = {
type: string;
props: Record<string, unknown> & { children?: ElementChild };
};
export type ElementChild = ElementNode | string | number | null | false | ElementChild[];
export function h(
type: string,
props: Record<string, unknown> | null,
...children: ElementChild[]
): ElementNode {
const flat = children.flat(Infinity as 1).filter((child) => child !== null && child !== false);
return {
type,
props: {
...(props ?? {}),
...(flat.length > 0 ? { children: flat.length === 1 ? flat[0] : flat } : {}),
},
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Screen geometry and typography, in one place.
*
* The panel is 800x480 and strictly 1-bit: there is no grey to lean on, so
* hierarchy has to come from size, weight and solid inversion. Nothing here
* may introduce a shadow, a gradient or a hairline — they dither into mud on
* e-ink and cost a redraw for nothing.
*/
export const SCREEN_WIDTH = 800;
export const SCREEN_HEIGHT = 480;
export const BLACK = '#000000';
export const WHITE = '#ffffff';
export const PADDING = 24;
export const HEADER_HEIGHT = 76;
export const WEEK_HEIGHT = 112;
export const BANNER_HEIGHT = 84;
/** Solid rules only; anything thinner than 2px breaks up on e-ink. */
export const RULE = 3;
export const SANS = 'Inter';
export const SERIF = 'Source Serif 4';
+32
View File
@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
/**
* The 1-bit wordmark, inlined as a data URI.
*
* It is read from the committed `public/brand/logo-eink.png` and embedded in
* the SVG rather than linked: the renderer must not depend on the network, and
* an image the rasteriser silently failed to fetch would leave a hole in the
* shop window. Regenerate the file with `npm run brand`.
*/
/** Matches the committed asset; see scripts/brand.ts. */
export const LOGO_WIDTH = 260;
export const LOGO_HEIGHT = 36;
let cached: string | null | undefined;
export async function loadLogoDataUri(): Promise<string | null> {
if (cached !== undefined) {
return cached;
}
try {
const file = path.join(process.cwd(), 'public', 'brand', 'logo-eink.png');
cached = `data:image/png;base64,${(await readFile(file)).toString('base64')}`;
} catch {
// Not fatal: the header falls back to the shop name set in the settings,
// which is worse-looking but still a working screen.
cached = null;
}
return cached;
}
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import { SCREEN_HEIGHT, SCREEN_WIDTH } from './layout';
import { encodeScreen } from './encode';
import { renderScreenSvg, screenTree } from './render';
import type { ScreenPayload } from './contract';
const PAYLOAD: ScreenPayload = {
schema: 1,
generated_at: '2026-09-22T12:00:00+02:00',
shop_name: 'ITA ITO',
logo_url: 'https://trmnl.loxi.ch/brand/logo-eink.png',
today: {
date_fr: 'Mardi 22 septembre',
date_en: 'Tuesday 22 September',
status: 'OPEN',
status_fr: 'OUVERT',
status_en: 'OPEN',
hours_text: '10:00 – 13:00 · 14:00 – 18:30',
next_change_fr: 'Ferme à 13:00',
next_change_en: 'Closes at 1:00 pm',
is_exception: false,
},
week: [
{ label_fr: 'Lun', label_en: 'Mon', hours_text: '', is_today: false, is_closed: true, is_exception: false },
{ label_fr: 'Mar', label_en: 'Tue', hours_text: '10:00 – 18:30', is_today: true, is_closed: false, is_exception: false },
{ label_fr: 'Mer', label_en: 'Wed', hours_text: '10:00 – 13:00 · 14:00 – 18:30', is_today: false, is_closed: false, is_exception: false },
{ label_fr: 'Jeu', label_en: 'Thu', hours_text: '14:00 – 18:30', is_today: false, is_closed: false, is_exception: true },
{ label_fr: 'Ven', label_en: 'Fri', hours_text: '', is_today: false, is_closed: true, is_exception: true },
{ label_fr: 'Sam', label_en: 'Sat', hours_text: '10:00 – 18:30', is_today: false, is_closed: false, is_exception: false },
{ label_fr: 'Dim', label_en: 'Sun', hours_text: '', is_today: false, is_closed: true, is_exception: false },
],
banner: {
kind: 'MESSAGE',
text_fr: 'Fermeture exceptionnelle jeudi après-midi',
text_en: 'Exceptionally closed on Thursday afternoon',
},
};
describe('screenTree', () => {
it('drops the banner block entirely when there is nothing to say', () => {
const withBanner = JSON.stringify(screenTree(PAYLOAD));
const without = JSON.stringify(screenTree({ ...PAYLOAD, banner: null }));
expect(withBanner).toContain('Fermeture exceptionnelle jeudi après-midi');
expect(without).not.toContain('Fermeture');
// The week block takes the freed room rather than leaving a white gap.
expect(without.length).toBeLessThan(withBanner.length);
});
it('places every key label in the drawing', () => {
// satori vectorises text into paths, so the strings are only observable
// here, on the element tree it is handed.
const tree = JSON.stringify(screenTree(PAYLOAD));
for (const label of [
'ITA ITO',
'Mardi 22 septembre',
'OUVERT',
'10:00 – 13:00 · 14:00 – 18:30',
'Ferme à 13:00',
'Closes at 1:00 pm',
'Lun',
'Jeu*',
'Dim',
]) {
expect(tree).toContain(label);
}
});
it('leaves no value unrendered', () => {
// A dropped or mistyped field surfaces as one of these in the drawing
// rather than as a crash, so they are worth failing the build over.
// (Liquid-style {{ }} holes are not checked: there is no template engine
// left in this pipeline, and JSON braces would match them anyway.)
expect(JSON.stringify(screenTree(PAYLOAD))).not.toMatch(
/undefined|\[object Object\]|NaN|null,null/,
);
});
it('drops the separator when only one language has something to say', () => {
const frenchOnly = JSON.stringify(
screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, next_change_en: '' } }),
);
expect(frenchOnly).toContain('Ferme à 13:00');
expect(frenchOnly).not.toContain('"·"');
const englishOnly = JSON.stringify(
screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, next_change_fr: '' } }),
);
expect(englishOnly).toContain('Closes at 1:00 pm');
expect(englishOnly).not.toContain('"·"');
});
it('drops the whole next-change line when there is nothing ahead', () => {
const tree = JSON.stringify(
screenTree({
...PAYLOAD,
today: { ...PAYLOAD.today, next_change_fr: '', next_change_en: '' },
}),
);
// Checked against the exact strings: the banner legitimately starts with
// "Fermeture", which a looser assertion would trip over.
expect(tree).not.toContain('Ferme à 13:00');
expect(tree).not.toContain('Closes at 1:00 pm');
});
it('drops the English banner line when the translation is missing', () => {
const tree = JSON.stringify(
screenTree({ ...PAYLOAD, banner: { ...PAYLOAD.banner!, text_en: '' } }),
);
expect(tree).toContain('Fermeture exceptionnelle jeudi après-midi');
expect(tree).not.toContain('Exceptionally');
});
it('shows the wordmark when one is available, and the shop name otherwise', () => {
const withLogo = JSON.stringify(screenTree(PAYLOAD, 'data:image/png;base64,AAAA'));
expect(withLogo).toContain('"img"');
expect(withLogo).toContain('data:image/png;base64,AAAA');
// The fallback keeps the panel working if the asset ever goes missing.
const withoutLogo = JSON.stringify(screenTree(PAYLOAD, null));
expect(withoutLogo).not.toContain('"img"');
expect(withoutLogo).toContain('ITA ITO');
});
it('omits the hours line on a day with no hours', () => {
const tree = JSON.stringify(
screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, hours_text: '' } }),
);
expect(tree).not.toContain('10:00 – 13:00 · 14:00 – 18:30');
});
});
describe('renderScreenSvg', () => {
it('renders exactly 800x480', async () => {
const svg = await renderScreenSvg(PAYLOAD);
expect(svg).toContain(`width="${SCREEN_WIDTH}"`);
expect(svg).toContain(`height="${SCREEN_HEIGHT}"`);
});
it('uses no grey, no gradient and no shadow', async () => {
const svg = await renderScreenSvg(PAYLOAD);
expect(svg).not.toMatch(/linearGradient|radialGradient|feGaussianBlur|filter=/);
// Only pure black and pure white may appear, in either hex spelling.
const normalise = (colour: string): string =>
/^#[0-9a-f]{3}$/i.test(colour)
? `#${[...colour.slice(1)].map((digit) => digit + digit).join('')}`.toLowerCase()
: colour.toLowerCase();
const paints = [...svg.matchAll(/(?:fill|stroke)="([^"]+)"/g)].map((match) =>
normalise(match[1] ?? ''),
);
const unexpected = paints.filter(
(paint) => !['#000000', '#ffffff', 'none', 'transparent'].includes(paint),
);
expect(unexpected).toEqual([]);
});
});
describe('encodeScreen', () => {
it('produces a 1-bit BMP of the exact panel size', async () => {
const bmp = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp');
expect(bmp.subarray(0, 2).toString('ascii')).toBe('BM');
expect(bmp.readInt32LE(18)).toBe(SCREEN_WIDTH);
expect(bmp.readInt32LE(22)).toBe(SCREEN_HEIGHT);
expect(bmp.readUInt16LE(28)).toBe(1);
// 800x480 at one bit, plus headers: comfortably inside any firmware limit.
expect(bmp.length).toBe(62 + 100 * 480);
});
it('produces a 1-bit PNG of the exact panel size', async () => {
const png = encodeScreen(await renderScreenSvg(PAYLOAD), 'png');
expect(png.readUInt32BE(16)).toBe(SCREEN_WIDTH);
expect(png.readUInt32BE(20)).toBe(SCREEN_HEIGHT);
expect(png.readUInt8(24)).toBe(1);
// Compressed, so it must come out far smaller than the raw bitmap.
expect(png.length).toBeLessThan(62 + 100 * 480);
});
it('is byte-stable for the same payload', async () => {
// The device skips the redraw when the filename is unchanged, and the
// filename is a hash of these bytes. Instability would wake the panel for
// nothing, every single cycle.
const first = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp');
const second = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp');
expect(first.equals(second)).toBe(true);
});
it('refuses to hand the device an image of the wrong size', () => {
// Better a loud failure here than a panel that silently draws nothing.
const square = '<svg width="10" height="10" xmlns="http://www.w3.org/2000/svg"></svg>';
expect(() => encodeScreen(square, 'bmp')).toThrow(/expected 800x480/);
});
it('passes the invert option through to the bit packing', async () => {
const svg = await renderScreenSvg(PAYLOAD);
const normal = encodeScreen(svg, 'bmp');
const inverted = encodeScreen(svg, 'bmp', { invert: true });
expect(normal.equals(inverted)).toBe(false);
// Only the pixels differ; the headers are identical.
expect(normal.subarray(0, 62).equals(inverted.subarray(0, 62))).toBe(true);
});
it('changes when the hours change', async () => {
const before = encodeScreen(await renderScreenSvg(PAYLOAD), 'bmp');
const after = encodeScreen(
await renderScreenSvg({
...PAYLOAD,
today: { ...PAYLOAD.today, hours_text: '14:00 – 18:30' },
}),
'bmp',
);
expect(before.equals(after)).toBe(false);
});
});
+213
View File
@@ -0,0 +1,213 @@
/**
* Draws the screen payload as an 800x480 SVG.
*
* This module draws and nothing else. Every string it places has already been
* computed and translated by the view model, so there is no business logic to
* disagree about — which is also why its output can be snapshotted as text
* rather than compared as pixels.
*
* e-ink discipline: no greys, no shadows, no gradients, no hairlines.
* Hierarchy comes from size, weight and solid inversion.
*/
import satori from 'satori';
import type { ScreenPayload, ScreenWeekDay } from './contract';
import { loadScreenFonts } from './fonts';
import { h, type ElementNode } from './h';
import { LOGO_HEIGHT, LOGO_WIDTH, loadLogoDataUri } from './logo';
import {
BANNER_HEIGHT,
BLACK,
HEADER_HEIGHT,
PADDING,
RULE,
SANS,
SCREEN_HEIGHT,
SCREEN_WIDTH,
SERIF,
WEEK_HEIGHT,
WHITE,
} from './layout';
export function screenTree(payload: ScreenPayload, logo: string | null = null): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
backgroundColor: WHITE,
color: BLACK,
fontFamily: SANS,
},
},
header(payload, logo),
main(payload),
weekStrip(payload.week),
banner(payload),
);
}
function header(payload: ScreenPayload, logo: string | null): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: HEADER_HEIGHT,
paddingLeft: PADDING,
paddingRight: PADDING,
borderBottom: `${RULE}px solid ${BLACK}`,
},
},
logo
? h('img', {
src: logo,
width: LOGO_WIDTH,
height: LOGO_HEIGHT,
style: { display: 'flex' },
})
: h(
'div',
{ style: { display: 'flex', fontFamily: SERIF, fontSize: 34, letterSpacing: 6 } },
payload.shop_name,
),
h('div', { style: { display: 'flex', fontSize: 22 } }, payload.today.date_fr),
);
}
function main(payload: ScreenPayload): ElementNode {
const { today } = payload;
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
justifyContent: 'center',
paddingLeft: PADDING,
paddingRight: PADDING,
},
},
h(
'div',
{ style: { display: 'flex', fontSize: 92, fontWeight: 700, lineHeight: 1 } },
today.status_fr,
),
today.hours_text
? h(
'div',
{ style: { display: 'flex', fontSize: 36, fontWeight: 600, marginTop: 10 } },
today.hours_text,
)
: null,
nextChangeLine(payload),
);
}
function nextChangeLine(payload: ScreenPayload): ElementNode | null {
const { next_change_fr: fr, next_change_en: en } = payload.today;
if (!fr && !en) {
return null;
}
return h(
'div',
{ style: { display: 'flex', alignItems: 'baseline', marginTop: 12 } },
fr ? h('div', { style: { display: 'flex', fontSize: 24 } }, fr) : null,
fr && en ? h('div', { style: { display: 'flex', fontSize: 24, paddingLeft: 10, paddingRight: 10 } }, '·') : null,
en ? h('div', { style: { display: 'flex', fontSize: 20 } }, en) : null,
);
}
function weekStrip(week: ScreenWeekDay[]): ElementNode {
return h(
'div',
{
style: {
display: 'flex',
height: WEEK_HEIGHT,
borderTop: `${RULE}px solid ${BLACK}`,
},
},
...week.map((day) => weekColumn(day)),
);
}
function weekColumn(day: ScreenWeekDay): ElementNode {
// Today is inverted rather than tinted: a solid block is the only emphasis
// a 1-bit panel renders cleanly.
const inverted = day.is_today;
// A column is only 114px wide. The spaced en dash reads better in the main
// block, but here it is the difference between fitting and clipping.
const lines = day.is_closed
? ['—']
: day.hours_text.split(' · ').map((slot) => slot.replace(' – ', '–'));
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flexGrow: 1,
flexBasis: 0,
paddingTop: 8,
paddingBottom: 8,
paddingLeft: 6,
paddingRight: 6,
backgroundColor: inverted ? BLACK : WHITE,
color: inverted ? WHITE : BLACK,
},
},
h(
'div',
{ style: { display: 'flex', fontSize: 19, fontWeight: 700, marginBottom: 6 } },
`${day.label_fr}${day.is_exception ? '*' : ''}`,
),
...lines.map((line) =>
h('div', { style: { display: 'flex', fontSize: 16, lineHeight: 1.35 } }, line),
),
);
}
function banner(payload: ScreenPayload): ElementNode | null {
if (!payload.banner) {
return null;
}
const { text_fr: fr, text_en: en } = payload.banner;
return h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
height: BANNER_HEIGHT,
paddingLeft: PADDING,
paddingRight: PADDING,
backgroundColor: BLACK,
color: WHITE,
},
},
h('div', { style: { display: 'flex', fontSize: 25, fontWeight: 600 } }, fr),
en ? h('div', { style: { display: 'flex', fontSize: 19, marginTop: 4 } }, en) : null,
);
}
/** Renders the payload to an SVG string, exactly 800x480. */
export async function renderScreenSvg(payload: ScreenPayload): Promise<string> {
const [fonts, logo] = await Promise.all([loadScreenFonts(), loadLogoDataUri()]);
return satori(screenTree(payload, logo) as unknown as React.ReactNode, {
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
fonts,
});
}
+52 -2
View File
@@ -28,6 +28,8 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
@@ -40,6 +42,7 @@
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prisma": "^7.10.0",
"sharp": "^0.35.4",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
@@ -415,6 +418,7 @@
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1111,6 +1115,26 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/source-serif-4": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/source-serif-4/-/source-serif-4-5.3.0.tgz",
"integrity": "sha512-yQz8xmIgMzks8zQbpuca3UYtjxK798XCyQAGdDXmzBgvV7sdoK6d0YvE9F9kiYxORCEMRBgGZDZsSYcXj5KvwA==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1181,8 +1205,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"devOptional": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -1194,6 +1218,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1216,6 +1241,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1235,6 +1261,7 @@
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1257,6 +1284,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1273,6 +1301,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1289,6 +1318,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1305,6 +1335,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1321,6 +1352,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1337,6 +1369,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1353,6 +1386,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1369,6 +1403,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1385,6 +1420,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1401,6 +1437,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1417,6 +1454,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1439,6 +1477,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1461,6 +1500,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1483,6 +1523,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1505,6 +1546,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1527,6 +1569,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1549,6 +1592,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1571,6 +1615,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1590,6 +1635,7 @@
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -1609,6 +1655,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
@@ -1628,6 +1675,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1647,6 +1695,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -1666,6 +1715,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -9717,8 +9767,8 @@
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"devOptional": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
+3
View File
@@ -40,6 +40,8 @@
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/source-serif-4": "^5.3.0",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "^26.6.2",
"@types/node-cron": "^3.0.11",
@@ -52,6 +54,7 @@
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prisma": "^7.10.0",
"sharp": "^0.35.4",
"tailwindcss": "^4.3.3",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

+20
View File
@@ -0,0 +1,20 @@
# Fonts used to render the e-ink screen
These four files are committed on purpose.
The panel image is rendered server-side by satori, which needs the font
binaries at render time. Reading them from `node_modules` would make the
rendering depend on the install tree, and on what the production image happens
to trace; reading them from a CDN would make it depend on the network. Both
would let the rendered pixels drift, and the rendered pixels feed a content
hash that decides whether the panel redraws at all.
So the exact binaries live here, next to the code that uses them.
| File | Source | Licence |
|---|---|---|
| `inter-400.woff`, `inter-600.woff`, `inter-700.woff` | `@fontsource/inter` (Inter, Rasmus Andersson) | SIL Open Font License 1.1 |
| `source-serif-600.woff` | `@fontsource/source-serif-4` (Source Serif 4, Adobe) | SIL Open Font License 1.1 |
Both licences permit redistribution. To refresh them, re-copy from
`node_modules/@fontsource/*/files/*-latin-*-normal.woff`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+112
View File
@@ -0,0 +1,112 @@
/**
* Prepares the ITA ITO brand assets.
*
* npm run brand
*
* Downloads the shop logo once and writes two files into `public/brand`, both
* of which are committed:
*
* logo.png the original, for the admin interface
* logo-eink.png the wordmark alone, 1-bit and thresholded, for the panel
*
* The outputs are committed on purpose. Rendering must not depend on a
* Shopify CDN being reachable, and the panel image feeds a content hash that
* decides whether the screen redraws — so the bytes have to be stable.
*
* `sharp` is a devDependency used only here. Nothing at runtime needs it.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { encodePng, toBitmap } from '../lib/screen/encode';
const LOGO_URL =
process.env.BRAND_LOGO_URL ?? 'https://ita-ito.com/cdn/shop/files/ITA_ITO_knitwear_logo.png';
/** Wide enough to read from the pavement, short enough for a 76px header. */
const EINK_WIDTH = 260;
async function main(): Promise<void> {
const directory = path.join(process.cwd(), 'public', 'brand');
await mkdir(directory, { recursive: true });
const response = await fetch(LOGO_URL);
if (!response.ok) {
throw new Error(`Could not fetch the logo: ${response.status} ${response.statusText}`);
}
const original = Buffer.from(await response.arrayBuffer());
await writeFile(path.join(directory, 'logo.png'), original);
const band = await findWordmarkBand(original);
const cropped = await sharp(original)
.extract({ left: 0, top: band.top, width: band.width, height: band.height })
.resize({ width: EINK_WIDTH })
.flatten({ background: '#ffffff' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
// Reuse the panel's own encoder, so the logo is thresholded by exactly the
// rule the rest of the screen is. A line drawing gets no dithering: it would
// only fuzz the serifs.
const bitmap = toBitmap(cropped.data, cropped.info.width, cropped.info.height);
await writeFile(path.join(directory, 'logo-eink.png'), encodePng(bitmap));
console.log(`logo.png ${original.length} bytes (original)`);
console.log(`logo-eink.png ${cropped.info.width}x${cropped.info.height}, 1-bit`);
console.log('Both are committed; the CDN is never read at runtime.');
}
/**
* The logo stacks a wordmark, a two-line tagline and a drawn motif. Only the
* wordmark survives at panel size, so find its band rather than hard-coding
* pixel offsets that a future logo revision would silently break.
*/
async function findWordmarkBand(
source: Buffer,
): Promise<{ top: number; height: number; width: number }> {
const { data, info } = await sharp(source)
.flatten({ background: '#ffffff' })
.greyscale()
.raw()
.toBuffer({ resolveWithObject: true });
const bands: { top: number; bottom: number }[] = [];
let start: number | null = null;
for (let y = 0; y < info.height; y += 1) {
let ink = 0;
for (let x = 0; x < info.width; x += 1) {
if ((data[y * info.width + x] ?? 255) < 128) {
ink += 1;
}
}
if (ink > 0 && start === null) {
start = y;
} else if (ink === 0 && start !== null) {
bands.push({ top: start, bottom: y - 1 });
start = null;
}
}
if (start !== null) {
bands.push({ top: start, bottom: info.height - 1 });
}
const wordmark = bands[0];
if (!wordmark) {
throw new Error('Found no ink in the logo; is the download correct?');
}
// A couple of pixels of air, so the thresholding cannot clip a serif.
const top = Math.max(0, wordmark.top - 2);
const bottom = Math.min(info.height - 1, wordmark.bottom + 2);
return { top, height: bottom - top + 1, width: info.width };
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Renders the panel locally, so the layout can be iterated on without a device.
*
* npm run screen:preview
*
* Writes `.preview/screen.svg`, `.preview/screen.bmp`, `.preview/screen.png`
* and an `index.html` that shows the real 1-bit image at 1:1 next to a
* magnified view, which is where clipping and thin strokes give themselves up.
*/
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { ScreenPayload } from '../lib/screen/contract';
import { encodeScreen } from '../lib/screen/encode';
import { SCREEN_HEIGHT, SCREEN_WIDTH } from '../lib/screen/layout';
import { renderScreenSvg } from '../lib/screen/render';
import { buildScreenPayload } from '../lib/screen/viewmodel';
import type { ScheduleContext } from '../lib/schedule/types';
const ZURICH = 'Europe/Zurich';
/** A deliberately busy week: a lunch break, a late opening and a holiday. */
const CONTEXT: ScheduleContext = {
timezone: ZURICH,
weekly: [
{ dayOfWeek: 0, isClosed: true, slots: [] },
{ dayOfWeek: 1, isClosed: true, slots: [] },
{ dayOfWeek: 2, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
{
dayOfWeek: 3,
isClosed: false,
slots: [
{ open: '10:00', close: '13:00' },
{ open: '14:00', close: '18:30' },
],
},
{ dayOfWeek: 4, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
{ dayOfWeek: 5, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
{ dayOfWeek: 6, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
],
exceptions: [
{
date: '2026-09-24',
isClosed: false,
slots: [{ open: '14:00', close: '18:30' }],
reason: 'TEMPORARY',
noteFr: 'Ouverture retardée',
noteEn: 'Late opening',
source: 'MANUAL',
},
],
vacations: [],
holidays: [
{ date: '2026-09-25', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', isAutoClosed: true },
],
};
function payload(): ScreenPayload {
return buildScreenPayload({
now: new Date('2026-09-23T10:30:00Z'),
ctx: CONTEXT,
shopName: 'ITA ITO',
logoUrl: 'https://trmnl.loxi.ch/brand/logo-eink.png',
messages: [
{
textFr: 'Fermeture exceptionnelle jeudi matin',
textEn: 'Exceptionally closed on Thursday morning',
startsAt: null,
endsAt: null,
priority: 0,
isActive: true,
},
],
});
}
async function main(): Promise<void> {
const directory = path.join(process.cwd(), '.preview');
await mkdir(directory, { recursive: true });
const screen = payload();
const svg = await renderScreenSvg(screen);
await writeFile(path.join(directory, 'screen.svg'), svg);
await writeFile(path.join(directory, 'screen.bmp'), encodeScreen(svg, 'bmp'));
await writeFile(path.join(directory, 'screen.png'), encodeScreen(svg, 'png'));
await writeFile(path.join(directory, 'payload.json'), `${JSON.stringify(screen, null, 2)}\n`);
await writeFile(path.join(directory, 'index.html'), page());
console.log(`Preview written to ${path.relative(process.cwd(), directory)}/index.html`);
console.log('Open it in a browser, or look at screen.png directly.');
}
function page(): string {
return `<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Aperçu écran — ITA ITO</title>
<style>
body { margin: 0; padding: 32px; background: #f4f4f2; color: #141413;
font: 15px/1.5 -apple-system, system-ui, sans-serif; }
h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
p { margin: 0 0 24px; color: #5e5d59; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
color: #5e5d59; margin: 32px 0 8px; }
img { display: block; background: #fff; border: 1px solid #d6d3c7;
image-rendering: pixelated; }
.one-to-one img { width: ${SCREEN_WIDTH}px; height: ${SCREEN_HEIGHT}px; }
.magnified img { width: ${SCREEN_WIDTH * 2}px; height: ${SCREEN_HEIGHT * 2}px; }
</style>
</head>
<body>
<h1>Aperçu de l'écran — ${SCREEN_WIDTH} × ${SCREEN_HEIGHT}, 1 bit</h1>
<p>Image réelle envoyée à l'appareil, après seuillage. Aucun gris n'est possible ici.</p>
<h2>Taille réelle</h2>
<div class="one-to-one"><img src="screen.png" alt="Aperçu à l'échelle 1:1"></div>
<h2>Agrandi ×2 — pour repérer les coupures et les traits trop fins</h2>
<div class="magnified"><img src="screen.png" alt="Aperçu agrandi"></div>
</body>
</html>
`;
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});