Files
ita-ito-horaires/scripts/brand.ts
T
vliaudatandClaude Opus 5 61074d02f9 fix: keep the thin strokes of the wordmark on the panel
The A of ITA had lost its diagonal: at 260px the hairlines of a Didone
fall below a pixel, downscaling averages them into grey, and the
panel's mid-grey cut then discarded them. What reached the shop window
was a detached serif and something closer to a lambda.

Letting more grey count as ink keeps them whole. 165 was not enough —
the diagonal came back dotted — and 195 holds it solid without
thickening the stems into mud. The number belongs to this particular
drawing, so `npm run brand:variants` renders the grid of widths and
thresholds it was chosen from, magnified, for the day the logo changes.

Width stays at 260: the header shares its line with the date, and the
main block is narrower now the week runs down the right-hand side.

The panel will redraw once when it next wakes, since the image and
therefore its content hash have changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 23:24:53 +02:00

130 lines
4.5 KiB
TypeScript

/**
* 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;
/**
* Where grey becomes ink.
*
* The wordmark is a Didone: its hairlines are a fraction of its stems, and at
* 260px they land below one pixel. Downscaling averages them into grey, and
* the panel's default mid-grey cut then threw them away — the diagonal of the
* A vanished entirely, leaving a detached serif and something closer to a
* lambda. Letting more grey count as ink keeps the thin strokes whole.
*
* 165 was still not enough: the diagonal came back dotted. 195 holds it solid
* without thickening the stems into mud. Re-check with `npm run brand:variants`
* if the logo is ever replaced — this number belongs to this drawing.
*/
const EINK_THRESHOLD = 195;
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, {
threshold: EINK_THRESHOLD,
});
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;
});