Eli Heuer’s Blog

NeuralType:
A Post-OpenType
Font Format

index

The NeuralType font format (.ntf) is an experiment in what comes after OpenType: a font format where the font file is a small neural network that draws each letterform in context, instead of a table of fixed outlines to look up. The spec, research notes, and Rust engine are on GitHub at github.com/eliheuer/post-opentype, dual-licensed under Apache-2.0 or MIT.

The .ntf font in the demo below stores the letterforms only as the weights of a 53,600-parameter neural network; the file contains no outline data. Every letterform on the canvas is generated at the moment you type it: the letter, its joining form, and the elongation setting go into the network, the letterform comes out, and the engine traces it into vector outlines and fills it. It is still real text: click the canvas, place the cursor, type in Arabic or Latin capitals, select, copy, and paste. The elongation slider sets an input to the network; the stretched letterforms are generated, not substituted.

The same engine can load a very different .ntf file. The demo below is a second neural font in the same editor: nastaliq, the cascading Perso-Arabic style that OpenType handles worst, distilled from the OFL font Gulzar into a 1.36M-parameter model that generates each letterform and its position in the cascade from context. It runs a live training checkpoint that improves as training proceeds, so the letterforms may still be forming when you read this. There is a newer post specifically about this font: Nastaliq Distilled.

Section Index

01. A Font That Is a Neural Network

An OpenType font is a compression scheme for the Latin letter: one fixed outline per glyph, reused everywhere, in per-glyph cells that digital type inherited from metal sorts. Arabic in its manuscript forms, naskh and above all nastaliq, is one continuous, context-dependent stroke, and it has never fit this system well. The OpenType workaround is thousands of glyphs and substitution rules approximating what a hand does in one motion.

Any font can be described as a function. Rendering evaluates it once per character: the character and its context go in, a shape comes out, and a line of text is the function applied across the string.

glyph = f(character, context, parameters)

The difference between the two formats is how that function is computed. An OpenType font computes it by lookup. Every shape the font can produce was drawn ahead of time and stored in a table, and the layout rules (cmap, GSUB, GPOS) select among the stored shapes and position them. The output set is finite. If a script needs a form the tables do not contain, a person has to draw the glyph and write the rule, so contextual behavior costs one glyph and one rule per case, and the number of cases is limited by what a type designer can enumerate by hand. Many new AI tools help with this work, but it is still a massive amount of labor, and the result is still incapable of reproducing the best naskh and nastaliq manuscripts on the web.

Arabic fonts reduce each letter to four forms (isolated, initial, medial, final). The four positional categories are real, they come from the script itself, but written Arabic also varies each form by its neighbors and by elongation, and in OpenType every one of those variants costs another glyph and another rule.

A .ntf font replaces the lookup with a computation. The file stores the weights of a small neural network, and rendering runs the network once per glyph, per keystroke (a “forward pass”), followed by outline extraction. Here is he glyph function in the engine:

impl GlyphSource for NeuralFont {
/// Generate the glyph for one character in context.
/// A single forward pass through the network.
fn glyph(&self, ch: char, form: Form, elongation: f64) -> Option<GlyphImage> {
let ch = canonical_char(ch);
let letter = self.alphabet.iter().position(|&a| a == ch)?;
let input = encode_input(letter, form, elongation);
let output = self.mlp.forward(&input);
Some(decode_output(&output))
}
}

The character, its joining form, and the elongation value go in; the letterform comes out. The function does contain one lookup: it finds the character’s position in the font’s alphabet, which tells the network which letter to draw. That lookup retrieves a number, not a shape, because no shapes are stored.

Computing the function instead of storing its outputs matters because computed outputs do not need to be enumerated. In practice this means:

  • Continuous parameters. Justification by elongation is a scalar input, not a substituted glyph. Any learned axis (weight, slope, formality) works the same way: variable fonts without masters.
  • Open-ended context. Today the network sees the letter, its joining form, and the elongation. More context can be added as new inputs: the neighboring letters, the position in the word, the space left on the line. A table needs a glyph for every case; a function just needs the case as an input.
  • A small, uniform file. No table graph, no rule bytecode: a JSON header and a weight blob. The font in the demo is 215 KB of f32 weights, before any quantization.

“Generative” here does not mean random. The same text at the same settings produces identical outlines every time, on every machine. The shapes are computed, not retrieved.

02. How It Works: Teacher, Student, Tracer

The engine is Rust on the Linebender stack, compiled to WASM for the demo above. The pipeline has four stages:

  1. Shape. Unicode text becomes joining forms.
  2. Generate. One forward pass per glyph.
  3. Layout. Composite every generated form.
  4. Trace. The composited line becomes vector outlines.

The model learns its letterforms from a teacher program: hand-written code that draws each form as an ASCII-art grid in the Rust source. The network is trained until it reproduces the teacher’s output exactly. Each grid holds only the letter’s skeleton (the rasm); dots are added in a separate step, which is why the art below has none. ب, ت, and ث all share this skeleton. Two of beh’s four forms:

// Beh body (ب ت ث teeth family): open bowl; tooth when joined.
(Beh, Isolated) => art!(8, [
"#...#",
"#...#",
"#####",
]),
(Beh, Medial) => art!(8, [
".#.",
".#.",
"###",
]),

In the trained model, dots are generated, not input. The training set is every supported (letter, form, elongation) context, 437 samples. A small multilayer perceptron (an MLP, the simplest kind of neural network: layers of learned weights, each feeding the next) trains on it in a few minutes on CPU and reproduces all 437 exactly. The teacher never ships. The weights are the font.

The alphabet also carries Latin capitals, and elongation works for both scripts. Text direction is detected automatically.

03. The .ntf File

The format:

offset size content
0 4 magic "NTF0"
4 4 u32 LE: header length H
8 H JSON header (format, script, style, alphabet, layer sizes)
8+H ... raw little-endian f32 weights, layer by layer

Like OpenType, one format serves every script. Everything specific to a font, its letters, its inputs, its output, is declared in the header. The header is padded with trailing spaces so the weights start on a 4-byte boundary; a renderer can map the file into memory and read the weights in place.