The logotype is drawn with wide spacing — handsome on a website, and a third of the panel's header width spent on nothing. The spacing is artwork, not typesetting, so no property adjusts it; but every pixel column between two letters is blank, and a blank column carries no shape. Dropping most of them moves the letters together without altering a single letterform. At the same 260px the capitals are now a third taller, which the hairlines needed more than the spacing did: the A of ITA reads at a glance instead of being rescued by a threshold. Done at full resolution, before the downscale. Compressing columns that are already an average of ink and paper would smear exactly the strokes this logo can least afford to lose. The rendered size is now read from the PNG's own header rather than kept as a constant beside it. The spacing is adjustable and every value changes the height; a number copied into the renderer would have drifted and squashed the logo with nothing failing to say so. `npm run brand:variants` renders the spacings side by side, magnified, since this is a decision to be looked at rather than reasoned about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
156 lines
5.4 KiB
TypeScript
156 lines
5.4 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 { tighten } from '../lib/brand/tighten';
|
|
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;
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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);
|
|
|
|
// 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 })
|
|
.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 });
|
|
|
|
// 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;
|
|
});
|