@lyubomir-popov/baseline-nudge-generator
Version:
Automatic font metrics reader that generates baseline grid nudges for CSS typography systems
722 lines (619 loc) • 29.2 kB
JavaScript
const fs = require('fs');
const path = require('path');
const fontkitParser = require('./fontkit-parser');
const opentype = require('opentype.js');
const { validateConfigFile } = require('./config-validator');
const { FontFileError, ConfigurationError, FontMetricsError } = require('./error-handler');
const { extractRobustFontName } = require('./font-name-extractor');
class BaselineNudgeGenerator {
constructor(fontMetrics = null, parser = 'fontkit') {
this.fontMetrics = fontMetrics;
this.parser = parser;
}
async readFontMetrics(fontPath) {
// Use fontkit as the default parser
return await fontkitParser.readFontMetrics(fontPath);
}
// Comprehensive font name extraction from metadata
async extractFontName(font, fontPath) {
let otFont = null;
// Try to load with opentype.js for fallback
try {
otFont = await opentype.load(fontPath);
} catch (error) {
// Will use null as fallback
}
// Use the robust font name extractor
const nameTable = font.tables && font.tables.name ? font.tables.name : null;
return extractRobustFontName(fontPath, nameTable, otFont);
}
// Find font file in directory
findFontFile(directory, fontFileName) {
// Try the exact filename as provided first
const exactPath = path.join(directory, fontFileName);
if (fs.existsSync(exactPath)) {
return exactPath;
}
// Then try possible extensions
const possibleExtensions = ['.woff2', '.woff', '.ttf', '.otf'];
const baseName = fontFileName.replace(/\.[^.]+$/, ''); // Remove extension if provided
for (const ext of possibleExtensions) {
const fullPath = path.join(directory, baseName + ext);
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
}
calculateNudgeRem(fontSizeRem, lineHeightRem, baselineUnitRem) {
if (!this.fontMetrics) {
throw new Error('Font metrics not loaded. Cannot calculate baseline nudges without font file.');
}
const { ascent, descent, lineGap, unitsPerEm } = this.fontMetrics;
const ascenderRem = ascent * fontSizeRem / unitsPerEm;
const descenderRem = Math.abs(descent) * fontSizeRem / unitsPerEm;
const lineGapRem = lineGap * fontSizeRem / unitsPerEm;
const effectiveAscenderRem = ascenderRem + lineGapRem;
const effectiveDescenderRem = descenderRem;
const contentAreaRem = effectiveAscenderRem + effectiveDescenderRem;
const lineHeightAbsoluteRem = lineHeightRem * baselineUnitRem;
const leadingRem = lineHeightAbsoluteRem - contentAreaRem;
// CORRECTED: lineGap should be distributed equally above and below the text
// The baseline position should be: leading/2 + ascender + lineGap/2
// This fixes the calculation that was adding the full lineGap to the ascender
const baselineOffsetRem = leadingRem / 2 + ascenderRem + (lineGapRem / 2);
let nudgeRem = (Math.ceil(baselineOffsetRem / baselineUnitRem) * baselineUnitRem) - baselineOffsetRem;
// Add compensation for 1px drift by slightly reducing the nudge
// Convert 1px to rem (assuming 16px root font size) and subtract a fraction of it
const onePixelInRem = 1 / 16; // 1px = 0.0625rem at 16px root
const compensation = onePixelInRem * 1; // Use 1 pixel as compensation (2x more than original)
// Use scaling compensation that smoothly transitions from 0 at 1rem to full at larger sizes
const scaleFactor = Math.max(0, fontSizeRem - 1); // 0 at 1rem, increases with font size
const proportionalCompensation = (compensation / fontSizeRem) * scaleFactor;
// Only apply compensation if the original nudge is not zero
// If nudgeRem is 0, the text is already perfectly aligned to the grid
let compensatedNudgeRem = nudgeRem;
if (nudgeRem > 0) {
compensatedNudgeRem = nudgeRem - proportionalCompensation;
}
// Fix negative nudges by moving to next grid line
if (compensatedNudgeRem < 0) {
compensatedNudgeRem = compensatedNudgeRem + baselineUnitRem;
}
return Math.round(compensatedNudgeRem * 100000) / 100000;
}
// Clean classname for CSS (remove leading dots, handle special characters)
cleanClassname(classname) {
if (!classname || typeof classname !== 'string') {
return 'unknown';
}
return classname.replace(/^\./, '').replace(/[^a-zA-Z0-9_-]/g, '_');
}
getFontFormat(fontFile) {
const ext = path.extname(fontFile).toLowerCase();
switch (ext) {
case '.woff2':
return 'woff2';
case '.woff':
return 'woff';
case '.ttf':
return 'truetype';
case '.otf':
return 'opentype';
default:
return 'truetype'; // fallback
}
}
// Generate tokens from new input format
generateTokens(config, fontMetricsMap = null) {
const { baselineUnit, elements, fontFile, fontFiles } = config;
// Determine if we're using the new multi-font format or legacy single-font format
const isMultiFont = fontFiles && fontMetricsMap;
if (isMultiFont) {
// New format with multiple fonts
const tokens = {
baselineUnit: `${baselineUnit}rem`,
fontFiles: fontFiles,
elements: {}
};
for (const element of elements) {
const { identifier, classname, tag, fontSize, lineHeight, spaceAfter, fontFamily, fontWeight, fontStyle } = element;
const fontSizeRem = fontSize;
const lineHeightRem = lineHeight;
// Get the appropriate font metrics for this element
const elementFontFamily = fontFamily || 'sans'; // Default to sans if not specified
const elementFontWeight = fontWeight || 400; // Default to 400 if not specified
const elementFontStyle = fontStyle || 'normal'; // Default to normal if not specified
const fontMetrics = fontMetricsMap[elementFontFamily];
if (!fontMetrics) {
throw new Error(`Font family "${elementFontFamily}" not found in fontMetricsMap. Available: ${Object.keys(fontMetricsMap).join(', ')}`);
}
// Create a temporary instance with the specific font metrics for calculation
const tempGenerator = new BaselineNudgeGenerator(fontMetrics);
const nudgeTop = tempGenerator.calculateNudgeRem(fontSizeRem, lineHeightRem, baselineUnit);
const spaceAfterRem = (spaceAfter !== undefined ? spaceAfter : 4) * baselineUnit; // Default to 4 baseline units if not specified
// Use identifier if available, otherwise use classname (backward compatibility), otherwise use tag, otherwise fallback to 'element'
const elementName = identifier || classname || tag || 'element';
const cleanName = this.cleanClassname(elementName);
tokens.elements[cleanName] = {
fontSize: `${fontSizeRem}rem`,
lineHeight: `${lineHeightRem * baselineUnit}rem`,
fontFamily: elementFontFamily,
fontWeight: elementFontWeight,
fontStyle: elementFontStyle,
spaceAfter: `${spaceAfterRem}rem`,
nudgeTop: `${nudgeTop}rem`
};
}
return tokens;
} else {
// Legacy format with single font
const tokens = {
font: this.fontMetrics.fontName || 'Unknown Font',
fontWeight: this.fontMetrics.fontWeight || 400,
baselineUnit: `${baselineUnit}rem`,
fontFile: fontFile,
elements: {}
};
for (const element of elements) {
const { identifier, classname, tag, fontSize, lineHeight, spaceAfter } = element;
const fontSizeRem = fontSize;
const lineHeightRem = lineHeight;
const nudgeTop = this.calculateNudgeRem(fontSizeRem, lineHeightRem, baselineUnit);
const spaceAfterRem = (spaceAfter !== undefined ? spaceAfter : 4) * baselineUnit; // Default to 4 baseline units if not specified
// Use identifier if available, otherwise use classname (backward compatibility), otherwise use tag, otherwise fallback to 'element'
const elementName = identifier || classname || tag || 'element';
const cleanName = this.cleanClassname(elementName);
tokens.elements[cleanName] = {
fontSize: `${fontSizeRem}rem`,
lineHeight: `${lineHeightRem * baselineUnit}rem`,
fontWeight: this.fontMetrics.fontWeight || 400,
spaceAfter: `${spaceAfterRem}rem`,
nudgeTop: `${nudgeTop}rem`
};
}
return tokens;
}
}
// Generate baseline grid CSS
generateBaselineGridCSS(baselineUnit) {
return `
.u-baseline-grid::after {
background: linear-gradient(to top, rgba(255, 0, 0, 0.15), rgba(255, 0, 0, 0.15) 1px, transparent 1px, transparent);
background-image: linear-gradient(to top, rgba(255, 0, 0, 0.15), rgba(255, 0, 0, 0.15) 1px, transparent 1px, transparent);
background-position-x: initial;
background-position-y: initial;
background-size: initial;
background-repeat: initial;
background-attachment: initial;
background-origin: initial;
background-clip: initial;
background-color: initial;
background-size: 100% ${baselineUnit}rem;
bottom: 0;
content: "";
display: block;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
z-index: 200;
}
body {
position: relative;
}
`;
}
// Generate HTML example page
generateHTML(tokens, fontMetricsMap = null) {
const { baselineUnit, elements, font, fontFile, fontFiles } = tokens;
// Determine if we're using the new multi-font format or legacy single-font format
const isMultiFont = fontFiles && fontMetricsMap;
let styles = `
<style>
`;
if (isMultiFont) {
// New format with multiple fonts
for (const fontFile of fontFiles) {
const fontFileName = path.basename(fontFile.path);
const fontMetrics = fontMetricsMap[fontFile.family];
styles += `
@font-face {
font-family: '${fontMetrics.fontName}';
src: url('fonts/${fontFileName}') format('${this.getFontFormat(fontFile.path)}');
}
`;
}
} else {
// Legacy format with single font
const fontFileName = path.basename(fontFile);
styles += `
@font-face {
font-family: '${font}';
src: url('fonts/${fontFileName}') format('${this.getFontFormat(fontFile)}');
font-weight: normal;
font-style: normal;
}
`;
}
// Default body styles
const defaultFontFamily = isMultiFont ?
(fontMetricsMap['sans'] ? fontMetricsMap['sans'].fontName : Object.values(fontMetricsMap)[0].fontName) :
font;
styles += `
body {
font-family: '${defaultFontFamily}', sans-serif;
font-weight: 400;
margin: 0;
padding: 0;
background: white;
color: #333;
position: relative;
min-height: 100vh;
}
${this.generateBaselineGridCSS(parseFloat(baselineUnit))}
body.u-baseline-grid::after {
background: linear-gradient(to top, rgba(255, 0, 0, 0.15), rgba(255, 0, 0, 0.15) 1px, transparent 1px, transparent);
background-size: 100% ${parseFloat(baselineUnit)}rem;
bottom: 0;
content: "";
display: block;
left: 0;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
z-index: 200;
}
`;
for (const [identifier, props] of Object.entries(elements)) {
const nudgeTopValue = parseFloat(props.nudgeTop);
const spaceAfterValue = parseFloat(props.spaceAfter);
let marginBottom;
// Special handling for spaceAfter = 0: change to 0.5rem
let adjustedSpaceAfter = spaceAfterValue;
if (spaceAfterValue === 0) {
adjustedSpaceAfter = 0.5;
}
// Calculate margin-bottom to ensure total spacing equals spaceAfter
// Total spacing = padding-top + margin-bottom = spaceAfter
marginBottom = adjustedSpaceAfter - nudgeTopValue;
if (isMultiFont) {
// New format with font family, weight, and style
const fontFamily = props.fontFamily || 'sans';
const fontWeight = props.fontWeight || 400;
const fontStyle = props.fontStyle || 'normal';
const fontMetrics = fontMetricsMap[fontFamily];
styles += `
.${identifier} {
font-size: ${props.fontSize};
line-height: ${props.lineHeight};
font-family: '${fontMetrics.fontName}', sans-serif;
font-weight: ${fontWeight};
font-style: ${fontStyle};
padding-top: ${props.nudgeTop};
margin-bottom: ${marginBottom}rem;
margin-top: 0;
}
`;
} else {
// Legacy format
styles += `
.${identifier} {
font-size: ${props.fontSize};
line-height: ${props.lineHeight};
font-weight: ${props.fontWeight};
padding-top: ${props.nudgeTop};
margin-bottom: ${marginBottom}rem;
margin-top: 0;
}
`;
}
}
styles += `
</style>
`;
let htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baseline Grid Typography Example</title>
${styles}
</head>
<body class="u-baseline-grid">
`;
for (const [identifier, props] of Object.entries(elements)) {
// Determine the HTML tag to use based on identifier
const isHeading = /^h[1-6]$/.test(identifier);
const tag = isHeading ? identifier : 'p';
// Generate sample text based on format
let sampleText;
if (isMultiFont) {
const fontFamily = props.fontFamily || 'sans';
const fontMetrics = fontMetricsMap[fontFamily];
sampleText = `This is an example of ${identifier} using ${fontMetrics.fontName} (${fontFamily}).`;
} else {
sampleText = `This is an example of ${identifier} using ${font}.`;
}
htmlContent += `
<${tag} class="${identifier}">
${sampleText}
</${tag}>
`;
}
htmlContent += `
</body>
</html>
`;
return htmlContent;
}
// Generate files from new input format
async generateFiles(inputPath, outputDir = 'dist') {
// Validate configuration first
const validation = validateConfigFile(inputPath);
if (!validation.isValid) {
throw new ConfigurationError(
`Configuration validation failed:\n${validation.errors.join('\n')}`,
inputPath
);
}
// Show warnings if any
if (validation.warnings.length > 0) {
console.warn('⚠️ Configuration warnings:');
validation.warnings.forEach(warning => console.warn(` ${warning}`));
}
if (!fs.existsSync(inputPath)) {
throw new ConfigurationError(`Input file not found: ${inputPath}`, inputPath);
}
const config = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
const inputDir = path.dirname(inputPath);
// Handle both single fontFile (legacy) and multiple fontFiles (new format)
let fontMetricsMap = {};
if (config.fontFiles) {
// New format with multiple font files
for (const fontFile of config.fontFiles) {
const fontPath = this.findFontFile(inputDir, fontFile.path);
if (!fontPath) {
throw new FontFileError(`Font file not found: ${fontFile.path}
Checked directory: ${inputDir}
Supported formats: .woff2, .woff, .ttf, .otf
Please ensure your font file is in the same directory as your config file.`, fontFile.path);
}
try {
const metrics = await this.readFontMetrics(fontPath);
const fontName = await this.extractFontName(metrics, fontPath);
fontMetricsMap[fontFile.family] = {
...metrics,
fontName: fontName,
family: fontFile.family,
path: fontFile.path
};
} catch (error) {
throw new FontMetricsError(`Failed to read font metrics for ${fontFile.family}: ${error.message}`, fontPath);
}
}
} else if (config.fontFile) {
// Legacy format with single font file
const fontPath = this.findFontFile(inputDir, config.fontFile);
if (!fontPath) {
throw new FontFileError(`Font file not found: ${config.fontFile}
Checked directory: ${inputDir}
Supported formats: .woff2, .woff, .ttf, .otf
Please ensure your font file is in the same directory as your config file.`, config.fontFile);
}
try {
const metrics = await this.readFontMetrics(fontPath);
const fontName = await this.extractFontName(metrics, fontPath);
fontMetricsMap['default'] = {
...metrics,
fontName: fontName,
family: 'default',
weight: 400,
style: 'normal',
path: config.fontFile
};
// Set the main fontMetrics for backward compatibility
this.fontMetrics = metrics;
} catch (error) {
throw new FontMetricsError(`Failed to read font metrics: ${error.message}`, fontPath);
}
} else {
throw new ConfigurationError('Either fontFile (legacy) or fontFiles (new format) is required.', inputPath);
}
const tokens = this.generateTokens(config, fontMetricsMap);
const html = this.generateHTML(tokens, fontMetricsMap);
// Create output directory if it doesn't exist
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write JSON tokens
const tokensPath = path.join(outputDir, 'tokens.json');
fs.writeFileSync(tokensPath, JSON.stringify(tokens, null, 2));
console.log(`✅ Generated tokens: ${tokensPath}`);
// Write HTML example
const htmlPath = path.join(outputDir, 'index.html');
fs.writeFileSync(htmlPath, html);
console.log(`✅ Generated HTML example: ${htmlPath}`);
// Copy font files to dist folder for HTML demo
const fontDistDir = path.join(outputDir, 'fonts');
if (!fs.existsSync(fontDistDir)) {
fs.mkdirSync(fontDistDir, { recursive: true });
}
// Copy all font files
for (const fontFamily in fontMetricsMap) {
const fontMetrics = fontMetricsMap[fontFamily];
const fontSrcPath = path.resolve(path.dirname(inputPath), fontMetrics.path);
const fontFileName = path.basename(fontSrcPath);
const fontDistPath = path.join(fontDistDir, fontFileName);
if (fs.existsSync(fontSrcPath)) {
fs.copyFileSync(fontSrcPath, fontDistPath);
console.log(`✅ Copied font: ${fontDistPath}`);
}
}
return { tokens, tokensPath, htmlPath };
}
// Legacy method for backward compatibility
generateScssMap(obj, valueTransform = v => v) {
return `(\n${Object.entries(obj).map(([k, v]) => ` ${k}: ${valueTransform(v)},`).join('\n')}\n)`;
}
// Updated legacy SCSS generation to work with new format
generateNudges(config) {
// Handle both new format (elements array) and legacy format (separate objects)
if (config.elements) {
// New format - convert elements array to legacy format
const calculatedNudges = {};
const calculatedSpAfters = {};
for (const element of config.elements) {
const { classname, fontSize, lineHeight } = element;
const cleanName = this.cleanClassname(classname);
const nudgeRem = this.calculateNudgeRem(fontSize, lineHeight, config.baselineUnit);
const spAfterRem = 4 * config.baselineUnit; // Default space after
calculatedNudges[cleanName] = nudgeRem;
calculatedSpAfters[cleanName] = spAfterRem;
}
return { nudges: calculatedNudges, spAfters: calculatedSpAfters };
}
// Legacy format - keep existing logic for backward compatibility
const { fontSizes, lineHeights, baselineUnit, spAfter } = config;
const calculatedNudges = {};
const calculatedSpAfters = {};
// Use provided keys instead of hardcoded ones
const providedKeys = Object.keys(fontSizes || {});
for (const key of providedKeys) {
const fontSizeRem = fontSizes[key];
const lineHeightRem = lineHeights[key];
const spAfterMultiplier = spAfter[key];
if (fontSizeRem !== undefined && lineHeightRem !== undefined) {
const nudgeRem = this.calculateNudgeRem(fontSizeRem, lineHeightRem, baselineUnit);
const spAfterRem = spAfterMultiplier * baselineUnit;
calculatedNudges[key] = nudgeRem;
calculatedSpAfters[key] = spAfterRem;
} else {
calculatedNudges[key] = 0;
calculatedSpAfters[key] = 0;
}
}
return { nudges: calculatedNudges, spAfters: calculatedSpAfters };
}
generateScss(config) {
if (!this.fontMetrics) {
throw new Error('Font metrics not loaded. Cannot generate SCSS without font file.');
}
const { nudges, spAfters } = this.generateNudges(config);
// Handle both new and legacy formats
if (config.elements) {
// New format
const fontSizes = {};
const lineHeights = {};
for (const element of config.elements) {
const cleanName = this.cleanClassname(element.classname);
fontSizes[cleanName] = element.fontSize;
lineHeights[cleanName] = element.lineHeight;
}
return [
'// AUTO-GENERATED FILE. DO NOT EDIT DIRECTLY.',
'// Generated by @lyubomir-popov/baseline-nudge-generator',
'',
`$baseline-unit: ${config.baselineUnit}rem;`,
`$font-sizes: ${this.generateScssMap(fontSizes, v => v + 'rem')};`,
`$line-heights: ${this.generateScssMap(lineHeights, v => v + ' * $baseline-unit')};`,
`$nudges: ${this.generateScssMap(nudges, v => v + 'rem')};`,
`$space-after: ${this.generateScssMap(spAfters, v => v + 'rem')};`,
`$font-ascent: ${this.fontMetrics.ascent};`,
`$font-descent: ${this.fontMetrics.descent};`,
`$font-line-gap: ${this.fontMetrics.lineGap};`,
`$font-units-per-em: ${this.fontMetrics.unitsPerEm};`,
`$font-cap-height: ${this.fontMetrics.capHeight};`,
`$font-x-height: ${this.fontMetrics.xHeight};`,
`$font-weight: ${this.fontMetrics.fontWeight || 400};`,
''
].join('\n');
} else {
// Legacy format
const { fontSizes, lineHeights, baselineUnit } = config;
// const spAfter = config.spAfter; // Commented out as unused
return [
'// AUTO-GENERATED FILE. DO NOT EDIT DIRECTLY.',
'// Generated by @lyubomir-popov/baseline-nudge-generator',
'',
`$baseline-unit: ${baselineUnit}rem;`,
`$font-sizes: ${this.generateScssMap(fontSizes, v => v + 'rem')};`,
`$line-heights: ${this.generateScssMap(lineHeights, v => v + ' * $baseline-unit')};`,
`$nudges: ${this.generateScssMap(nudges, v => v + 'rem')};`,
`$space-after: ${this.generateScssMap(spAfters, v => v + 'rem')};`,
`$font-ascent: ${this.fontMetrics.ascent};`,
`$font-descent: ${this.fontMetrics.descent};`,
`$font-line-gap: ${this.fontMetrics.lineGap};`,
`$font-units-per-em: ${this.fontMetrics.unitsPerEm};`,
`$font-cap-height: ${this.fontMetrics.capHeight};`,
`$font-x-height: ${this.fontMetrics.xHeight};`,
`$font-weight: ${this.fontMetrics.fontWeight || 400};`,
''
].join('\n');
}
}
generateFile(inputPath, outputPath) {
if (!fs.existsSync(inputPath)) {
throw new Error(`Input file not found: ${inputPath}`);
}
const config = JSON.parse(fs.readFileSync(inputPath, 'utf8'));
// Load font metrics if fontFile is specified (works for both new and legacy formats)
if (config.fontFile && !this.fontMetrics) {
const inputDir = path.dirname(inputPath);
const fontPath = this.findFontFile(inputDir, config.fontFile);
if (fontPath) {
// This is sync version for legacy compatibility
try {
const fontBuffer = fs.readFileSync(fontPath);
const fontkit = require('fontkit');
const font = fontkit.open(fontBuffer.buffer);
this.fontMetrics = {
ascent: font.tables.hhea.ascender,
descent: font.tables.hhea.descender,
lineGap: font.tables.hhea.lineGap,
unitsPerEm: font.unitsPerEm,
capHeight: (font.tables.os2 && font.tables.os2.sCapHeight) || font.tables.hhea.ascender * 0.7,
xHeight: (font.tables.os2 && font.tables.os2.sxHeight) || font.tables.hhea.ascender * 0.5
};
console.log(`✅ Loaded font metrics from: ${path.basename(fontPath)}`);
console.log(` Ascent: ${this.fontMetrics.ascent}, Descent: ${this.fontMetrics.descent}, UnitsPerEm: ${this.fontMetrics.unitsPerEm}`);
} catch (error) {
console.warn(`⚠️ Could not read font metrics: ${error.message}`);
}
}
}
const scss = this.generateScss(config);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(outputPath, scss);
console.log(`✅ Generated: ${outputPath}`);
return { nudges: this.generateNudges(config).nudges, outputPath };
}
watch(inputPath, outputPath) {
const chokidar = require('chokidar');
console.log(`👀 Watching: ${inputPath}`);
this.generateFile(inputPath, outputPath);
const watcher = chokidar.watch(inputPath, {
ignored: /(^|[/\\])\../,
persistent: true,
usePolling: true,
interval: 1000
});
watcher.on('change', () => {
console.log('🔄 Configuration changed, regenerating...');
try {
this.generateFile(inputPath, outputPath);
} catch (error) {
console.error('❌ Error regenerating:', error.message);
}
});
console.log('🚀 Watcher ready');
return watcher;
}
}
module.exports = { BaselineNudgeGenerator };