Files
ita-ito-horaires/scripts/brand.ts
T
vliaudatandClaude Opus 5 879536986f revert: keep the wordmark's original letter-spacing
Tightening the gaps bought letters a third taller at the same width,
which helped the hairlines — but it changed the logotype's character,
and the shop prefers the drawing as it was designed. The logo returns to
260x36 with its spacing intact.

The threshold fix that made the A's diagonal survive stays: that was a
separate problem and it is still solved.

lib/brand/tighten.ts and `npm run brand:variants` are kept rather than
deleted. Both are tested, the variants script is also how the threshold
itself was chosen, and a spacing decision is one to revisit by looking
at renders rather than by imagining them.

The logo's rendered size is still read from the PNG header rather than
copied into the renderer, which is what made this reversal a one-line
change with nothing to keep in sync.

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

140 lines
4.9 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;
/*
* The wordmark keeps its original letter-spacing.
*
* Compressing the gaps was tried: it buys letters a third taller at the same
* width, which helps the hairlines. It also changes the logotype's character,
* and the shop prefers the drawing as it was designed. `lib/brand/tighten.ts`
* and `npm run brand:variants` remain, so the decision can be revisited by
* looking rather than by imagining.
*/
/**
* 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;
});