What's in the Font? Reading the Packing List

Prerequisites: This post assumes you have loaded a font with @font and drawn a glyph with PathBlock.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.info counts them as unmappedCount, 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.glyphs warns past 5,000. In the playground, where Google delivers a large font in pieces, Font.info and 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 Font calls, like fromGlyph, 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`; } Top: ten digits typed by hand. Bottom: the same call, with the string supplied by Font.characters(styles, "digit"). The result is an ordinary ten-character string.

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}`; } } } Font.info(styles).scripts for Raleway Bold, one bar per script, with the first few glyphs of each drawn beside it. Accents (Inherited) are drawn over an o, since they have no width of their own.

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); Capitals by code-point range: A–Z is 65 to 90, А–Я is 1040 to 1071. Each glyph is centred in its cell over its code point.

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}`); Font.glyphs(styles, "Latin-1 Supplement") in code-point order, sixteen to a row, tinted by what each glyph says about itself. The legend counts are gathered in the same loop.

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}`; } } } Raleway Bold's specimen: the name set in the font itself, vertical metrics drawn as guide lines, every Latin-1 letter, and up to five of its scripts. Change the family line at the top and it redraws.

From the top:

  • The name is info.fullName, drawn with fromGlyph in 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, and font-size sets how big that square comes out. Font.info has already scaled each metric to the style's font-size, so capHeight is how tall an H will 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's advanceWidth. The specimen tries sizes from 10 to 20 and keeps the largest whose lines fit the space left over.
  • The scripts are the same scripts list 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.