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
82 lines
2.2 KiB
TypeScript
82 lines
2.2 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|