diff --git a/lib/screen/fit.test.ts b/lib/screen/fit.test.ts new file mode 100644 index 0000000..c3f5774 --- /dev/null +++ b/lib/screen/fit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { fitFontSize } from './fit'; + +const options = { available: 500, preferred: 36, minimum: 20 }; + +describe('fitFontSize', () => { + it('keeps the preferred size when the text is short', () => { + expect(fitFontSize('10:00 – 18:30', options)).toBe(36); + }); + + it('shrinks a long line rather than letting it wrap', () => { + // Three periods, none on a round hour: the case that overflowed. + const crowded = '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30'; + const size = fitFontSize(crowded, options); + expect(size).toBeLessThan(36); + expect(size * crowded.length * 0.54).toBeLessThanOrEqual(options.available); + }); + + it('never goes below the floor, however long the text', () => { + expect(fitFontSize('a'.repeat(500), options)).toBe(20); + }); + + it('keeps the preferred size for empty text', () => { + expect(fitFontSize('', options)).toBe(36); + }); + + it('returns whole pixels', () => { + expect(Number.isInteger(fitFontSize('10:00 – 13:00 · 14:00', options))).toBe(true); + }); + + it('gives more room a larger size', () => { + const narrow = fitFontSize('10:00 – 13:00 · 14:00 – 18:30', { ...options, available: 300 }); + const wide = fitFontSize('10:00 – 13:00 · 14:00 – 18:30', { ...options, available: 700 }); + expect(wide).toBeGreaterThan(narrow); + }); +}); diff --git a/lib/screen/fit.ts b/lib/screen/fit.ts new file mode 100644 index 0000000..3a8eeb9 --- /dev/null +++ b/lib/screen/fit.ts @@ -0,0 +1,34 @@ +/** + * Choosing a type size that fits the space available. + * + * The panel cannot scroll and cannot reflow gracefully: text that does not fit + * is text that spills over a neighbour or falls off the bottom edge. The shop + * can legitimately enter three opening periods in a day, none of them on a + * round hour, and write a long message on top — a combination that overflowed + * both the week strip and the bottom banner before this existed. + * + * The width estimate is deliberately crude. Measuring glyphs properly would + * mean loading the font here and would still be an estimate, because the + * renderer applies its own kerning. Erring small costs a point of type; erring + * large costs a broken screen. + */ + +/** Average glyph width as a fraction of the type size, for Inter's digits and lowercase. */ +const WIDTH_RATIO = 0.54; + +export type FitOptions = { + /** The width the text has to live in, in pixels. */ + available: number; + /** The size to use when the text is short enough. */ + preferred: number; + /** Never go below this: smaller stops being readable from the pavement. */ + minimum: number; +}; + +export function fitFontSize(text: string, { available, preferred, minimum }: FitOptions): number { + if (text.length === 0) { + return preferred; + } + const fitting = available / (text.length * WIDTH_RATIO); + return Math.max(minimum, Math.min(preferred, Math.floor(fitting))); +} diff --git a/lib/screen/fonts.ts b/lib/screen/fonts.ts index 0c46c1e..998e32d 100644 --- a/lib/screen/fonts.ts +++ b/lib/screen/fonts.ts @@ -13,27 +13,49 @@ import { SANS, SERIF } from './layout'; * 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 }, -]; +/** + * 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; -let cached: Font[] | null = null; +export type SansFamily = keyof typeof SANS_FAMILIES; -export async function loadScreenFonts(): Promise { - if (cached) { - return cached; +const WEIGHTS: Font['weight'][] = [400, 600, 700]; + +const cache = new Map(); + +export async function loadScreenFonts(family: SansFamily = 'inter'): Promise { + const hit = cache.get(family); + if (hit) { + return hit; } + const directory = path.join(process.cwd(), 'public', 'fonts'); - cached = await Promise.all( - FILES.map(async ({ file, name, weight }) => ({ - name, + 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, + weight: WEIGHTS[index] ?? 400, style: 'normal' as const, })), - ); - return cached; + (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; } diff --git a/lib/screen/layout.ts b/lib/screen/layout.ts index 5286ba5..bdffa0a 100644 --- a/lib/screen/layout.ts +++ b/lib/screen/layout.ts @@ -14,6 +14,17 @@ export const BLACK = '#000000'; export const WHITE = '#ffffff'; export const PADDING = 24; + +/** + * Where the week sits. + * + * A bottom band gives each day a 114px column, and that width is what caps + * the type size. A side band gives roughly 230px and a 68px row instead — + * twice the room in both directions, at the cost of narrowing the main block. + */ +export type WeekPlacement = 'bottom' | 'right' | 'left'; + +export const SIDE_BAND_WIDTH = 232; export const HEADER_HEIGHT = 76; export const WEEK_HEIGHT = 112; export const BANNER_HEIGHT = 84; diff --git a/lib/screen/render.test.ts b/lib/screen/render.test.ts index 8b82657..d806c98 100644 --- a/lib/screen/render.test.ts +++ b/lib/screen/render.test.ts @@ -122,6 +122,24 @@ describe('screenTree', () => { expect(withoutLogo).toContain('ITA ITO'); }); + it('keeps the hours whole in the week, tightening only the dash', () => { + const tree = JSON.stringify(screenTree(PAYLOAD)); + // A shop's opening hour is not a detail to abbreviate: 11:00 stays 11:00. + expect(tree).toContain('10:00–13:00'); + expect(tree).toContain('14:00–18:30'); + expect(tree).not.toContain('10–13'); + // The main block has the room for the spaced form, and it is the line + // someone actually reads for today. + expect(tree).toContain('10:00 – 13:00 · 14:00 – 18:30'); + }); + + it('draws the week down the right-hand side by default', () => { + const tree = JSON.stringify(screenTree(PAYLOAD)); + // The band is the default arrangement; the bottom strip remains available. + expect(tree).toContain('"width":232'); + expect(JSON.stringify(screenTree(PAYLOAD, null, 'bottom'))).not.toContain('"width":232'); + }); + it('omits the hours line on a day with no hours', () => { const tree = JSON.stringify( screenTree({ ...PAYLOAD, today: { ...PAYLOAD.today, hours_text: '' } }), diff --git a/lib/screen/render.ts b/lib/screen/render.ts index 2afad75..2732913 100644 --- a/lib/screen/render.ts +++ b/lib/screen/render.ts @@ -13,12 +13,14 @@ import satori from 'satori'; import type { ScreenPayload, ScreenWeekDay } from './contract'; -import { loadScreenFonts } from './fonts'; +import { loadScreenFonts, type SansFamily } from './fonts'; +import { fitFontSize } from './fit'; import { h, type ElementNode } from './h'; import { LOGO_HEIGHT, LOGO_WIDTH, loadLogoDataUri } from './logo'; import { BANNER_HEIGHT, BLACK, + SIDE_BAND_WIDTH, HEADER_HEIGHT, PADDING, RULE, @@ -28,26 +30,124 @@ import { SERIF, WEEK_HEIGHT, WHITE, + type WeekPlacement, } from './layout'; -export function screenTree(payload: ScreenPayload, logo: string | null = null): ElementNode { +export function screenTree( + payload: ScreenPayload, + logo: string | null = null, + placement: WeekPlacement = 'right', +): ElementNode { + const root = { + display: 'flex', + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + backgroundColor: WHITE, + color: BLACK, + fontFamily: SANS, + } as const; + + if (placement === 'bottom') { + return h( + 'div', + { style: { ...root, flexDirection: 'column' } }, + header(payload, logo), + main(payload, SCREEN_WIDTH), + weekStrip(payload.week), + banner(payload), + ); + } + + const column = h( + 'div', + { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, flexBasis: 0 } }, + header(payload, logo), + main(payload, SCREEN_WIDTH - SIDE_BAND_WIDTH), + banner(payload), + ); + const band = weekBand(payload.week, placement); + + return h( + 'div', + { style: { ...root, flexDirection: 'row' } }, + ...(placement === 'left' ? [band, column] : [column, band]), + ); +} + +/** + * The week as a side band: one row per day, the label and the hours on the + * same line. A row is 68px tall and the band 232px wide, so the type can be + * a third larger than the bottom strip allows. + */ +function weekBand(week: ScreenWeekDay[], placement: 'right' | 'left'): ElementNode { return h( 'div', { style: { display: 'flex', flexDirection: 'column', - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT, - backgroundColor: WHITE, - color: BLACK, - fontFamily: SANS, + width: SIDE_BAND_WIDTH, + ...(placement === 'right' + ? { borderLeft: `${RULE}px solid ${BLACK}` } + : { borderRight: `${RULE}px solid ${BLACK}` }), }, }, - header(payload, logo), - main(payload), - weekStrip(payload.week), - banner(payload), + ...week.map((day) => weekRow(day)), + ); +} + +function weekRow(day: ScreenWeekDay): ElementNode { + const inverted = day.is_today; + const lines = day.is_closed ? ['—'] : day.hours_text.split(' · ').map(tightHours); + + return h( + 'div', + { + style: { + display: 'flex', + alignItems: 'center', + flexGrow: 1, + flexBasis: 0, + paddingLeft: 14, + paddingRight: 14, + backgroundColor: inverted ? BLACK : WHITE, + color: inverted ? WHITE : BLACK, + overflow: 'hidden', + }, + }, + h( + 'div', + { style: { display: 'flex', width: 62, fontSize: 23, fontWeight: 700 } }, + `${day.label_fr}${day.is_exception ? '*' : ''}`, + ), + h( + 'div', + { + style: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + flexGrow: 1, + }, + }, + ...lines.map((line) => + h( + 'div', + { + style: { + display: 'flex', + // A row is 68px tall; three lines have to share it. + fontSize: Math.min( + lines.length >= 3 ? 17 : 22, + fitFontSize(line, { available: 142, preferred: 22, minimum: 14 }), + ), + lineHeight: 1.2, + }, + }, + line, + ), + ), + ), ); } @@ -81,7 +181,7 @@ function header(payload: ScreenPayload, logo: string | null): ElementNode { ); } -function main(payload: ScreenPayload): ElementNode { +function main(payload: ScreenPayload, width: number): ElementNode { const { today } = payload; return h( 'div', @@ -103,7 +203,20 @@ function main(payload: ScreenPayload): ElementNode { today.hours_text ? h( 'div', - { style: { display: 'flex', fontSize: 36, fontWeight: 600, marginTop: 10 } }, + { + style: { + display: 'flex', + // Three periods with no round hour would otherwise wrap and push + // the banner off the bottom edge. + fontSize: fitFontSize(today.hours_text, { + available: width - 2 * PADDING, + preferred: 36, + minimum: 22, + }), + fontWeight: 600, + marginTop: 10, + }, + }, today.hours_text, ) : null, @@ -147,7 +260,7 @@ function weekColumn(day: ScreenWeekDay): ElementNode { // block, but here it is the difference between fitting and clipping. const lines = day.is_closed ? ['—'] - : day.hours_text.split(' · ').map((slot) => slot.replace(' – ', '–')); + : day.hours_text.split(' · ').map(tightHours); return h( 'div', @@ -169,11 +282,21 @@ function weekColumn(day: ScreenWeekDay): ElementNode { }, h( 'div', - { style: { display: 'flex', fontSize: 19, fontWeight: 700, marginBottom: 6 } }, + { style: { display: 'flex', fontSize: 20, fontWeight: 700, marginBottom: 5 } }, `${day.label_fr}${day.is_exception ? '*' : ''}`, ), ...lines.map((line) => - h('div', { style: { display: 'flex', fontSize: 16, lineHeight: 1.35 } }, line), + h( + 'div', + { + style: { + display: 'flex', + fontSize: fitFontSize(line, { available: 102, preferred: 19, minimum: 14 }), + lineHeight: 1.3, + }, + }, + line, + ), ), ); } @@ -202,12 +325,37 @@ function banner(payload: ScreenPayload): ElementNode | null { ); } +export type RenderOptions = { + /** Which sans family to set the panel in; see lib/screen/fonts.ts. */ + family?: SansFamily; + /** Where the week sits; see lib/screen/layout.ts. */ + placement?: WeekPlacement; +}; + /** 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, { +export async function renderScreenSvg( + payload: ScreenPayload, + options: RenderOptions = {}, +): Promise { + const [fonts, logo] = await Promise.all([ + loadScreenFonts(options.family), + loadLogoDataUri(), + ]); + return satori(screenTree(payload, logo, options.placement) as unknown as React.ReactNode, { width: SCREEN_WIDTH, height: SCREEN_HEIGHT, fonts, }); } + +/** + * The week's own spelling of an opening period. + * + * Only the spaces around the dash go: "10:00–18:30" instead of + * "10:00 – 18:30". The times themselves stay whole — a shop's opening hour is + * not a detail to abbreviate — and losing two spaces is enough to hold 22pt + * in a 142px column. + */ +function tightHours(slot: string): string { + return slot.replace(' – ', '–'); +} diff --git a/package-lock.json b/package-lock.json index 8e91960..031cb1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@fontsource/atkinson-hyperlegible": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", + "@fontsource/silkscreen": "^5.3.0", "@fontsource/source-serif-4": "^5.3.0", "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4.3.3", @@ -1113,6 +1116,26 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/atkinson-hyperlegible": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz", + "integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "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", @@ -1123,6 +1146,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource/silkscreen": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/silkscreen/-/silkscreen-5.3.0.tgz", + "integrity": "sha512-M1NQ3LQH0LFNvyFPd/piHEGMImPPtpLglNnLC6Vdcq9kL0jNIOtTQmksAWCc4enbDnw1OnIpZtjNEfRqYm/QmA==", + "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", diff --git a/package.json b/package.json index 87bb39d..cb763fd 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "e2e": "playwright test", "holidays:sync": "tsx scripts/sync-holidays.ts", "translate:test": "tsx scripts/test-translation.ts", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "screen:fonts": "tsx scripts/typography-preview.ts" }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", @@ -44,7 +45,10 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@fontsource/atkinson-hyperlegible": "^5.3.0", + "@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", + "@fontsource/silkscreen": "^5.3.0", "@fontsource/source-serif-4": "^5.3.0", "@playwright/test": "^1.63.0", "@tailwindcss/postcss": "^4.3.3", diff --git a/public/fonts/atkinson-400.woff b/public/fonts/atkinson-400.woff new file mode 100644 index 0000000..815fbf5 Binary files /dev/null and b/public/fonts/atkinson-400.woff differ diff --git a/public/fonts/atkinson-700.woff b/public/fonts/atkinson-700.woff new file mode 100644 index 0000000..e9495ca Binary files /dev/null and b/public/fonts/atkinson-700.woff differ diff --git a/public/fonts/plex-400.woff b/public/fonts/plex-400.woff new file mode 100644 index 0000000..cf5e2bb Binary files /dev/null and b/public/fonts/plex-400.woff differ diff --git a/public/fonts/plex-600.woff b/public/fonts/plex-600.woff new file mode 100644 index 0000000..0ccb1f1 Binary files /dev/null and b/public/fonts/plex-600.woff differ diff --git a/public/fonts/plex-700.woff b/public/fonts/plex-700.woff new file mode 100644 index 0000000..0b70436 Binary files /dev/null and b/public/fonts/plex-700.woff differ diff --git a/scripts/typography-preview.ts b/scripts/typography-preview.ts new file mode 100644 index 0000000..46e1082 --- /dev/null +++ b/scripts/typography-preview.ts @@ -0,0 +1,123 @@ +/** + * Renders the panel in each candidate typeface, at 1 bit, plus a magnified + * crop of the week strip — the smallest text on the screen and the only part + * whose legibility is in question. + * + * npm run screen:fonts + * + * Choosing a face for a 1-bit panel is done by looking at it thresholded. A + * specimen sheet shows anti-aliased outlines the panel will never render. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import sharp from 'sharp'; + +import type { ScreenPayload } from '../lib/screen/contract'; +import { encodeScreen } from '../lib/screen/encode'; +import type { SansFamily } from '../lib/screen/fonts'; +import type { WeekPlacement } from '../lib/screen/layout'; +import { renderScreenSvg } from '../lib/screen/render'; + +const PAYLOAD: ScreenPayload = { + schema: 1, + generated_at: '2026-09-22T12:00:00+02:00', + shop_name: 'ITA ITO', + logo_url: '', + today: { + date_fr: 'Mercredi 23 septembre', + date_en: 'Wednesday 23 September', + status: 'OPEN', + status_fr: 'OUVERT', + status_en: 'OPEN', + hours_text: '11:00 – 18:00', + next_change_fr: 'Ferme à 18:00', + next_change_en: 'Closes at 6: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: '11:00 – 18:00', is_today: false, is_closed: false, is_exception: false }, + { label_fr: 'Mer', label_en: 'Wed', hours_text: '11:00 – 18:00', is_today: true, is_closed: false, is_exception: false }, + { label_fr: 'Jeu', label_en: 'Thu', hours_text: '11:00 – 13:00 · 14:00 – 18:00', is_today: false, is_closed: false, is_exception: false }, + { label_fr: 'Ven', label_en: 'Fri', hours_text: '14:00 – 18:00', is_today: false, is_closed: false, is_exception: true }, + { label_fr: 'Sam', label_en: 'Sat', hours_text: '10:00 – 17:00', 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 matin', + text_en: 'Exceptionally closed on Thursday morning', + }, +}; + +/** + * The worst case the data model allows: three slots in a day, none of them on + * a round hour, so nothing can be shortened. If a layout survives this it + * survives anything the shop can enter. + */ +const CROWDED: ScreenPayload = { + ...PAYLOAD, + today: { + ...PAYLOAD.today, + hours_text: '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30', + next_change_fr: 'Ferme à 12:00', + next_change_en: 'Closes at 12:00 pm', + }, + week: PAYLOAD.week.map((day, index) => + index === 2 + ? { ...day, hours_text: '09:30 – 12:00 · 13:30 – 16:00 · 17:00 – 19:30' } + : index === 3 + ? { ...day, hours_text: '10:30 – 18:30' } + : day, + ), +}; + +const VARIANTS: { + key: string; + label: string; + family: SansFamily; + placement: WeekPlacement; + payload?: ScreenPayload; +}[] = [ + { key: 'bas', label: 'Bande en bas (actuelle)', family: 'inter', placement: 'bottom' }, + { key: 'droite', label: 'Bande à droite', family: 'inter', placement: 'right' }, + { key: 'gauche', label: 'Bande à gauche', family: 'inter', placement: 'left' }, + { key: 'limite-droite', label: 'Pire cas, bande à droite', family: 'inter', placement: 'right', payload: CROWDED }, + { key: 'limite-bas', label: 'Pire cas, bande en bas', family: 'inter', placement: 'bottom', payload: CROWDED }, +]; + +async function main(): Promise { + const directory = path.join(process.cwd(), '.preview', 'fonts'); + await mkdir(directory, { recursive: true }); + + for (const { key, label, family, placement, payload } of VARIANTS) { + const png = encodeScreen( + await renderScreenSvg(payload ?? PAYLOAD, { family, placement }), + 'png', + ); + await writeFile(path.join(directory, `${key}.png`), png); + + // Magnified with nearest-neighbour on the week area: the point is to see + // the pixels the panel will actually set, not a smoothed impression. + const crop = + placement === 'bottom' + ? { left: 0, top: 284, width: 800, height: 116 } + : placement === 'right' + ? { left: 800 - 232, top: 0, width: 232, height: 480 } + : { left: 0, top: 0, width: 232, height: 480 }; + + await sharp(png) + .extract(crop) + .resize({ width: crop.width * 2, kernel: 'nearest' }) + .toFile(path.join(directory, `${key}-zoom.png`)); + + console.log(`${label.padEnd(26)} -> .preview/fonts/${key}.png (+ -zoom.png)`); + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +});