Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default defineConfig({
platform: 'node',
target: 'node22',
clean: true,
minify: true,
// Workspace packages and zod are bundled; elkjs (EPL-2.0) stays a runtime dependency, never vendored.
noExternal: [/^@stackmap\//, 'zod'],
external: ['elkjs'],
Expand Down
58 changes: 58 additions & 0 deletions packages/core/src/font-metrics-codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/** Pair adjustments in 1/1000 em, keyed by first * 0x10000 + second code point (both printable ASCII). */
export type KerningPairs = Record<number, number>;

/**
* A face's kerning as shipped: each pair is its two characters, the adjustment, then a comma (about a third
* of the size of an object literal). A face whose pairs are another's minus a few (tabular digits drop the
* digit pairs) is stored as that face plus the dropped pairs, two characters each.
*/
export type EncodedKerning<F extends string = string> = string | { base: F; drop: string };

const key = (a: number, b: number) => a * 0x10000 + b;
const chars = (k: number) => String.fromCharCode(Math.floor(k / 0x10000), k % 0x10000);

export function encodePairs(pairs: KerningPairs): string {
return Object.entries(pairs)
.map(([k, v]) => `${chars(Number(k))}${v},`)
.join('');
}

export function decodePairs(s: string): KerningPairs {
const pairs: KerningPairs = {};
for (let i = 0; i < s.length; ) {
const end = s.indexOf(',', i + 2);
pairs[key(s.charCodeAt(i), s.charCodeAt(i + 1))] = Number(s.slice(i + 2, end));
i = end + 1;
}
return pairs;
}

export function encodeKerning<F extends string>(faces: Record<F, KerningPairs>): Record<F, EncodedKerning<F>> {
const names = Object.keys(faces) as F[];
const out = {} as Record<F, EncodedKerning<F>>;
names.forEach((name, i) => {
const pairs = faces[name];
const keys = Object.keys(pairs);
const base = names
.slice(0, i)
.find((b) => typeof out[b] === 'string' && keys.length > 0 && keys.every((k) => faces[b][Number(k)] === pairs[Number(k)]));
out[name] = base
? {
base,
drop: Object.keys(faces[base])
.filter((k) => !(k in pairs))
.map((k) => chars(Number(k)))
.join(''),
}
: encodePairs(pairs);
});
return out;
}

export function decodeKerning<F extends string>(faces: Record<F, EncodedKerning<F>>, face: F): KerningPairs {
const enc = faces[face];
if (typeof enc === 'string') return decodePairs(enc);
const pairs = decodePairs(faces[enc.base] as string);
for (let i = 0; i < enc.drop.length; i += 2) delete pairs[key(enc.drop.charCodeAt(i), enc.drop.charCodeAt(i + 1))];
return pairs;
}
20 changes: 12 additions & 8 deletions packages/core/src/font-metrics.gen.ts

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion packages/core/src/text-measure.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { decodeKerning, type KerningPairs } from './font-metrics-codec';
import { FONT_METRICS, type FontFace } from './font-metrics.gen';

/** The faces NodeCard renders text in (Geist Sans 400/500, tabular-digit 500, Geist Mono 400). */
Expand All @@ -9,6 +10,14 @@ const FALLBACK_EM = 1.1;
const EMOJI_EM = 1.3;
const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;

const decoded = new Map<FontFace, KerningPairs>();
/** A face's pair adjustments in 1/1000 em, decoded from the shipped table on first use. */
export function kerningPairs(face: FontFace): KerningPairs {
let pairs = decoded.get(face);
if (!pairs) decoded.set(face, (pairs = decodeKerning(FONT_METRICS.kerning, face)));
return pairs;
}

/**
* Rendered width in CSS px, headless: the wider of how macOS/Windows Chromium lays the text out
* (fractional advances + pair kerning) and how Linux Chromium does (each advance snapped to whole
Expand All @@ -22,7 +31,7 @@ export function measureText(text: string, face: FontFace, sizePx: number): numbe
/** Both platform models, for tests and diagnostics: `exact` (kerned, fractional) and Linux's `snapped`. */
export function textWidths(text: string, face: FontFace, sizePx: number): { exact: number; snapped: number } {
const advances: Record<number, number | undefined> = FONT_METRICS.faces[face];
const kerning: Record<number, number | undefined> = FONT_METRICS.kerning[face];
const kerning: Record<number, number | undefined> = kerningPairs(face);
const em = FONT_METRICS.unitsPerEm;
let exact = 0;
let snapped = 0;
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/font-metrics-codec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { decodeKerning, decodePairs, encodeKerning, encodePairs } from '../src/font-metrics-codec';

const pair = (a: string, b: string) => a.charCodeAt(0) * 0x10000 + b.charCodeAt(0);

describe('font-metrics codec', () => {
it('round-trips pairs whose characters are the separator, digits, a minus sign or need escaping', () => {
const pairs = { [pair('A', 'V')]: -40.5, [pair(',', ',')]: 3, [pair('7', '1')]: -12, [pair('-', '-')]: 0.5, [pair('"', '\\')]: 237 };
expect(decodePairs(JSON.parse(JSON.stringify(encodePairs(pairs))))).toEqual(pairs);
expect(decodePairs('')).toEqual({});
});

it('stores a face that is another minus some pairs as that face plus the dropped pairs', () => {
const full = { [pair('A', 'V')]: -40, [pair('1', '1')]: -8, [pair('T', 'o')]: -30 };
const tnum = { [pair('A', 'V')]: -40, [pair('T', 'o')]: -30 };
const enc = encodeKerning({ full, tnum, empty: {} });
expect(enc.tnum).toEqual({ base: 'full', drop: '11' });
expect(enc.empty).toBe('');
expect(decodeKerning(enc, 'tnum')).toEqual(tnum);
expect(decodeKerning(enc, 'full')).toEqual(full);
});

it('keeps a face standalone when a shared pair differs', () => {
const enc = encodeKerning({ a: { [pair('A', 'V')]: -40, [pair('T', 'o')]: -30 }, b: { [pair('A', 'V')]: -41 } });
expect(typeof enc.b).toBe('string');
});
});
6 changes: 3 additions & 3 deletions packages/core/test/text-measure.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest';
import { FONT_METRICS } from '../src/font-metrics.gen';
import { measureText, textWidths } from '../src/text-measure';
import { kerningPairs, measureText, textWidths } from '../src/text-measure';

const exact = (t: string, f: Parameters<typeof measureText>[1], size: number) => textWidths(t, f, size).exact;

const { faces, kerning } = FONT_METRICS;
const { faces } = FONT_METRICS;

describe('measureText', () => {
it('is zero for an empty string', () => {
Expand All @@ -16,7 +16,7 @@ describe('measureText', () => {
});

it('applies the pair kerning between adjacent glyphs', () => {
const av = kerning.sans400[65 * 0x10000 + 86]!;
const av = kerningPairs('sans400')[65 * 0x10000 + 86]!;
expect(av).toBeLessThan(0);
expect(exact('AV', 'sans400', 1000)).toBeCloseTo(faces.sans400[65]! + faces.sans400[86]! + av, 6);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/schema/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { NODE_TYPES } from '@stackmap/core';

// Mirrors the hand-written types in @stackmap/core (a type test keeps them identical). Objects are strict
Expand Down
1 change: 1 addition & 0 deletions packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit",
"e2e": "playwright test",
"metrics:gen": "bun scripts/gen-font-metrics.ts",
"icons:gen": "bun scripts/gen-brand-icons.ts",
"visual:update": "sh scripts/visual-update.sh"
},
"dependencies": {
Expand Down
27 changes: 27 additions & 0 deletions packages/viewer/scripts/font-metrics-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { writeFileSync } from 'node:fs';
import { encodeKerning, type KerningPairs } from '../../core/src/font-metrics-codec';

type Tables = Record<string, Record<number, number>>;

/** Writes packages/core/src/font-metrics.gen.ts from measured advances and kerning (1/`unitsPerEm` em). */
export function writeFontMetrics(unitsPerEm: number, advances: Tables, kerning: Record<string, KerningPairs>): string {
const faces = Object.keys(advances);
const table = Object.entries(advances)
.map(([name, m]) => ` ${name}: {${Object.entries(m).map(([k, v]) => `${k}:${v}`).join(',')}},`)
.join('\n');
const kern = Object.entries(encodeKerning(kerning))
.map(([name, enc]) => ` ${name}: ${JSON.stringify(enc)},`)
.join('\n');
const file = new URL('../../core/src/font-metrics.gen.ts', import.meta.url);
writeFileSync(
file,
`// Generated by packages/viewer/scripts/gen-font-metrics.ts — do not edit.\n` +
`// Advance widths in 1/${unitsPerEm} em keyed by code point; kerning encoded by font-metrics-codec.ts.\n` +
`import type { EncodedKerning } from './font-metrics-codec';\n\n` +
`export type FontFace = ${faces.map((f) => `'${f}'`).join(' | ')};\n\n` +
// Widened on purpose: literal types for thousands of keys make tsc slow and the tables unindexable.
`export const FONT_METRICS: {\n unitsPerEm: number;\n faces: Record<FontFace, Record<number, number>>;\n kerning: Record<FontFace, EncodedKerning<FontFace>>;\n} = {\n` +
` unitsPerEm: ${unitsPerEm},\n faces: {\n${table}\n },\n kerning: {\n${kern}\n },\n};\n`,
);
return file.pathname;
}
19 changes: 19 additions & 0 deletions packages/viewer/scripts/gen-brand-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Generates src/icons/brands.gen.ts: title and path of each allowlisted simple-icons mark (core's
// BRAND_SLUGS). Each simple-icons export also carries its full SVG (the path again), colour and source
// URLs, which the viewer never uses: about half the icon bytes. Run: bun run icons:gen
import { writeFileSync } from 'node:fs';
import * as simpleIcons from 'simple-icons';
import { BRAND_SLUGS } from '../../core/src/brands';

const bySlug = new Map(Object.values(simpleIcons).map((i) => [i.slug, i]));
const entries = [...BRAND_SLUGS].sort().map((slug) => {
const icon = bySlug.get(slug);
if (!icon) throw new Error(`simple-icons has no "${slug}"`);
return ` ${JSON.stringify(slug)}: { title: ${JSON.stringify(icon.title)}, path: ${JSON.stringify(icon.path)} },`;
});
writeFileSync(
new URL('../src/icons/brands.gen.ts', import.meta.url),
`// Generated by packages/viewer/scripts/gen-brand-icons.ts from simple-icons — do not edit.\n` +
`export const BRANDS: Record<string, { title: string; path: string }> = {\n${entries.join('\n')}\n};\n`,
);
console.log(`wrote brands.gen.ts (${entries.length} marks)`);
17 changes: 3 additions & 14 deletions packages/viewer/scripts/gen-font-metrics.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Generates packages/core/src/font-metrics.gen.ts: per-glyph advances of the inlined Geist faces, measured
// by Chromium itself so weight-500 (a variable-font instance) and tabular digits match what users see.
// Run: bun run metrics:gen (needs `bunx playwright install chromium` once).
import { readFileSync, writeFileSync } from 'node:fs';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { chromium } from '@playwright/test';
import * as fontkit from 'fontkit';
import { writeFontMetrics } from './font-metrics-file';

const require = createRequire(import.meta.url);
const file = (spec: string) => readFileSync(require.resolve(spec));
Expand Down Expand Up @@ -95,17 +96,5 @@ for (const [name, f] of Object.entries(FACES)) {
}
await browser.close();

const table = (t: Record<string, Record<number, number>>) =>
Object.entries(t)
.map(([name, m]) => ` ${name}: {${Object.entries(m).map(([k, v]) => `${k}:${v}`).join(',')}},`)
.join('\n');
writeFileSync(
new URL('../../core/src/font-metrics.gen.ts', import.meta.url),
`// Generated by packages/viewer/scripts/gen-font-metrics.ts — do not edit.\n` +
`// Advance widths in 1/${SIZE} em keyed by code point; kerning keyed by first * 0x10000 + second.\n` +
`export type FontFace = ${Object.keys(FACES).map((f) => `'${f}'`).join(' | ')};\n\n` +
// Widened on purpose: literal types for ~12k keys make tsc slow and the tables unindexable.
`type Table = Record<FontFace, Record<number, number>>;\n\n` +
`export const FONT_METRICS: { unitsPerEm: number; faces: Table; kerning: Table } = {\n unitsPerEm: ${SIZE},\n faces: {\n${table(out)}\n },\n kerning: {\n${table(kern)}\n },\n};\n`,
);
writeFontMetrics(SIZE, out, kern);
console.log(`wrote font-metrics.gen.ts (${Object.keys(out).map((n) => `${n}: ${Object.keys(out[n]!).length} glyphs, ${Object.keys(kern[n]!).length} pairs`).join('; ')})`);
Loading
Loading