What's in the Font? Reading the Packing List
Prerequisites: This post assumes you have loaded a font with
@fontand drawn a glyph withPathBlock.fromGlyph. If not, start with From Fonts to Paths. The full reference for everything below is the Fonts page.
A font file is a box of drawings with a packing list taped inside the
lid. Each drawing, a glyph, is the shape of one character, and the
packing list says which character each glyph is for. Until now, Pathogen
let you reach into the box only for a drawing you could already name:
PathBlock.fromGlyph("A", styles) works when you know to ask for A.
Three functions read the packing list itself. Font.characters returns
the characters a font has, as a string. Font.glyphs returns them as
drawable glyphs. Font.info describes the whole box: names, sizes, and
counts by writing system. That's what you need to check that a font
covers your readers' languages before you commit to it, to build a
character picker, or to make a generative piece out of every glyph a
font has. This post climbs from a ten-character string
to a type specimen that reads everything it shows from the font.
What to know first
- The numbers here come from one file. Every sample uses Raleway
Bold, compiled in the CLI from the font file in this project's
fonts/folder. Open a sample in the playground and Google Fonts serves its own current release of Raleway, which lists 856 characters rather than 936. Same functions, different box. - Some glyphs aren't on the packing list. A font can hold drawings no
single character points to: ligatures such as a joined
fi, alternate shapes, letters that join differently mid-word. Raleway Bold has 81 of them, plus one every font carries: the empty box drawn for a character it doesn't have. That's why 936 characters and 81 extras come to 1,018 glyphs.Font.infocounts them asunmappedCount, but none of the three functions can hand them to you yet. - Big fonts are big. A Chinese font such as Noto Sans SC maps more
than 13,000 characters, and
Font.glyphswarns past 5,000. In the playground, where Google delivers a large font in pieces,Font.infoand any category selection (such as"digit") read the whole font: about 4 MB for Noto Sans SC. A script, block or code-point selection downloads only the pieces it touches. - The VS Code preview doesn't load fonts yet, so
Fontcalls, likefromGlyph, work in the CLI and the playground but not there.
The font writes the string
fromGlyph takes a string of characters and returns one glyph per
character. In the top row below, you typed that string. In the bottom
row, the font wrote it: Font.characters(styles, "digit") returns every
digit the font has, as an ordinary string, and the string goes into
fromGlyph in exactly the same place.
//-- Two rows, one call: fromGlyph draws a string of characters. In the top
//-- row you typed the string; in the bottom row the font wrote it.
define ViewBox(0, 0, 520, 230);
@font "Raleway" 700;
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = Color(CSSVar('--accent', #3a64b8));
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 520, 230);
}
let rules = PathLayer('rules') #{
fill: none;
stroke: fg_hair;
stroke-width: 0.5;
};
let typedRow = PathLayer('typed-glyphs') #{
fill: fg_muted;
stroke: none;
};
let askedRow = PathLayer('asked-glyphs') #{
fill: accent;
stroke: none;
};
let eyebrow = TextLayer('eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
};
let code = TextLayer('code') #{
font-family: monospace;
font-size: 10;
fill: fg_auto;
};
let answer = TextLayer('answer') #{
font-family: monospace;
font-size: 10;
fill: accent;
};
// ─── Components ────────────────────────────────────────────────
let glyphRows = GroupLayer('glyph-rows');
glyphRows.append(typedRow, askedRow);
let captions = GroupLayer('captions');
captions.append(rules, eyebrow, code, answer);
// ─── The glyphs ────────────────────────────────────────────────
let styles = #{
font-family: Raleway;
font-weight: 700;
font-size: 40;
};
// Before: you type the characters you already know about.
let typed = '0123456789';
// After: the font lists the characters it has.
let asked = Font.characters(styles, 'digit');
// Draws a string's glyphs left to right from a baseline point.
fn drawRow(target, characters, left, baseline) {
target.apply {
let x = left;
for (glyph in PathBlock.fromGlyph(characters, styles)) {
M x baseline glyph.draw()
x = calc(x + glyph.advanceWidth);
}
}
}
let left = 30;
let topBaseline = 92;
let bottomBaseline = 192;
drawRow(typedRow, typed, left, topBaseline);
drawRow(askedRow, asked, left, bottomBaseline);
// ─── Labels and baselines ──────────────────────────────────────
rules.apply {
M 15 topBaseline
H 505
M 15 bottomBaseline
H 505
}
eyebrow.apply {
text(left, 32)`TYPED BY HAND`;
text(left, 132)`ASKED OF THE FONT`;
}
code.apply {
text(left, 48)`PathBlock.fromGlyph("0123456789", styles)`;
text(left, 148)`PathBlock.fromGlyph(Font.characters(styles, "digit"), styles)`;
}
// What Font.characters handed back: an ordinary string.
answer.apply {
text(290, 180)`returns "${asked}", ${asked.length} characters`;
}
The two rows match, and that is the point: nothing downstream of the string changed. The difference is who knew which characters exist. For digits you could have typed them yourself. For a font's Cyrillic, or every accented capital it carries, you couldn't, and that is where the font's own list earns its place.
"digit" is a category, the kind of character something is. There are eight: letter, mark (a combining accent), digit, number (other numerals such as ½ or ²), punctuation, symbol, whitespace and other. The categories come from Unicode, not from the font, which is why every font answers the same question the same way.
(The digits dip and rise because Raleway's figures are old-style: some sit below the line and some climb above it, like lowercase letters. That's the design, not a layout bug.)
Reading the packing list
Font.info(styles) returns an object describing the font. Its
scripts property lists the writing systems the font covers, Latin,
Cyrillic, Greek and so on, each with a count, most characters first.
//-- Font.info lists the writing systems a font covers, with a count for
//-- each. One bar per script, longest first, plus a few of its glyphs.
define ViewBox(0, 0, 520, 300);
@font "Raleway" 700;
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = Color(CSSVar('--accent', #3a64b8));
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 520, 300);
}
let track = PathLayer('track') #{
fill: fg_hair;
stroke: none;
};
let bars = PathLayer('bars') #{
fill: accent;
stroke: none;
};
let samples = PathLayer('sample-glyphs') #{
fill: fg_auto;
stroke: none;
};
let eyebrow = TextLayer('eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
};
let heading = TextLayer('heading') #{
font-family: font;
font-size: 22;
font-weight: 200;
fill: fg_auto;
};
let names = TextLayer('script-names') #{
font-family: font;
font-size: 10;
font-weight: 700;
letter-spacing: 1;
fill: fg_auto;
};
let counts = TextLayer('counts') #{
font-family: font;
font-size: 10;
fill: fg_muted;
text-anchor: end;
};
let glyphNames = TextLayer('glyph-names') #{
font-family: font;
font-size: 8;
fill: fg_muted;
};
// ─── Components ────────────────────────────────────────────────
let chart = GroupLayer('chart');
chart.append(track, bars, names, counts);
let glyphSamples = GroupLayer('glyph-samples');
glyphSamples.append(samples, glyphNames);
let captions = GroupLayer('captions');
captions.append(eyebrow, heading);
// ─── Ask the font ──────────────────────────────────────────────
let styles = #{
font-family: Raleway;
font-weight: 700;
font-size: 20;
};
let info = Font.info(styles);
eyebrow.apply {
text(15, 30)`FONT.INFO(STYLES).SCRIPTS`;
}
heading.apply {
text(15, 58)`${info.fullName}: ${info.characterCount} characters`;
}
// ─── One row per script ────────────────────────────────────────
let nameColumn = 15;
let barLeft = 100;
let barWidth = 220;
let countColumn = 360;
let sampleColumn = 375;
let rowTop = 82;
let rowHeight = 34;
let most = info.scripts[0].count;
let carrier = PathBlock.fromGlyph('o', styles)[0];
for ([script, i] in info.scripts) {
let y = calc(rowTop + i * rowHeight);
let filled = calc(barWidth * script.count / most);
track.apply {
rect(barLeft,
calc(y + 8),
barWidth,
6);
}
bars.apply {
rect(barLeft,
calc(y + 8),
max(filled, 1.5),
6);
}
names.apply {
text(nameColumn, calc(y + 15))`${script.name}`;
}
counts.apply {
text(countColumn, calc(y + 15))`${script.count}`;
}
// The first few glyphs of this script that draw something. An accent
// (category 'mark') has no width of its own and nothing to sit on, so each one
// is centred over an "o".
samples.apply {
let x = sampleColumn;
let shown = 0;
for (glyph in Font.glyphs(styles, script.name)) {
if (shown < 6 && !glyph.isEmpty) {
if (glyph.category == 'mark') {
let box = glyph.boundingBox();
M x calc(y + 17) carrier.draw()
M calc(x + carrier.advanceWidth / 2 - box.x - box.width / 2) calc(y + 17) glyph.draw()
x = calc(x + carrier.advanceWidth + 3);
} else {
M x calc(y + 17) glyph.draw()
x = calc(x + glyph.advanceWidth + 3);
}
shown = shown + 1;
}
}
}
// A script with one character gets its glyph's name, so a lone small
// mark reads as deliberate.
if (script.count == 1) {
let lone = Font.glyphs(styles, script.name)[0];
glyphNames.apply {
text(calc(sampleColumn + lone.advanceWidth + 8), calc(y + 15))`← ${lone.glyphName}`;
}
}
}
Three of those names aren't writing systems. Common holds characters
many scripts share: most punctuation, the digits 0–9, currency signs.
Inherited holds combining accents, which take on the script of
whatever letter they sit on. Unknown holds characters Unicode gives
no meaning to: Raleway's single one sits at a private-use code point,
one Unicode leaves free for a font to use as it likes, and holds a
helper glyph its designer named commaaccent.
Look twice at the Cyrillic row, too. Its first characters, Ѐ and Ё, are drawn exactly like Latin È and Ë, and Ѕ like S. Same shape, different character, which is why a font lists them separately.
A count tells you a script is present, not that it is complete. Raleway's
five Greek characters aren't an alphabet. They are Δ, π, μ and two
different Ω, the Greek letter and the ohm sign, kept for mathematics and
units. When completeness matters, look at blocks instead. A block
is a fixed, named stretch of the Unicode chart, so each entry can say how
much of it the font fills:
@font "Raleway" 700;
let info = Font.info(#{ font-family: Raleway; font-weight: 700; });
for (block in info.blocks) {
log(`${block.name}: ${block.count} of ${block.size}`);
}
// Basic Latin: 97 of 128, Latin-1 Supplement: 96 of 128, …
Read size with care: it counts every code point in the block, including
invisible control codes no font draws. Latin-1 Supplement's first 32 are
controls, so 96 of 128 means Raleway has every printable character in
the block (the next sample shows all 96), and Basic Latin's 97 is every
printable character there too.
Asking for part of the list
Both Font.characters and Font.glyphs take an optional second
argument, a selection, that narrows the answer. A name selects a
script ("Cyrillic"), a block ("Latin-1 Supplement") or a category
("punctuation"). Misspell one and the error suggests the closest real
name.
A selection can also be a list of code points. A code point is the
number Unicode gives every character: A is 65, ж is 1078. Capitals
aren't one of the eight categories, but within one alphabet they are
usually a run of numbers, so a range selects them. Usually: Russian's
Ё sits apart at 1025, so the А–Я range below draws 32 of its 33
capitals. To include it, list code points yourself: any array of them,
such as [1025, 1040, 1041], works as a selection.
//-- A selection can be a list of code points. Capitals aren't a category,
//-- but they are a range: A–Z is 65–90, and А–Я is 1040–1071.
define ViewBox(0, 0, 520, 285);
@font "Raleway" 700;
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let latinColor = Color(CSSVar('--latin', #3a64b8));
let cyrillicColor = Color(CSSVar('--cyrillic', #a8452f));
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 520, 285);
}
let cells = PathLayer('cells') #{
fill: none;
stroke: fg_hair;
stroke-width: 0.5;
};
let latinGlyphs = PathLayer('latin-glyphs') #{
fill: latinColor;
stroke: none;
};
let cyrillicGlyphs = PathLayer('cyrillic-glyphs') #{
fill: cyrillicColor;
stroke: none;
};
let eyebrow = TextLayer('eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
};
let selection = TextLayer('selection') #{
font-family: monospace;
font-size: 10;
fill: fg_auto;
};
let codePoints = TextLayer('code-points') #{
font-family: font;
font-size: 7.5;
letter-spacing: 0.3;
fill: fg_muted;
text-anchor: middle;
};
// ─── Components ────────────────────────────────────────────────
let grids = GroupLayer('grids');
grids.append(cells, latinGlyphs, cyrillicGlyphs, codePoints);
let captions = GroupLayer('captions');
captions.append(eyebrow, selection);
let styles = #{
font-family: Raleway;
font-weight: 700;
font-size: 20;
};
// ─── A grid of glyphs, each centred in a cell over its code point ─
let left = 28;
let cellWidth = 29;
let cellHeight = 38;
let columns = 16;
fn drawGrid(target, glyphs, top) {
for ([glyph, i] in glyphs) {
let cellLeft = calc(left + i % columns * cellWidth);
let cellTop = calc(top + floor(i / columns) * cellHeight);
let baseline = calc(cellTop + 21);
cells.apply {
rect(cellLeft, cellTop, cellWidth, cellHeight);
}
target.apply {
M calc(cellLeft + (cellWidth - glyph.advanceWidth) / 2) baseline glyph.draw()
}
codePoints.apply {
text(calc(cellLeft + cellWidth / 2), calc(cellTop + 34))`${glyph.codePoint}`;
}
}
}
let latinCapitals = Font.glyphs(styles, (65..90));
let cyrillicCapitals = Font.glyphs(styles, (1040..1071));
eyebrow.apply {
text(left, 30)`LATIN CAPITALS · ${latinCapitals.length} GLYPHS`;
text(left, 164)`CYRILLIC CAPITALS · ${cyrillicCapitals.length} GLYPHS`;
}
selection.apply {
text(left, 44)`Font.glyphs(styles, (65..90))`;
text(left, 178)`Font.glyphs(styles, (1040..1071))`;
}
drawGrid(latinGlyphs, latinCapitals, 54);
drawGrid(cyrillicGlyphs, cyrillicCapitals, 188);
To combine conditions, pass an object. Every key must match, so this asks for the letters in the Latin-1 Supplement block, leaving out its punctuation, symbols and fractions (65 of Raleway's 96; the specimen at the end uses exactly this):
let accentedLetters = Font.glyphs(styles, { block: "Latin-1 Supplement", category: "letter" });
The keys are script, block, category and codePoints. The
Selections reference has the full rules.
Glyphs that know what they are
Every glyph Font.glyphs returns, and every glyph from fromGlyph,
now carries what Unicode says about its character: script, block,
category, isLetter, isDigit, isPunctuation, isSymbol, and the
glyphName the designer gave it, alongside the older char,
codePoint, isMark and isEmpty. So code can sort glyphs by kind
without a hand-made table of which character is which.
Below, the whole Latin-1 Supplement block is laid out like a code chart, one cell per code point, and each glyph picks its layer, and so its colour, from its own properties.
//-- Every glyph knows what kind of character it is. The Latin-1
//-- Supplement block, laid out like a code chart, tinted by category.
define ViewBox(0, 0, 520, 312);
@font "Raleway" 700;
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let letterColor = Color('#0d1638').alpha(0.45);
let punctuationColor = Color(CSSVar('--punctuation', #b07a12));
let symbolColor = Color(CSSVar('--symbol', #3a64b8));
let numberColor = Color(CSSVar('--number', #17818a));
let otherColor = Color(CSSVar('--other', #8a3c8a));
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 520, 312);
}
let cells = PathLayer('cells') #{
fill: none;
stroke: fg_hair;
stroke-width: 0.5;
};
let letters = PathLayer('letters') #{
fill: letterColor;
stroke: none;
};
let punctuation = PathLayer('punctuation') #{
fill: punctuationColor;
stroke: none;
};
let symbols = PathLayer('symbols') #{
fill: symbolColor;
stroke: none;
};
let numbers = PathLayer('numbers') #{
fill: numberColor;
stroke: none;
};
let others = PathLayer('others') #{
fill: otherColor;
stroke: none;
};
let eyebrow = TextLayer('eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
};
let selection = TextLayer('selection') #{
font-family: monospace;
font-size: 10;
fill: fg_auto;
};
let emptyLabels = TextLayer('empty-labels') #{
font-family: font;
font-size: 7.5;
letter-spacing: 0.8;
fill: otherColor;
text-anchor: middle;
};
// A second signal besides colour: each non-letter cell carries its
// category's initial in the corner, keyed in the legend.
let kindMarks = TextLayer('kind-marks') #{
font-family: font;
font-size: 7.5;
font-weight: 700;
fill: fg_muted;
text-anchor: end;
};
let legend = TextLayer('legend') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
};
// ─── Components: one layer per category, so each takes its own fill ─
let chart = GroupLayer('chart');
chart.append(cells,
letters,
punctuation,
symbols,
numbers,
others,
emptyLabels,
kindMarks);
let captions = GroupLayer('captions');
captions.append(eyebrow, selection, legend);
let styles = #{
font-family: Raleway;
font-weight: 700;
font-size: 20;
};
// ─── The block, one cell per code point ────────────────────────
let left = 28;
let top = 62;
let cellWidth = 29;
let cellHeight = 34;
let block = Font.glyphs(styles, 'Latin-1 Supplement');
let firstCodePoint = block[0].codePoint;
eyebrow.apply {
text(left, 30)`LATIN-1 SUPPLEMENT · ${block.length} GLYPHS · TINTED BY CATEGORY`;
}
selection.apply {
text(left, 46)`Font.glyphs(styles, "Latin-1 Supplement")`;
}
let letterCount = 0;
let punctuationCount = 0;
let symbolCount = 0;
let numberCount = 0;
let otherCount = 0;
for (glyph in block) {
let offset = glyph.codePoint - firstCodePoint;
let cellLeft = calc(left + offset % 16 * cellWidth);
let cellTop = calc(top + floor(offset / 16) * cellHeight);
cells.apply {
rect(cellLeft, cellTop, cellWidth, cellHeight);
}
// Pick a layer from what the glyph says about itself.
// (Latin-1 has no plain digits: ¹ ² ³ and the fractions are 'number'.)
let target = others;
let mark = 'O';
if (glyph.isLetter) {
target = letters;
mark = '';
letterCount = letterCount + 1;
} else if (glyph.isPunctuation) {
target = punctuation;
mark = 'P';
punctuationCount = punctuationCount + 1;
} else if (glyph.isSymbol) {
target = symbols;
mark = 'S';
symbolCount = symbolCount + 1;
} else if (glyph.category == 'number') {
target = numbers;
mark = 'N';
numberCount = numberCount + 1;
} else {
otherCount = otherCount + 1;
}
if (mark != '') {
kindMarks.apply {
text(calc(cellLeft + cellWidth - 3), calc(cellTop + 9))`${mark}`;
}
}
if (glyph.isEmpty) {
// A space draws nothing: show its code point instead.
emptyLabels.apply {
text(calc(cellLeft + cellWidth / 2), calc(cellTop + 20))`${glyph.codePoint}`;
}
} else {
target.apply {
M calc(cellLeft + (cellWidth - glyph.advanceWidth) / 2) calc(cellTop + 24) glyph.draw()
}
}
}
// ─── Legend, from the counts gathered above ─────────────────────
let legendY = calc(top + 6 * cellHeight + 26);
fn legendEntry(target, x, label) {
target.apply {
rect(x,
calc(legendY - 7),
8,
8);
}
legend.apply {
text(calc(x + 12), legendY)`${label}`;
}
}
// Five entries spaced evenly across the chart's width.
let legendStep = 16 * cellWidth / 5;
legendEntry(letters, left, `letter ${letterCount}`);
legendEntry(punctuation, calc(left + legendStep), `P punctuation ${punctuationCount}`);
legendEntry(symbols, calc(left + 2 * legendStep), `S symbol ${symbolCount}`);
legendEntry(numbers, calc(left + 3 * legendStep), `N number ${numberCount}`);
legendEntry(others, calc(left + 4 * legendStep), `O space/other ${otherCount}`);
A few cells are worth a second look. ª and º are letters, not symbols:
Spanish and Portuguese use them in ordinals such as 1º. The superscript
digits ¹ ² ³ and the fractions are numbers, not digits, because
digit means the ten plain digits of a script. µ is a letter in the
Common script: the micro sign is a different character from the Greek μ in
the Greek block, and a font draws the two separately. The first cell,
160, is the no-break space, which draws nothing, and the dash marked O is
the soft hyphen, an invisible line-break hint that this font gives a
visible shape.
A specimen that reads itself
A type specimen is the sheet a foundry (the company that makes and sells a typeface) prints to show off a font: its name, its proportions, its characters, its reach. Here is one where every part comes from the font, so nothing on it was typed in from a datasheet.
//-- A one-page type specimen, every part of it read from the font: its
//-- name, its vertical metrics, its Latin-1 letters and the writing
//-- systems it covers. Change `family` and it redraws.
define ViewBox(0, 0, 520, 470);
let family = 'Raleway';
@font family 700;
// ─── Core tokens ───────────────────────────────────────────────
let bg_color = Color(CSSVar('--bg', #d0d7f0));
let fg_auto = Color('#0d1638');
let fg_muted = Color('#0d1638').alpha(0.6);
let fg_hair = Color('#0d1638').alpha(0.22);
let accent = Color(CSSVar('--accent', #3a64b8));
let guideColor = Color(CSSVar('--guide', #a8452f));
let font = 'sans-serif';
let bg = PathLayer('bg') #{
fill: bg_color;
stroke: none;
};
bg.apply {
rect(0, 0, 520, 470);
}
let ink = PathLayer('ink') #{
fill: fg_auto;
stroke: none;
};
let guides = PathLayer('guides') #{
fill: none;
stroke: guideColor;
stroke-width: 0.5;
stroke-dasharray: 3 4;
};
let rules = PathLayer('rules') #{
fill: none;
stroke: fg_hair;
stroke-width: 0.5;
};
let track = PathLayer('track') #{
fill: fg_hair;
stroke: none;
};
let bars = PathLayer('bars') #{
fill: accent;
stroke: none;
};
let eyebrow = TextLayer('eyebrow') #{
font-family: font;
font-size: 8;
font-weight: 700;
letter-spacing: 3;
fill: fg_muted;
};
let guideLabels = TextLayer('guide-labels') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: guideColor;
text-anchor: end;
};
let small = TextLayer('small') #{
font-family: font;
font-size: 8;
letter-spacing: 0.5;
fill: fg_auto;
};
// ─── Components ────────────────────────────────────────────────
let type = GroupLayer('type');
type.append(ink);
let metricGuides = GroupLayer('metric-guides');
metricGuides.append(guides, guideLabels);
let coverage = GroupLayer('coverage');
coverage.append(track, bars);
let captions = GroupLayer('captions');
captions.append(rules, eyebrow, small);
// One style per size. Font.info scales every metric to its font-size.
fn styleAt(size) {
return #{
font-family: family;
font-weight: 700;
font-size: size;
};
}
// The page: content stays inside these edges. Every section below
// measures the font and takes the room it needs, top to bottom.
let left = 20;
let right = 500;
let bottom = 452;
// Draws a string in the font from a baseline point.
fn drawWord(word, size, x, baseline) {
let pen = x;
ink.apply {
for (glyph in PathBlock.fromGlyph(word, styleAt(size))) {
M pen baseline glyph.draw()
pen = calc(pen + glyph.advanceWidth);
}
}
}
// How wide a string sets at a size, and the largest size (up to `size`)
// at which it fits in `room`. Widths scale with font-size.
fn wordWidth(word, size) {
let width = 0;
for (glyph in PathBlock.fromGlyph(word, styleAt(size))) {
width = calc(width + glyph.advanceWidth);
}
return width;
}
fn fitSize(word, size, room) {
return min(size, size * room / wordWidth(word, size));
}
// ─── 1. The name, set in the font itself ───────────────────────
let info = Font.info(styleAt(34));
eyebrow.apply {
text(left, 28)`TYPE SPECIMEN · ${info.characterCount} CHARACTERS · ${info.glyphCount} GLYPHS`;
}
drawWord(info.fullName,
fitSize(info.fullName, 34, right - left),
left,
66);
// ─── 2. Vertical metrics, drawn as guide lines ─────────────────
// The sample word gets 300 units of width, leaving room for the labels;
// the baseline sits as far down as this font's ascender needs.
let metricSize = fitSize('Hxgp', 84, 300);
let metrics = Font.info(styleAt(metricSize));
let metricsTop = 96;
let baseline = calc(metricsTop + 12 + metrics.ascender);
eyebrow.apply {
text(left, metricsTop)`VERTICAL METRICS · FONT-SIZE ${round(metricSize)} · ${metrics.unitsPerEm} UNITS PER EM`;
}
drawWord('Hxgp',
metricSize,
calc(left + 10),
baseline);
// Top to bottom. Each label sits just above its line; when that would
// crowd the label before it (cap height close to the ascender, or equal
// to the x-height in a capitals-only font), it moves below its line.
let metricLines = [
{
label: 'ascender',
height: metrics.ascender,
},
{
label: 'cap height',
height: metrics.capHeight,
},
{
label: 'x-height',
height: metrics.xHeight,
},
{
label: 'baseline',
height: 0,
},
{
label: 'descender',
height: metrics.descender,
},
];
let previousLabelY = -100;
for (line in metricLines) {
let y = calc(baseline - line.height);
guides.apply {
M left y
H right
}
let labelY = calc(y - 3);
if (labelY < previousLabelY + 10) {
labelY = max(calc(y + 10), calc(previousLabelY + 10));
}
guideLabels.apply {
text(right, labelY)`${line.label} ${round(line.height)}`;
}
previousLabelY = labelY;
}
// ─── 4 (laid out first). Coverage, pinned to the bottom edge ────
// Up to five scripts, one 18-unit row each.
let shownScripts = min(5, info.scripts.length);
let coverageTop = calc(bottom - 8 - (shownScripts - 1) * 18);
let barLeft = 100;
let barWidth = 360;
let most = info.scripts[0].count;
// ─── 3. Every Latin-1 letter, in the space left between ────────
let lettersRule = calc(baseline - metrics.descender + 14);
let lettersTop = calc(lettersRule + 24);
let lettersRoom = calc(coverageTop - 22 - 8 - lettersTop);
let selection = {
block: 'Latin-1 Supplement',
category: 'letter',
};
// Wraps the letters at `size` from the top of their space; draws them
// when asked, and returns the height the lines take.
fn setLetters(size, draw) {
let lineHeight = size * 1.5;
let x = left;
let y = calc(lettersTop + size);
for (glyph in Font.glyphs(styleAt(size), selection)) {
if (x + glyph.advanceWidth > right) {
x = left;
y = calc(y + lineHeight);
}
if (draw) {
ink.apply {
M x y glyph.draw()
}
}
x = calc(x + glyph.advanceWidth + size / 5);
}
return calc(y - lettersTop + lineHeight / 3);
}
// The largest size, up to 20, whose lines fit the space.
let letterSize = 10;
for (size in [
10,
12,
14,
16,
18,
20,
]) {
if (setLetters(size, false) <= lettersRoom) {
letterSize = size;
}
}
setLetters(letterSize, true);
rules.apply {
M left lettersRule
H right
}
eyebrow.apply {
text(left, calc(lettersRule + 16))`LATIN-1 LETTERS · ${Font.characters(styleAt(letterSize), selection).length} · FONT-SIZE ${letterSize}`;
}
// ─── 4. Coverage, drawn ────────────────────────────────────────
rules.apply {
M left calc(coverageTop - 22)
H right
}
eyebrow.apply {
text(left, calc(coverageTop - 6))`SCRIPTS`;
}
for ([script, i] in info.scripts) {
if (i < shownScripts) {
let y = calc(coverageTop + i * 18);
track.apply {
rect(barLeft,
calc(y + 3),
barWidth,
4);
}
bars.apply {
rect(barLeft,
calc(y + 3),
max(calc(barWidth * script.count / most), 1.5),
4);
}
small.apply {
text(left, calc(y + 8))`${script.name}`;
text(calc(barLeft + barWidth + 8), calc(y + 8))`${script.count}`;
}
}
}
From the top:
- The name is
info.fullName, drawn withfromGlyphin the font it names. It is measured first, by adding up its glyphs'advanceWidth, and set smaller if it wouldn't fit across the page. - The guide lines come from
Font.info's metrics. Type designers draw each letter on a square grid called the em; Raleway's is 1,000 units on a side, andfont-sizesets how big that square comes out.Font.infohas already scaled each metric to the style's font-size, socapHeightis how tall anHwill be in your drawing, and the guide line goes exactly there. The x-height is the height of a lowercase x; the ascender and descender are the room the font reserves above and below the baseline for tall letters and for the tails of g and p. The labels are in those same drawing units, at the font-size the eyebrow names. The baseline sits as low as this font's ascender needs, so a tall font gets a taller band. - The letters are one selection object:
{ block: "Latin-1 Supplement", category: "letter" }, wrapped using each glyph'sadvanceWidth. The specimen tries sizes from 10 to 20 and keeps the largest whose lines fit the space left over. - The scripts are the same
scriptslist as before, up to five, pinned to the bottom edge.
Because every section measures the font before taking its room, you can
change the family line at the top without touching the layout. It holds up for
a wide display face like Unbounded or Syncopate, for Bebas Neue, which
has only capitals (so its x-height line lands on its cap height, and
the labels stack instead of colliding), and for Noto Sans SC, where Han
tops the script list. In the playground, any Google Fonts family works;
one without a bold weight is drawn at its nearest weight, and the name
on the page says which (Bebas Neue comes out "Regular"). In the CLI the
font file needs to be on your disk; the
@font reference says where it
looks.
Where to go next
Open the specimen in the playground and set family to the font you're
considering. The Fonts page covers everything Font.info returns,
including the names (designer, license, version) and the full metrics
table, along with every selection rule and error message. If you'd rather
shape the glyphs than list them, From Fonts to
Paths cuts, offsets and rearranges
them.