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
108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
/**
|
|
* Renders the wordmark at several widths and thresholds.
|
|
*
|
|
* npm run brand:variants
|
|
*
|
|
* The shop logo is a Didone: its thin strokes are a fraction of the thick
|
|
* ones. Downscaling averages a hairline into grey, and a threshold then has to
|
|
* decide whether that grey is ink. Too strict and the diagonal of the A
|
|
* disappears; too loose and the whole wordmark turns to mud. The only way to
|
|
* choose is to look at the result thresholded, magnified.
|
|
*/
|
|
|
|
import { mkdir, readFile, 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';
|
|
|
|
/** How many blank pixel columns may remain between two letters. */
|
|
const GAPS = [40, 14, 8, 4];
|
|
|
|
async function main(): Promise<void> {
|
|
const out = path.join(process.cwd(), '.preview', 'logo');
|
|
await mkdir(out, { recursive: true });
|
|
|
|
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 gap of GAPS) {
|
|
const tightened = tighten(
|
|
{ data: full.data, width: full.info.width, height: full.info.height },
|
|
gap,
|
|
);
|
|
|
|
const raw = await sharp(tightened.data, {
|
|
raw: { width: tightened.width, height: tightened.height, channels: 1 },
|
|
})
|
|
.resize({ width: 260 })
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
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));
|
|
|
|
// 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'));
|
|
}
|
|
|
|
async function findWordmarkBand(source: Buffer) {
|
|
const { data, info } = await sharp(source)
|
|
.flatten({ background: '#ffffff' })
|
|
.greyscale()
|
|
.raw()
|
|
.toBuffer({ resolveWithObject: true });
|
|
|
|
let top: number | null = null;
|
|
let bottom = 0;
|
|
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) {
|
|
if (top === null) {
|
|
top = y;
|
|
}
|
|
bottom = y;
|
|
} else if (top !== null) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const start = Math.max(0, (top ?? 0) - 2);
|
|
return { left: 0, top: start, width: info.width, height: bottom + 2 - start + 1 };
|
|
}
|
|
|
|
main().catch((error: unknown) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|