Glyph, a font renderer written from nothing
Glyph reads a font file and draws its letters without the browser’s help: the tables, the outlines, the axes of a variable font, and the pixels. It was written from an empty file and the specifications, and it reads the font this page is set in. Below, every line is drawn twice, once by Glyph and once by your browser, from the same bytes.
·
Both sides read the same file, the Inter this site is set in, served as WOFF. Your browser draws each letter with its canvas, and Glyph draws the same letters at the same weight and optical size. Each letter stands on a whole pixel and is not kerned, on both sides.
A table of tables
A TrueType font is a directory of tables, each named by four letters. cmap maps characters to glyph numbers. loca says where each glyph’s outline starts in glyf. hmtx gives each glyph its advance, the distance to the next letter. A variable font adds fvar, which names its axes, and gvar, which says how far every point moves along them.
WOFF wraps the same tables for the web and squeezes each one with zlib on its own, so Glyph hands them to Thaw, the PNG decoder from the note before, whose inflate is the same DEFLATE. These are the tables of the file this page loaded, with how many bytes each took on the network and in memory.
Points on and off the curve
An outline in glyf is a list of contours, and a contour is a list of points, each marked on or off the curve. Two points on the curve are joined by a straight line. A point off the curve pulls the line between its neighbors into a quadratic Bézier. When two points off the curve stand next to each other, the font saves a point: the one on the curve between them is implied, halfway, and never stored. The coordinates themselves are stored as differences from the point before, in one or two bytes each, with flags saying which.
Moving the points
A variable font stores one outline and the ways it can change. A position on the axes is first normalized, so the default is zero and the ends are minus one and one, and then bent by avar, which lets the designer space the weights evenly to the eye rather than by the numbers. Inter’s own bend is slight: it leaves optical size straight and nudges the weight axis by the smallest step the file can store.
Each glyph has a few tuples in gvar. A tuple is a set of deltas, one per point, and a peak: the place on the axes where the deltas count in full. Away from the peak a tuple counts less, falling to nothing at the edge of its region, and the weights of all the axes are multiplied. Every point moves by the sum of the deltas, each times its tuple’s weight. The list beside the letter shows its tuples and how much each counts where the sliders stand.
To save space, a tuple may leave points out. Those move with their neighbors on the same contour: a point between two that moved, on one axis at a time, gets a delta interpolated by where it lies between them, and a point outside them takes the delta of the nearer one. The specification calls this interpolation of untouched points. The first version of Glyph got one corner of it wrong. When the two neighbors have the same coordinate, their delta counts only if they agree, wherever the point between them lies. Glyph had tested for the point lying outside them first, and moved the top of the J too far at the boldest weight and largest optical size.
From outline to pixels
The first rasterizer added up areas. Each edge of the outline adds to the pixels it crosses the area between it and the pixel’s edge, signed by its direction, and a running sum along the row turns those areas into coverage. It is exact for a simple shape and fast, and it drew Inter’s e wrong.
Inter’s e is one contour that crosses itself: the bar runs on into the bowl, so a strip of it lies inside the outline twice. Areas add up to two there, and a pixel on the edge of that strip is counted darker than it is. Type designers draw letters this way on purpose, and a variable font keeps the overlaps, because removing them would break the way the points move.
So Glyph now draws as the browser’s own rule says, by counting. Every row of pixels is crossed by sixteen lines. On each line, Glyph finds where the edges cross it, sorts them, and walks along adding one for an edge going up and taking one for an edge going down. Wherever the count is not zero, the line is inside the letter, and each pixel gets the exact part of its width the inside covers. Down, the result is sampled sixteen times; across, it is exact.
Held against Chromium
The command draws every character in the font at five sizes and seven weights three ways: by Chromium’s canvas from the font file, by Skia, Chromium’s graphics library, filling the outline Glyph read, and by Glyph alone. Where two drawings of a pixel differ by more than a tenth, it asks Chromium itself which is right: isPointInPath tests a single point against the outline exactly, and the pixel is sampled with it on a grid that shares no row with Glyph’s.
$ node web/notes/glyph/canvas.mjs Chromium 141.0.7390.37, Inter as this site serves it: 89,588 bytes of WOFF, 196,280 once Thaw has unpacked its 19 tables 774 glyphs, 291 characters, axes opsz 14 to 32 and wght 400 to 700 every character at 12, 16, 24, 32, 48 px and weights 400 to 700 in steps of 50 Glyph against Chromium drawing the text 1,533,458 inked pixels, 51.2 % the same, 63.1 % within 2/255, mean difference 0.0172 at 12 px: 39.7 % within 2/255, mean difference 0.0261 at 16 px: 41.8 % within 2/255, mean difference 0.0264 at 24 px: 55.1 % within 2/255, mean difference 0.0197 at 32 px: 62.9 % within 2/255, mean difference 0.0170 at 48 px: 71.5 % within 2/255, mean difference 0.0141 51,860 off by more than a tenth: the samples side with Glyph on 51,858 and with Chromium on 2 the most: ※ at 48 px, weight 400: Glyph 0.24, Chromium 1, the samples 0.24 Chromium against itself: fillText against Skia filling the same outline 1,505,798 inked pixels, 61.5 % the same, 75.0 % within 2/255, mean difference 0.0067 9,160 off by more than a tenth: the samples side with Skia on 5,280 and with fillText on 3,880 Glyph against Skia filling the same outline 1,533,385 inked pixels, 54.1 % the same, 68.4 % within 2/255, mean difference 0.0158 48,781 off by more than a tenth: the samples side with Glyph on 48,778 and with Skia on 3 the most: ※ at 48 px, weight 400: Glyph 0.24, Skia 1, the samples 0.24 The optical size: at 48 px, fillText against Skia filling Glyph's outline at opsz 32, which Glyph takes from the size: mean difference 0.0050 at opsz 14, the font's default: mean difference 0.2283 Why: Skia filling the outline, against the samples, on every pixel at an edge, at 16 and 48 px, weight 400 as curves, cut up by Skia: 50,716 pixels, mean difference 0.0372, 4,329 off by more than a tenth cut into lines by Glyph first: 38,330 pixels, mean difference 0.0215, 243 off by more than a tenth
Where the browser and Glyph part
The drawings agree closely but not exactly, and the run shows on whose side the difference lies. Where they differ by more than a tenth, the exact test sides with Glyph nearly every time. The cause is the last block of the run. Skia does not fill a curve as a curve: it cuts it into straight pieces first, and coarsely, to be fast. Given the same outline already cut finely into lines, Skia’s pixels come close to the exact ones. Chromium is not wrong here, it chose speed, and at the size of text the difference is a shade on one pixel of an edge.
The second thing the run shows is that a canvas does not leave the optical size alone. Asked for Inter at a size, Chromium sets the optical size axis to that size, so large text gets the tighter, finer letters the designer drew for it. Glyph does the same, and the run shows what happens if it does not.
What it does not have
Glyph reads TrueType outlines and nothing else. It leaves out the PostScript outlines of an OpenType CFF font, color fonts and , whose tables are packed with Brotli. It does not hint, so small text is not snapped to the pixel grid, which is also how a canvas draws it. It does not shape: no kerning from GPOS, no ligatures or alternates from GSUB, and no right to left. A line is its letters side by side. It is written to be read and to be right.
The source is in the portfolio’s repository under web/notes/glyph/: glyph.js for the font, the outlines, the variations and the rasterizer, canvas.mjs for the command, glyph.test.mjs for the tests. Inter is Rasmus Andersson’s, under the SIL Open Font License.