A designer sends you a folder: BrandSans-Regular.otf, BrandSans-Bold.otf, and a PDF that nobody opens. You copy the two files into the theme, write six lines of @font-face, and the site looks the way the mockup looked. The licence you just agreed to is sitting inside those two files, in the name table under name ID 13, and for commercial fonts it very often says the font may be installed on a workstation and may not be served from a web server.
The second problem shows up on every visit. The page paints in Arial, the font lands 800 milliseconds later, the browser reflows. The web font has a bigger x-height and wider advances, a three line paragraph becomes four, and everything below it drops 28 pixels. Google records that as Cumulative Layout Shift. Your reader records it as the sentence they were reading sliding out from under their eyes.
Both answers are in the file. A font carries its own licence text, its own vertical metrics, its own character coverage, and if it is variable, its own axis ranges. None of it needs guessing, and none of it needs a service.
Read the file before you serve it
Drop a TTF, OTF, TTC, WOFF or WOFF2 on the font face generator below and it walks the table directory and prints what is there: family and subfamily, full name, version, designer and designer URL, manufacturer, licence description and licence URL, trademark, vendor ID and the embedding permission in fsType. It counts coverage out of the cmap, generates the @font-face block, and derives the four descriptors that stop the page moving when the font lands. It runs entirely in your browser: the bytes are read with a DataView in the tab, nothing is uploaded and nothing is fetched.
Font face and fallback generator
Drop a font file and read what is actually inside it: the name records, the vertical metrics, the italic angle, the variable axes and every code point it covers. Then take the finished @font-face block, the preload line, and a matched local fallback whose overrides hold the layout still while the web font is on its way. The file is read here in the browser tab with a DataView, byte by byte, and is never uploaded.
or press Enter to choose one.
TTF, OTF, WOFF and WOFF2.
Nothing loaded yet.
Hamburgefonstiv
The quick brown fox jumps over the lazy dog
0123456789 & @ # ? ! « » € £ — •
Drawn with a FontFace made from the bytes on your disk.
Drop a font file above to read its tables.
Every axis comes straight from the fvar table. Move a slider and the specimen above is redrawn with that instance.
Nothing loaded yet
height n/aType is measured, not guessed. This paragraph is set in the fallback stack exactly as a browser would show it while the web font is still loading.
height n/aType is measured, not guessed. This paragraph is set in the fallback stack exactly as a browser would show it while the web font is still loading.
height n/aThe dashed line marks the height of the web font block. The closer the other two blocks sit to it, the smaller the jump when the real font arrives.
Preload only the one file the first screen really needs, and only when it is served from your own origin. A preload that is never used is a wasted request, and crossorigin is required even for a same origin font because fonts are always fetched in CORS mode.
This tool cannot convert anything to WOFF2. WOFF2 is Brotli compressed, and no browser exposes a Brotli compressor, so the conversion has to happen on your machine. Run one of these, both from Google's woff2 and fonttools:
For the same reason a dropped WOFF2 can only be read down to its table list, never into the tables themselves. Drop the TTF or OTF it was built from to see every value on this page.
What is actually in a font file
An OpenType file is not a compressed blob. It opens with twelve bytes of header, then one fixed record per table, then the tables at the offsets those records name. That is the container format, and it has not changed in thirty years.
sfnt header, 12 bytes
0 uint32 sfntVersion 0x00010000 (TrueType) or 'OTTO' (CFF)
4 uint16 numTables
6 uint16 searchRange
8 uint16 entrySelector
10 uint16 rangeShift
table record, 16 bytes, numTables of them
0 char[4] tag 'head', 'cmap', 'OS/2', 'fvar', ...
4 uint32 checkSum
8 uint32 offset from the start of the file
12 uint32 length
A collection starts with ttcf and holds offsets to several fonts sharing glyph data. A WOFF starts with wOFF and a twenty byte record per table, each table stored raw or zlib compressed. A WOFF2 starts with wOF2 and everything after the header is Brotli, which matters later. Past the wrapper, these are the tables a web developer needs.
head:unitsPerEm, the grid every other number is expressed in. 1000 for most CFF fonts, 2048 for most TrueType ones. Also the font revision, the global bounding box,macStyle, and the created and modified timestamps, counted in seconds from 1 January 1904.hhea:ascender,descender,lineGapand the maximum advance width. One of three places the vertical metrics live.OS/2: the other two places,sTypoAscender/sTypoDescender/sTypoLineGapandusWinAscent/usWinDescent. AlsosCapHeightandsxHeightfrom version 2,usWeightClass,usWidthClass,fsSelection, ten PANOSE bytes andfsType.name: every human readable string, keyed by ID. Family is 1, subfamily 2, full name 4, version 5, trademark 7, manufacturer 8, designer 9, licence description 13, licence URL 14.post:italicAngle, underline position and thickness, and the fixed pitch flag that says whether it is genuinely monospaced.maxp:numGlyphs. A useful sanity check against file size before you decide subsetting is worth the trouble.cmap: the map from code point to glyph. Format 4 covers the Basic Multilingual Plane in segments, format 12 everything above it, formats 0 and 6 are small legacy tables. Merge the segments and you have the exact set of characters the font can render.fvar: present only in variable fonts. One record per axis with a four character tag (wght,wdth,slnt,opsz,italor a custom one), a minimum, a default and a maximum, plus the named instances.
Every claim you make in CSS can be checked against those numbers. Write font-weight: 100 900 for a variable face whose wght axis runs 300 to 800 and the browser clamps silently, so your light weight never appears. The generator reads the ranges out of fvar into the rule, with a slider per axis so you can see each end before you commit.
The licence is name ID 13, and nobody reads it
Type licensing is split by use. A desktop licence covers installing the font on a number of workstations so Illustrator and Word can see it. A webfont licence is a separate product, usually priced per monthly pageview, and it is the one that permits serving the file from your domain. The two arrive in the same ZIP often enough that nobody notices there was a distinction.
The file states which one you have. Name ID 13 is the licence description, 14 a URL to the full text, 0 the copyright and 9 the designer. Open source fonts name the SIL Open Font License in one sentence. Commercial fonts write a sentence that begins something like “This font may not be modified, embedded in a web page, or redistributed” and point at a URL. That is a complete answer, and it takes five seconds to read once something has parsed it out.
fsType in OS/2 is the machine readable version, a bit field: 0 means installable embedding, bit 1 restricted licence embedding, bit 2 preview and print only, bit 3 editable embedding, bit 8 no subsetting, bit 9 bitmap embedding only. Be honest about what it governs. It describes embedding a font into a document such as a PDF, no browser enforces it, and a CSS @font-face rule is outside its scope. It is still the foundry’s stated intent in a form you cannot claim you missed, and bit 8 set to 1 is about as clear a signal as you will get regarding running a subsetter over the file.
The risk is not abstract. Converting an OTF to WOFF2 produces a modified copy of the software, which plenty of licence agreements forbid outright, and a font served from your origin is discoverable by anyone who opens the network tab. Foundries do audit, and a retroactive webfont licence for a busy site costs more than the licence would have.
font-display and what each value costs
A web font is a dependency the browser discovers late. It parses the HTML, fetches the CSS, matches an @font-face rule to an element that has text in it, and only then requests the file. Something has to happen during the wait, and font-display decides what.
The specification defines two windows rather than five behaviours. During the block period the text is laid out with the fallback but painted invisibly, the classic flash of invisible text. During the swap period the fallback is painted and the web font is still allowed to replace it. After both periods end, whatever is on screen stays. The five keywords are just five pairs of durations, and the browser picks the actual numbers.
swap is the value everyone recommends, and it is worth being clear about what it buys: it removes the invisible text, and it guarantees the reflow. The font is applied however late it arrives, so the page always moves. optional is the only value that trades the font for a stable first paint. If the file is not cached within roughly 100 milliseconds the fallback stays for the whole page load, and the font downloads quietly for the next navigation. On a site whose readers arrive from search, read one article and leave, that means most of them never see your typeface. It is a design decision, not a performance setting. Fonts also sit fairly low in the diagnostic order that actually finds a slow site’s cause, well below the database and the hosting.
unicode-range only pays if you cut the file
unicode-range is the one descriptor that changes what gets downloaded rather than how it is displayed. The browser builds the set of code points it needs to render, compares it against each face in the family, and requests only the files whose declared range intersects that set. Declare seven faces for one family and an English page fetches one of them.
The trap is that the descriptor subsets nothing. It is a promise about the file, not an instruction to it. Writing unicode-range: U+0000-00FF on a face whose file holds 3,400 glyphs downloads all 3,400 and renders 200. The win comes from cutting real files with a subsetter and describing each one accurately, which is why the range and the subsetting command belong together. Google Fonts ships seven subsets for Latin script families, latin, latin-ext, greek, greek-ext, cyrillic, cyrillic-ext and vietnamese, and the tool counts your coverage against those seven plus twenty further named blocks, each with a count and a bar.
A subset is only claimed when its key characters are all present, so a font carrying a few shared accents is not passed off as covering Vietnamese. Code points outside every named block are counted into a CSS comment rather than quietly dropped, because a unicode-range that omits characters your content uses is a font that stops applying halfway down a page.
@font-face {
font-family: "Brand Sans";
src: url("https://cdn.wp-image-editor.com/fonts/brand-sans-latin.woff2") format("woff2"),
url("https://cdn.wp-image-editor.com/fonts/brand-sans-latin.woff") format("woff");
font-weight: 400;
font-style: normal;
font-display: swap;
/* abbreviated; the generator emits the full list */
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC,
U+2000-206F, U+2074, U+20AC, U+2122, U+FEFF, U+FFFD;
}
<link rel="preload" href="https://cdn.wp-image-editor.com/fonts/brand-sans-latin.woff2"
as="font" type="font/woff2" crossorigin>
Two details there earn their place. The src list puts WOFF2 first because the browser takes the first entry whose format() hint it understands, so leading with the fat file hands every modern browser the wrong one. And crossorigin is required on the preload even for a font on your own origin, because font requests are made in anonymous CORS mode; leave it off and the file downloads twice. Preload the one subset that renders above the fold and nothing else.
Why the swap moves the page
A line box is not the font size. It is ascent plus descent plus line gap, scaled from unitsPerEm to the used font size, and the width of a text run is the sum of its glyph advances. Swap one font for another at the same font-size and both change: the line gets taller or shorter, and the text wraps at a different word. A paragraph that gains a line pushes everything after it down to the footer.
Worse, the vertical metrics are not one answer. They are three, and which one the browser uses depends on the platform and on a single bit.
That bit is number 7 of fsSelection, USE_TYPO_METRICS. Set, the typographic values win everywhere. Clear, Windows browsers take the win values and macOS takes hhea, so a font whose three sets disagree renders at a visibly different line height on the two platforms. “It looks fine on my Mac” is not a test, and this is why.
CSS now lets the fallback lie convincingly. Four descriptors, all valid inside @font-face: size-adjust multiplies every glyph outline and advance by a percentage, and ascent-override, descent-override and line-gap-override replace the font’s vertical metrics with percentages of the used font size. Put them on a second @font-face rule pointing at a local system font and the fallback occupies exactly the space the web font will.
@font-face {
font-family: "Brand Sans Fallback";
src: local("Arial");
size-adjust: 94%;
ascent-override: 101.1%;
descent-override: 26.6%;
line-gap-override: 0%;
}
body {
font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}
The adjusted family goes second in the stack, after the real font and before the generic. The numbers are not found by nudging. Render the same string in both faces at the same pixel size, measure the advance width of each, and divide the web font’s width by the fallback’s: that ratio is size-adjust. Each vertical override is then the web font’s metric divided by unitsPerEm, divided again by that same ratio, because size-adjust has already rescaled the em the percentages resolve against. Forget the second division and every override is out by the size adjustment.
The tool measures rather than reads, and that is deliberate. It loads your file through the FontFace constructor, draws the measuring string on a canvas and takes fontBoundingBoxAscent and fontBoundingBoxDescent from the returned metrics: the values the browser actually chose on this platform, bit 7 already resolved, for your font and for the system stack you picked from the eleven in the select. The measuring string is an editable field for a reason, because a column of numerals has a different average advance from a paragraph of prose. The four numbers appear when the FontFace resolves and stay blank until it does, which is the tool refusing to guess. Before and after are drawn side by side with the web font’s height marked across both as a dashed line and the remaining shift printed in pixels.
Once the fallback and the web font agree on vertical space, a fluid type scale built with clamp() behaves the same at every viewport width instead of crossing a wrap threshold on one screen size and not another. Metric work is unglamorous, and it removes a whole class of the small typographic errors that give an amateur site away.
What the tool will not do, and the command that does
WOFF2 compresses the whole transformed font with Brotli. DecompressionStream, the only decompressor a browser exposes to script, supports gzip, deflate and deflate-raw, and there is no Brotli option in any engine. So a WOFF2 dropped on the tool is read as far as its header and its table inventory, and then it stops and asks for the TTF or OTF. For exactly the same reason it cannot produce a WOFF2 for you. It prints the commands instead.
# TTF or OTF to WOFF2, Google's woff2 tools
woff2_compress BrandSans-Regular.ttf
# subset and compress in one pass, fonttools
pip install "fonttools[woff]" brotli
pyftsubset BrandSans-Regular.ttf
--unicodes="U+0000-00FF,U+0131,U+0152-0153,U+2000-206F,U+20AC"
--layout-features="kern,liga,clig,calt"
--flavor=woff2
--output-file=brand-sans-latin.woff2
The other limits are stated in the interface rather than hidden behind a spinner. A WOFF whose tables are zlib compressed needs DecompressionStream; where that is missing you get the table list and the reason. Only the first font of a collection is read, only the first 64 tables and 64 named instances are listed, and files over 32 MB are refused. If the browser declines the file, every table is still shown and the tool says the specimen and the width measurement will stay empty. Strings from your file are treated as data throughout, set with textContent, escaped for CSS, with a comment terminator inside a family name broken up so it cannot close a comment early.
Where the rules go in WordPress
The @font-face rules and the adjusted fallback belong in your own stylesheet, which means a child theme if the parent is one you did not write, so an update does not take the fonts with it. The preload cannot live in a stylesheet at all, because its whole point is starting the request before the CSS is parsed. It goes into the head, early, from functions.php.
add_action( 'wp_head', function () {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "n",
esc_url( get_stylesheet_directory_uri() . 'https://cdn.wp-image-editor.com/fonts/brand-sans-latin.woff2' )
);
}, 1 );
The two failures at the top of this article are one failure wearing different clothes. Nobody opened the file. The licence was in it, the metrics were in it, the coverage was in it, and all of it was readable in the time it takes to drag a font onto a page. It goes unread because reading a binary by hand is tedious, not because the information is hard.
A working order falls out of that. Read the licence first, because if the answer is no then nothing else matters. Check the coverage against the languages you publish in. Subset and compress on your own machine with the commands above. Then generate the rule, choose font-display deliberately rather than by habit, and measure the fallback against the real font so the two occupy the same space.
Done in that order, a web font arriving stops being an event on the page. The shapes change, the letterforms sharpen into the ones the designer chose, and not a single line moves. That is the whole target, and four descriptors and ten minutes will reach it.