diff --git a/lib/screen/encode.test.ts b/lib/screen/encode.test.ts new file mode 100644 index 0000000..c431583 --- /dev/null +++ b/lib/screen/encode.test.ts @@ -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)); + }); +}); diff --git a/lib/screen/encode.ts b/lib/screen/encode.ts new file mode 100644 index 0000000..dd45e32 --- /dev/null +++ b/lib/screen/encode.ts @@ -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); +} diff --git a/lib/screen/fonts.ts b/lib/screen/fonts.ts new file mode 100644 index 0000000..0c46c1e --- /dev/null +++ b/lib/screen/fonts.ts @@ -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 { + 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; +} diff --git a/lib/screen/h.test.ts b/lib/screen/h.test.ts new file mode 100644 index 0000000..6612370 --- /dev/null +++ b/lib/screen/h.test.ts @@ -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); + }); +}); diff --git a/lib/screen/h.ts b/lib/screen/h.ts new file mode 100644 index 0000000..9986a1c --- /dev/null +++ b/lib/screen/h.ts @@ -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 & { children?: ElementChild }; +}; + +export type ElementChild = ElementNode | string | number | null | false | ElementChild[]; + +export function h( + type: string, + props: Record | 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 } : {}), + }, + }; +} diff --git a/lib/screen/layout.ts b/lib/screen/layout.ts new file mode 100644 index 0000000..5286ba5 --- /dev/null +++ b/lib/screen/layout.ts @@ -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'; diff --git a/lib/screen/logo.ts b/lib/screen/logo.ts new file mode 100644 index 0000000..96a55cb --- /dev/null +++ b/lib/screen/logo.ts @@ -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 { + 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; +} diff --git a/lib/screen/render.test.ts b/lib/screen/render.test.ts new file mode 100644 index 0000000..8b4150f --- /dev/null +++ b/lib/screen/render.test.ts @@ -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 = ''; + 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); + }); +}); diff --git a/lib/screen/render.ts b/lib/screen/render.ts new file mode 100644 index 0000000..2afad75 --- /dev/null +++ b/lib/screen/render.ts @@ -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 { + 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, + }); +} diff --git a/package-lock.json b/package-lock.json index 8e08996..32bf1ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d7e8158..e948632 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/brand/logo-eink.png b/public/brand/logo-eink.png new file mode 100644 index 0000000..7f56643 Binary files /dev/null and b/public/brand/logo-eink.png differ diff --git a/public/brand/logo.png b/public/brand/logo.png new file mode 100644 index 0000000..2d646f1 Binary files /dev/null and b/public/brand/logo.png differ diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..6dc6788 --- /dev/null +++ b/public/fonts/README.md @@ -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`. diff --git a/public/fonts/inter-400.woff b/public/fonts/inter-400.woff new file mode 100644 index 0000000..2f21ed4 Binary files /dev/null and b/public/fonts/inter-400.woff differ diff --git a/public/fonts/inter-600.woff b/public/fonts/inter-600.woff new file mode 100644 index 0000000..323fa67 Binary files /dev/null and b/public/fonts/inter-600.woff differ diff --git a/public/fonts/inter-700.woff b/public/fonts/inter-700.woff new file mode 100644 index 0000000..161b01e Binary files /dev/null and b/public/fonts/inter-700.woff differ diff --git a/public/fonts/source-serif-600.woff b/public/fonts/source-serif-600.woff new file mode 100644 index 0000000..2e64ea6 Binary files /dev/null and b/public/fonts/source-serif-600.woff differ diff --git a/scripts/brand.ts b/scripts/brand.ts new file mode 100644 index 0000000..733f899 --- /dev/null +++ b/scripts/brand.ts @@ -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 { + 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; +}); diff --git a/scripts/preview.ts b/scripts/preview.ts new file mode 100644 index 0000000..0807bcc --- /dev/null +++ b/scripts/preview.ts @@ -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 { + 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 ` + + + +Aperçu écran — ITA ITO + + + +

Aperçu de l'écran — ${SCREEN_WIDTH} × ${SCREEN_HEIGHT}, 1 bit

+

Image réelle envoyée à l'appareil, après seuillage. Aucun gris n'est possible ici.

+

Taille réelle

+
Aperçu à l'échelle 1:1
+

Agrandi ×2 — pour repérer les coupures et les traits trop fins

+
Aperçu agrandi
+ + +`; +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +});