A bottom strip gave each day a 114px column, and that width was what capped the type size. A side band gives 142px of usable width and a 68px row, so the hours go from 19pt to 22pt, the day labels from 20 to 23, and each day fits on one line — "Mar 11:00–18:00" reads at a glance where a column had to be read downwards. Right rather than left, for two reasons the renders make plain: on the left the logo is pushed into the middle and loses its place as a signature, and the eye meets the week before the day's status. OUVERT is what has to be read first from the pavement. The times stay whole. Only the spaces around the dash go, which is enough to hold 22pt without abbreviating anything: a shop's opening hour is not a detail to shorten. The idea came from the shop owner, and testing it surfaced a defect that had been in production all along. Rendering the worst case the data model allows — three periods in a day, none on a round hour — showed the day's hours wrapping, pushing the layout down, and the English line of the banner falling off the bottom edge. A panel does not scroll and does not reflow: what does not fit is lost. Type size is now fitted to the space available, with a floor below which it will not go, and nothing may leave its box. The width estimate is deliberately crude: measuring glyphs would mean loading the font in that module and would still be an estimate, since the renderer applies its own kerning. Erring small costs a point of type, erring large costs a broken screen. Three periods a day will reportedly never happen. The protection stays anyway — it costs nothing and removes the need to remember. `npm run screen:fonts` renders every arrangement and both alternative typefaces, including the worst case, because choosing type for a 1-bit panel is done by looking at it thresholded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
62 lines
1.8 KiB
TypeScript
62 lines
1.8 KiB
TypeScript
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.
|
|
*/
|
|
/**
|
|
* The sans families the panel can be set in.
|
|
*
|
|
* More than one exists because choosing a typeface for a 1-bit panel is done
|
|
* by looking at it thresholded, not by reading its specimen: anti-aliasing is
|
|
* what most screen faces rely on, and there is none here.
|
|
*/
|
|
export const SANS_FAMILIES = {
|
|
inter: ['inter-400.woff', 'inter-600.woff', 'inter-700.woff'],
|
|
atkinson: ['atkinson-400.woff', 'atkinson-700.woff', 'atkinson-700.woff'],
|
|
plex: ['plex-400.woff', 'plex-600.woff', 'plex-700.woff'],
|
|
} as const;
|
|
|
|
export type SansFamily = keyof typeof SANS_FAMILIES;
|
|
|
|
const WEIGHTS: Font['weight'][] = [400, 600, 700];
|
|
|
|
const cache = new Map<SansFamily, Font[]>();
|
|
|
|
export async function loadScreenFonts(family: SansFamily = 'inter'): Promise<Font[]> {
|
|
const hit = cache.get(family);
|
|
if (hit) {
|
|
return hit;
|
|
}
|
|
|
|
const directory = path.join(process.cwd(), 'public', 'fonts');
|
|
const files = SANS_FAMILIES[family];
|
|
|
|
const fonts = await Promise.all([
|
|
...files.map(async (file, index) => ({
|
|
name: SANS,
|
|
data: await readFile(path.join(directory, file)),
|
|
weight: WEIGHTS[index] ?? 400,
|
|
style: 'normal' as const,
|
|
})),
|
|
(async () => ({
|
|
name: SERIF,
|
|
data: await readFile(path.join(directory, 'source-serif-600.woff')),
|
|
weight: 600 as Font['weight'],
|
|
style: 'normal' as const,
|
|
}))(),
|
|
]);
|
|
|
|
cache.set(family, fonts);
|
|
return fonts;
|
|
}
|