autokerning
Version:
autokerning computes suggested kerning values for glyph pairs from TrueType/OpenType fonts by rendering glyph bitmaps, applying a small Gaussian blur, and measuring pixel overlap across horizontal offsets. It can be used programmatically (as an imported E
141 lines (140 loc) • 5.31 kB
JavaScript
import { Command } from "commander";
import * as opentype from "opentype.js";
import * as fs from "fs";
import { createCanvas } from "canvas";
const program = new Command();
program
.name("render-kerning")
.description("Render before/after comparisons of kerning")
.argument("<fontfile>", "Path to font file (.ttf/.otf)")
.argument("<kerningtable>", "Path to kerning JSON file")
.argument("[outputdir]", "Output directory (default: ./kerning-examples)")
.parse(process.argv);
const [fontfile, kerningTableFile, outputdir] = program.args;
const outDir = outputdir || "./kerning-examples";
// Create output directory
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const FONT_SIZE = 120;
const CANVAS_WIDTH = 1400;
const CANVAS_HEIGHT = 600;
// Create example sentences using common pairs
const EXAMPLE_SENTENCES = {
AV: "AVOCADO VALLEY",
AW: "AWARDS AWAITING",
AY: "ANYONE ANYWAY",
AF: "AFRICA FROZEN",
AB: "ABSOLUTE BEGINNING",
AD: "ADVANCED DESIGN",
AG: "AGILE GARDEN",
AQ: "AQUATIC QUALITY",
AR: "ARCHITECTURE RISING",
AS: "ASSISTANT ASSISTING",
AX: "AXIS AXLE",
AZ: "AZURE AZIMUTH",
VA: "VALID ANSWERS",
FA: "FANTASTIC ADVENTURES",
Pa: "Particular PARTY",
WA: "WAS GEHT AB",
WO: "WO GEHEN WIR HIN?",
Wa: "Waffle Wagon",
Ta: "Tactical Table",
Ye: "Yellow Yesterday",
Yp: "Yuppie Young",
Po: "Powerful Poet",
Pe: "Perpendicular Peace",
oo: "Foolish Moonlight",
oa: "Coastal Roaming",
oe: "Poet Opening",
};
function renderText(ctx, font, text, x, y, kern) {
let currentX = x;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const path = font.getPath(char, currentX, y + FONT_SIZE, FONT_SIZE);
// @ts-ignore
path.draw(ctx);
const advanceWidth = font.getAdvanceWidth(char, FONT_SIZE);
currentX += advanceWidth;
// Apply kerning between characters
if (i < text.length - 1 && kern) {
const pair = char + text[i + 1];
if (kern[pair] !== undefined) {
// kern is in percentage, convert to pixels
const kernPx = (kern[pair] / 100) * advanceWidth;
currentX += kernPx;
}
}
}
return currentX - x;
}
(async () => {
const font = await opentype.load(fontfile);
const kerningData = JSON.parse(fs.readFileSync(kerningTableFile, "utf-8"));
const kerningTable = kerningData.kerning;
console.log("Rendering kerning examples...");
let exampleCount = 0;
// Group pairs by first character
for (const pair of Object.keys(kerningTable)) {
if (pair.length !== 2)
continue;
// Use example sentence if available, otherwise just render the pair
const exampleText = EXAMPLE_SENTENCES[pair] || pair;
const canvas = createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const ctx = canvas.getContext("2d");
// White background
ctx.fillStyle = "white";
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
// Black text and strokes
ctx.fillStyle = "black";
ctx.strokeStyle = "black";
// Render WITHOUT kerning (top)
ctx.save();
renderText(ctx, font, exampleText, 50, 80, null);
ctx.restore();
// Render WITH kerning (bottom)
ctx.save();
renderText(ctx, font, exampleText, 50, 320, kerningTable);
ctx.restore();
// Add subtle separators (no text labels)
ctx.strokeStyle = "#cccccc";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 300);
ctx.lineTo(CANVAS_WIDTH, 300);
ctx.stroke();
// Save to PNG
const buffer = canvas.toBuffer("image/png");
// Build a case-unique filename: include code points so 'WA' != 'Wa' on case-insensitive filesystems
const codePoints = Array.from(pair).map((c) => c.codePointAt(0)?.toString(16).toUpperCase());
const safeFilename = `${pair.replace(/[^A-Za-z0-9_-]/g, "_")}_${codePoints.join("-")}.png`;
const filename = `${outDir}/${safeFilename}`;
fs.writeFileSync(filename, buffer);
// Log human-readable pair and saved filename
console.log(`✓ ${pair}: ${kerningTable[pair].toFixed(2)}% -> ${filename}`);
exampleCount++;
}
// Create summary page
const summaryCanvas = createCanvas(1200, 600);
const summaryCtx = summaryCanvas.getContext("2d");
summaryCtx.fillStyle = "white";
summaryCtx.fillRect(0, 0, 1200, 600);
summaryCtx.fillStyle = "black";
summaryCtx.font = "24px Arial";
summaryCtx.fillText(`Kerning Table: ${kerningData.font}`, 30, 40);
summaryCtx.font = "12px monospace";
let y = 80;
for (const [pair, kern] of Object.entries(kerningTable)) {
summaryCtx.fillText(`${pair}: ${kern.toFixed(2)}%`, 30, y);
y += 20;
if (y > 550)
break;
}
const summaryBuffer = summaryCanvas.toBuffer("image/png");
fs.writeFileSync(`${outDir}/summary.png`, summaryBuffer);
console.log(`\nRendering complete!`);
console.log(`Generated ${exampleCount} examples in ${outDir}/`);
console.log(`Summary page: ${outDir}/summary.png`);
})();