diff --git a/lib/brand/tighten.test.ts b/lib/brand/tighten.test.ts new file mode 100644 index 0000000..3287b26 --- /dev/null +++ b/lib/brand/tighten.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { columnHasInk, keptColumns, tighten, type Greyscale } from './tighten'; + +/** Builds an image from a picture: '#' is ink, '.' is paper. */ +function image(row: string, height = 2): Greyscale { + const width = row.length; + const data = Buffer.alloc(width * height, 255); + for (let y = 0; y < height; y += 1) { + [...row].forEach((cell, x) => { + data[y * width + x] = cell === '#' ? 0 : 255; + }); + } + return { data, width, height }; +} + +describe('columnHasInk', () => { + it('finds ink anywhere down the column', () => { + const grid: Greyscale = { data: Buffer.from([255, 255, 0, 255]), width: 2, height: 2 }; + expect(columnHasInk(grid, 0)).toBe(true); + expect(columnHasInk(grid, 1)).toBe(false); + }); +}); + +describe('keptColumns', () => { + it('shortens a gap to the allowance', () => { + expect(keptColumns(image('#.....#'), 2)).toEqual([0, 1, 2, 6]); + }); + + it('leaves a gap already within the allowance alone', () => { + expect(keptColumns(image('#.#'), 2)).toEqual([0, 1, 2]); + }); + + it('drops the margins, which are not spacing', () => { + expect(keptColumns(image('..#.#..'), 2)).toEqual([2, 3, 4]); + }); + + it('handles several gaps independently', () => { + expect(keptColumns(image('#....#....#'), 1)).toEqual([0, 1, 5, 6, 10]); + }); + + it('returns nothing for a blank image', () => { + expect(keptColumns(image('.....'), 2)).toEqual([]); + }); + + it('keeps solid ink untouched', () => { + expect(keptColumns(image('####'), 0)).toEqual([0, 1, 2, 3]); + }); +}); + +describe('tighten', () => { + it('narrows the image by the width it removed', () => { + const result = tighten(image('#.....#'), 2); + expect(result.width).toBe(4); + expect(result.height).toBe(2); + }); + + it('preserves the letterforms, only the space between them', () => { + const result = tighten(image('##....##'), 1); + const row = Array.from({ length: result.width }, (_, x) => (result.data[x] === 0 ? '#' : '.')); + expect(row.join('')).toBe('##.##'); + }); + + it('copies every row, not just the first', () => { + const source = image('#..#', 3); + const result = tighten(source, 1); + for (let y = 0; y < 3; y += 1) { + expect(result.data[y * result.width]).toBe(0); + } + }); +}); diff --git a/lib/brand/tighten.ts b/lib/brand/tighten.ts new file mode 100644 index 0000000..07c11c6 --- /dev/null +++ b/lib/brand/tighten.ts @@ -0,0 +1,81 @@ +/** + * Tightening the letter-spacing of a wordmark, by image rather than by text. + * + * The logo arrives as artwork: its spacing is drawn in, not typeset, so no + * property can adjust it. But between two letters every pixel column is blank, + * and a blank column carries no shape — dropping some of them moves the + * letters closer without altering a single letterform. + * + * Working on the full-resolution source matters: doing this after the + * downscale would compress columns that are already an average of ink and + * paper, and smear the hairlines this logo can least afford to lose. + */ + +export type Greyscale = { + data: Uint8Array | Buffer; + width: number; + height: number; +}; + +/** Below this a pixel counts as ink; above it, as paper. */ +const INK = 128; + +export function columnHasInk({ data, width, height }: Greyscale, x: number): boolean { + for (let y = 0; y < height; y += 1) { + if ((data[y * width + x] ?? 255) < INK) { + return true; + } + } + return false; +} + +/** + * The columns to keep so that no run of blank ones is longer than `maxGap`. + * + * Blank columns at the very edges are dropped entirely: they are margin, not + * spacing, and the caller sets its own. + */ +export function keptColumns(image: Greyscale, maxGap: number): number[] { + const ink: boolean[] = []; + for (let x = 0; x < image.width; x += 1) { + ink.push(columnHasInk(image, x)); + } + + const first = ink.indexOf(true); + const last = ink.lastIndexOf(true); + if (first === -1) { + return []; + } + + const kept: number[] = []; + let gap = 0; + + for (let x = first; x <= last; x += 1) { + if (ink[x]) { + gap = 0; + kept.push(x); + continue; + } + gap += 1; + if (gap <= maxGap) { + kept.push(x); + } + } + + return kept; +} + +/** Rebuilds the image from the kept columns. */ +export function tighten(image: Greyscale, maxGap: number): Greyscale { + const columns = keptColumns(image, maxGap); + const width = columns.length; + const data = Buffer.alloc(width * image.height, 255); + + for (let y = 0; y < image.height; y += 1) { + for (let index = 0; index < width; index += 1) { + data[y * width + index] = image.data[y * image.width + (columns[index] ?? 0)] ?? 255; + } + } + + return { data, width, height: image.height }; +} diff --git a/lib/screen/logo.test.ts b/lib/screen/logo.test.ts new file mode 100644 index 0000000..312efba --- /dev/null +++ b/lib/screen/logo.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { encodePng, toBitmap } from './encode'; +import { readPngSize } from './logo'; + +describe('readPngSize', () => { + it('reads the dimensions a PNG declares', () => { + // Built rather than fixtured, so the test cannot drift from the encoder. + const rgba = Buffer.alloc(17 * 5 * 4, 255); + const png = encodePng(toBitmap(rgba, 17, 5)); + expect(readPngSize(png)).toEqual({ width: 17, height: 5 }); + }); +}); diff --git a/lib/screen/logo.ts b/lib/screen/logo.ts index 96a55cb..92e93ba 100644 --- a/lib/screen/logo.ts +++ b/lib/screen/logo.ts @@ -4,29 +4,41 @@ 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`. + * 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`. + * + * Its dimensions are read from the file rather than kept as constants beside + * it. The letter-spacing of the wordmark is adjustable, and every adjustment + * changes the height — a number copied here would drift and squash the logo + * without anything failing. */ -/** Matches the committed asset; see scripts/brand.ts. */ -export const LOGO_WIDTH = 260; -export const LOGO_HEIGHT = 36; +export type Logo = { dataUri: string; width: number; height: number }; -let cached: string | null | undefined; +let cached: Logo | null | undefined; -export async function loadLogoDataUri(): Promise { +export async function loadLogo(): 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')}`; + const bytes = await readFile(file); + cached = { + dataUri: `data:image/png;base64,${bytes.toString('base64')}`, + ...readPngSize(bytes), + }; } catch { - // Not fatal: the header falls back to the shop name set in the settings, - // which is worse-looking but still a working screen. + // Not fatal: the header falls back to the shop name from the settings, + // which is plainer but still a working screen. cached = null; } return cached; } + +/** A PNG states its size in the IHDR chunk, at a fixed offset. */ +export function readPngSize(bytes: Buffer): { width: number; height: number } { + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; +} diff --git a/lib/screen/render.test.ts b/lib/screen/render.test.ts index c4163d2..6444837 100644 --- a/lib/screen/render.test.ts +++ b/lib/screen/render.test.ts @@ -124,9 +124,14 @@ describe('screenTree', () => { }); it('shows the wordmark when one is available, and the shop name otherwise', () => { - const withLogo = JSON.stringify(screenTree(PAYLOAD, 'data:image/png;base64,AAAA')); + const withLogo = JSON.stringify( + screenTree(PAYLOAD, { dataUri: 'data:image/png;base64,AAAA', width: 260, height: 49 }), + ); expect(withLogo).toContain('"img"'); expect(withLogo).toContain('data:image/png;base64,AAAA'); + // Drawn at the size the file declares, so tightening the letter-spacing + // cannot silently squash it. + expect(withLogo).toContain('"height":49'); // The fallback keeps the panel working if the asset ever goes missing. const withoutLogo = JSON.stringify(screenTree(PAYLOAD, null)); diff --git a/lib/screen/render.ts b/lib/screen/render.ts index d5fa794..705e262 100644 --- a/lib/screen/render.ts +++ b/lib/screen/render.ts @@ -16,7 +16,7 @@ import type { ScreenPayload, ScreenWeekDay } from './contract'; 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 { loadLogo, type Logo } from './logo'; import { BANNER_HEIGHT, BLACK, @@ -35,7 +35,7 @@ import { export function screenTree( payload: ScreenPayload, - logo: string | null = null, + logo: Logo | null = null, placement: WeekPlacement = 'right', ): ElementNode { const root = { @@ -151,7 +151,7 @@ function weekRow(day: ScreenWeekDay): ElementNode { ); } -function header(payload: ScreenPayload, logo: string | null): ElementNode { +function header(payload: ScreenPayload, logo: Logo | null): ElementNode { return h( 'div', { @@ -167,9 +167,9 @@ function header(payload: ScreenPayload, logo: string | null): ElementNode { }, logo ? h('img', { - src: logo, - width: LOGO_WIDTH, - height: LOGO_HEIGHT, + src: logo.dataUri, + width: logo.width, + height: logo.height, style: { display: 'flex' }, }) : h( @@ -373,7 +373,7 @@ export async function renderScreenSvg( ): Promise { const [fonts, logo] = await Promise.all([ loadScreenFonts(options.family), - loadLogoDataUri(), + loadLogo(), ]); return satori(screenTree(payload, logo, options.placement) as unknown as React.ReactNode, { width: SCREEN_WIDTH, diff --git a/public/brand/logo-eink.png b/public/brand/logo-eink.png index a129479..331ca16 100644 Binary files a/public/brand/logo-eink.png and b/public/brand/logo-eink.png differ diff --git a/scripts/brand.ts b/scripts/brand.ts index 65aa15d..b2dd6a5 100644 --- a/scripts/brand.ts +++ b/scripts/brand.ts @@ -21,6 +21,7 @@ import path from 'node:path'; import sharp from 'sharp'; +import { tighten } from '../lib/brand/tighten'; import { encodePng, toBitmap } from '../lib/screen/encode'; const LOGO_URL = @@ -29,6 +30,16 @@ const LOGO_URL = /** Wide enough to read from the pavement, short enough for a 76px header. */ const EINK_WIDTH = 260; +/** + * How many blank pixel columns may remain between two letters. + * + * The logotype is drawn with wide spacing, which reads well on a website and + * wastes a third of the width on a panel. Compressing the gaps lets the same + * 260px hold letters a third taller — which the hairlines need more than the + * spacing does. Rendered at several values by `npm run brand:variants`. + */ +const EINK_LETTER_GAP = 8; + /** * Where grey becomes ink. * @@ -56,10 +67,25 @@ async function main(): Promise { await writeFile(path.join(directory, 'logo.png'), original); const band = await findWordmarkBand(original); - const cropped = await sharp(original) + + // Tighten at full resolution, before any downscale: compressing columns that + // are already an average of ink and paper would smear the hairlines. + const full = await sharp(original) .extract({ left: 0, top: band.top, width: band.width, height: band.height }) - .resize({ width: EINK_WIDTH }) .flatten({ background: '#ffffff' }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }); + + const tightened = tighten( + { data: full.data, width: full.info.width, height: full.info.height }, + EINK_LETTER_GAP, + ); + + const cropped = await sharp(tightened.data, { + raw: { width: tightened.width, height: tightened.height, channels: 1 }, + }) + .resize({ width: EINK_WIDTH }) .ensureAlpha() .raw() .toBuffer({ resolveWithObject: true }); diff --git a/scripts/logo-variants.ts b/scripts/logo-variants.ts index a45d8b8..1ea98d2 100644 --- a/scripts/logo-variants.ts +++ b/scripts/logo-variants.ts @@ -15,10 +15,11 @@ import path from 'node:path'; import sharp from 'sharp'; +import { tighten } from '../lib/brand/tighten'; import { encodePng, toBitmap } from '../lib/screen/encode'; -const WIDTHS = [260, 320, 400]; -const THRESHOLDS = [127, 165, 195]; +/** How many blank pixel columns may remain between two letters. */ +const GAPS = [40, 14, 8, 4]; async function main(): Promise { const out = path.join(process.cwd(), '.preview', 'logo'); @@ -27,29 +28,44 @@ async function main(): Promise { const source = await readFile(path.join(process.cwd(), 'public', 'brand', 'logo.png')); const band = await findWordmarkBand(source); + // Full resolution, before any downscale: compressing averaged columns would + // smear the hairlines this logo can least afford to lose. + const full = await sharp(source) + .extract(band) + .flatten({ background: '#ffffff' }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }); + const rows: string[] = []; - for (const width of WIDTHS) { - for (const threshold of THRESHOLDS) { - const raw = await sharp(source) - .extract(band) - .resize({ width }) - .flatten({ background: '#ffffff' }) - .ensureAlpha() - .raw() - .toBuffer({ resolveWithObject: true }); + for (const gap of GAPS) { + const tightened = tighten( + { data: full.data, width: full.info.width, height: full.info.height }, + gap, + ); - const bitmap = toBitmap(raw.data, raw.info.width, raw.info.height, { threshold }); - const name = `w${width}-s${threshold}`; - await writeFile(path.join(out, `${name}.png`), encodePng(bitmap)); + const raw = await sharp(tightened.data, { + raw: { width: tightened.width, height: tightened.height, channels: 1 }, + }) + .resize({ width: 260 }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); - // Magnified to the same apparent size, so the variants compare fairly. - await sharp(path.join(out, `${name}.png`)) - .resize({ width: 1040, kernel: 'nearest' }) - .toFile(path.join(out, `${name}-zoom.png`)); + const bitmap = toBitmap(raw.data, raw.info.width, raw.info.height, { threshold: 195 }); + const name = `gap${String(gap).padStart(2, '0')}`; + await writeFile(path.join(out, `${name}.png`), encodePng(bitmap)); - rows.push(`${name} ${raw.info.width}x${raw.info.height}`); - } + // Magnified to the same apparent size, so the variants compare fairly. + await sharp(path.join(out, `${name}.png`)) + .resize({ width: 1040, kernel: 'nearest' }) + .toFile(path.join(out, `${name}-zoom.png`)); + + // Narrower source at the same final width means larger letters. + rows.push( + `${name} source ${tightened.width}px (-${full.info.width - tightened.width}) -> 260x${raw.info.height}`, + ); } console.log(rows.join('\n'));