Skip to the content
W05NotesEngines from nothing

Glyph, a font renderer written from nothing

Glyph reads a font file and draws its let­ters with­out the browser’s help: the tables, the out­lines, the axes of a vari­able font, and the pixels. It was writ­ten from an empty file and the spec­i­fi­ca­tions, 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.

PostedKindNotesSeriesEngines from nothing
Glyph
Your browser
Where they differ, made four times stronger

·

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 let­ters at the same weight and op­ti­cal size. Each letter stands on a whole pixel and is not kerned, on both sides.

A table of tables

A TrueType font is a di­rec­tory of tables, each named by four let­ters. cmap maps char­ac­ters to glyph num­bers. loca says where each glyph’s out­line starts in glyf. hmtx gives each glyph its ad­vance, the dis­tance to the next letter. A vari­able 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 de­coder from the note before, whose in­flate is the same DEFLATE. These are the tables of the file this page loaded, with how many bytes each took on the net­work and in memory.

    ·

    Points on and off the curve

    An out­line in glyf is a list of con­tours, and a con­tour 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 be­tween its neigh­bors into a qua­dratic Bézier. When two points off the curve stand next to each other, the font saves a point: the one on the curve be­tween them is im­plied, halfway, and never stored. The co­or­di­nates them­selves are stored as dif­fer­ences from the point before, in one or two bytes each, with flags saying which.

    The tuples in gvar
      Filled dots are on the curve, rings are off it, and small dots are the implied points between two rings. The dashed outline is the letter where the font was drawn, at the lightest weight and smallest optical size, and the red lines show how far each point has moved from there.

      Moving the points

      A vari­able font stores one out­line and the ways it can change. A po­si­tion on the axes is first nor­mal­ized, so the de­fault is zero and the ends are minus one and one, and then bent by avar, which lets the de­signer space the weights evenly to the eye rather than by the num­bers. Inter’s own bend is slight: it leaves op­ti­cal size straight and nudges the weight axis by the small­est 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 noth­ing at the edge of its region, and the weights of all the axes are mul­ti­plied. 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 slid­ers stand.

      To save space, a tuple may leave points out. Those move with their neigh­bors on the same con­tour: a point be­tween two that moved, on one axis at a time, gets a delta in­ter­po­lated by where it lies be­tween them, and a point out­side them takes the delta of the nearer one. The spec­i­fi­ca­tion calls this in­ter­po­la­tion of un­touched points. The first ver­sion of Glyph got one corner of it wrong. When the two neigh­bors have the same co­or­di­nate, their delta counts only if they agree, wher­ever the point be­tween them lies. Glyph had tested for the point lying out­side them first, and moved the top of the J too far at the bold­est weight and largest op­ti­cal size.

      From outline to pixels

      The first ras­ter­izer added up areas. Each edge of the out­line adds to the pixels it crosses the area be­tween it and the pixel’s edge, signed by its di­rec­tion, and a run­ning sum along the row turns those areas into cov­er­age. It is exact for a simple shape and fast, and it drew Inter’s e wrong.

      Nonzero
      Even and odd
      The same outline of e, filled by the two rules a path can be filled by. Under even and odd, the part of the bar that runs into the bowl turns hollow, because it is inside the outline twice.

      Inter’s e is one con­tour that crosses itself: the bar runs on into the bowl, so a strip of it lies inside the out­line twice. Areas add up to two there, and a pixel on the edge of that strip is counted darker than it is. Type de­sign­ers draw let­ters this way on pur­pose, and a vari­able font keeps the over­laps, be­cause re­mov­ing them would break the way the points move.

      So Glyph now draws as the browser’s own rule says, by count­ing. Every row of pixels is crossed by six­teen 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 sam­pled six­teen times; across, it is exact.

      Held against Chromium

      The com­mand draws every char­ac­ter in the font at five sizes and seven weights three ways: by Chromium’s canvas from the font file, by Skia, Chromium’s graph­ics li­brary, fill­ing the out­line Glyph read, and by Glyph alone. Where two draw­ings of a pixel differ by more than a tenth, it asks Chromium itself which is right: isPointInPath tests a single point against the out­line ex­actly, and the pixel is sam­pled 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 draw­ings agree closely but not ex­actly, and the run shows on whose side the dif­fer­ence 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 out­line al­ready 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 dif­fer­ence is a shade on one pixel of an edge.

      The second thing the run shows is that a canvas does not leave the op­ti­cal size alone. Asked for Inter at a size, Chromium sets the op­ti­cal size axis to that size, so large text gets the tighter, finer let­ters the de­signer drew for it. Glyph does the same, and the run shows what hap­pens if it does not.

      What it does not have

      Glyph reads TrueType out­lines and noth­ing else. It leaves out the PostScript out­lines 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 kern­ing from GPOS, no lig­a­tures or al­ter­nates from GSUB, and no right to left. A line is its let­ters side by side. It is writ­ten to be read and to be right.

      The source is in the port­fo­lio’s repos­i­tory under web/notes/glyph/: glyph.js for the font, the out­lines, the vari­a­tions and the ras­ter­izer, canvas.mjs for the com­mand, glyph.test.mjs for the tests. Inter is Rasmus Andersson’s, under the SIL Open Font License.