# Fonts and text metrics

> The preview loads the engine's own typefaces and measures text the way the engine does — which families ship, how to register the proprietary ones, why TextSize is not a font size, and where a line breaks.

Source: https://docs.astra-void.xyz/loom/guides/fonts/

Roblox ships its own typefaces. A browser has none of them.

Loom names the Roblox families in CSS — `font-family: "Gotham", system-ui, …` — and until `0.6.4`
loaded nothing behind them, so on a machine without the font installed every family resolved to
`system-ui`: SF Pro on macOS, Segoe UI on Windows, Roboto on Linux. Three typefaces, three sets of
advance widths. That is not only a paint difference: `AutomaticSize` and `TextWrapped` are driven by
[measuring those widths](https://docs.astra-void.xyz/loom/reference/supported-properties.md#automaticsize-on-text), so the same
scene laid out differently on each machine, and nothing pointed at the font as the reason.

**As of `0.9.0` the preview loads the faces itself.** The import sits in the globals module, which is
injected ahead of your entry whichever frontend you use, so a `vide` preview gets the same faces as a
React one and neither needs a line of configuration.

> **Nothing to install, nothing to import**
>
> `0.6.4` shipped the faces but left the import opt-in, and the preview never made it — so out of the
> box every family still fell through to `system-ui`, and four families were all that had a face at
> all. Both halves are fixed in `0.9.0`. If you are on an older version, `import
> "@loom-dev/renderer/fonts";` as the first line of your entry does the same job.

## The families that ship a face

Fontsource `woff2` in the bundle — no CDN, nothing installed on the machine. These are the *actual*
fonts the engine draws with, so the metrics are the engine's rather than an approximation of them.

| Roblox family | Typeface |
| --- | --- |
| `SourceSans*` | Source Sans 3 |
| `Roboto*` | Roboto |
| `RobotoMono*` | Roboto Mono |
| `RobotoCondensed` | Roboto Condensed |
| `Inconsolata` / `Code` | Inconsolata |
| `Arimo` | Arimo |
| `Jura` `Merriweather` `Nunito` `Oswald` `Ubuntu` `TitilliumWeb` | their own |
| `JosefinSans` `GrenzeGotisch` `Sarpanch` `Michroma` | their own |
| `AmaticSC` `Bangers` `Creepster` `DenkOne` `Fondamento` | their own |
| `IndieFlower` `Kalam` `LuckiestGuy` `PatrickHand` `PermanentMarker` `SpecialElite` | their own |
| `FredokaOne` | Fredoka — Google folded "Fredoka One" into Fredoka's heavier weights, so this is the one approximation |

Twenty-eight families, all OFL-1.1 apart from Ubuntu, which is under the Ubuntu Font Licence.
`Arial` and `Legacy` need nothing: every machine has Arial, and Arimo — which *is* registered — is
metric-compatible with it, so those stacks land on the right advance widths either way.

### Named, but not loaded

The rest of the engine's list resolves to a stack that leads with the right typeface and warns
instead of drifting in silence: `Gotham` and `BuilderSans` (proprietary), and `Bodoni`, `Garamond`,
`Cartoon`, `SciFi`, `Arcade`, `Fantasy`, `Antique`, `Highway` — faces either licensed to Roblox or
with no identity loom can vouch for. On a machine that happens to have the real font installed the
family's own name heads its stack, so it is used; otherwise it falls back and says so.

`Enum.Font` carries the engine's whole enum, all 53 items in its own order. Before `0.9.0` it held
the sixteen loom happened to paint, so `Enum.Font.Jura` was `undefined` and a scene that named it
crashed before drawing anything.

> **What this costs a page**
>
> A static gallery build emits ~2.8 MB of `woff2` across the whole set. What a *page* downloads is
> unchanged: Fontsource declares per-script `unicode-range` subsets, so a browser fetches only the
> families and scripts a scene actually paints with.

## Registering a face yourself

`registerFont` installs a typeface for one Roblox family. A project that has the Gotham files makes
exactly the call the shipped module makes for the open families:

```ts
import { registerFont } from "@loom-dev/renderer";
import gotham from "./fonts/Gotham-VF.woff2";

registerFont("Gotham", {
  family: "Gotham",
  faces: [{ src: gotham, weight: "100 900" }],
});
```

```bash title="The renderer is a transitive dependency; import it directly and it needs declaring"
pnpm add -D @loom-dev/renderer
```

Omit `faces` when the page already provides the family — a `<link>` to a font CDN, your own
`@font-face`, or a font the machine has installed. Then the registration only has to name it:

```ts
registerFont("SourceSans", { family: "Source Sans 3 Variable" });
```

A registration replaces whatever loom shipped for that family, so this is also how you swap one out.

### The registration shape

| Field | Type | Notes |
| --- | --- | --- |
| `family` | `string` | **Required.** The CSS family to paint and measure with. When `faces` is given, this is also the name they are declared under. |
| `faces` | `{ src, weight?, style?, display? }[]` | `@font-face` rules to declare. `src` is a URL the page can load — usually a bundler-emitted import. `weight` defaults to `400` and takes a range (`"200 900"`) for a variable font; `style` defaults to `normal`; `display` defaults to `swap`. |
| `fallback` | `string` | Appended behind `family`. Defaults to that family's own default stack. |

### One key per family

Roblox names a family several ways: the legacy `Enum.Font` item folds the weight in (`GothamBold`,
`ArimoBold`), and a `FontFace` carries the asset's own family, which is sometimes a different word
entirely (`GothamSSm`, `SourceSansPro`, and `Enum.Font.Code` is the Inconsolata family). Every
spelling is matched by **prefix**, longest first so `RobotoMono` is never read as `Roboto`, so one
registration covers every spelling and the weight suffixes come along for free.

The aliases worth knowing: `GothamSSm` → `Gotham`, `SourceSansPro` → `SourceSans`, `Code` →
`Inconsolata`, `Fredoka` → `FredokaOne`, `LegacyArial` → `Legacy`, `HighwayGothic` → `Highway`. A
name outside the family table falls to the generic sans stack and is not registrable.

## A late face re-lays-out

Text bounds are measured against whatever the browser has *at the time*, so a face that arrives after
the first paint would otherwise leave a layout measured in the fallback. Registering late is
therefore fine and expected: a registration — or a `@font-face` finishing its download —
invalidates every `AutomaticSize` bound that came out of the old stack, and both adapters re-measure.

> **Why `0.9.2` matters if you saw this go wrong**
>
> A registered face is never loaded at the moment it is registered: nothing has asked the browser for
> it, and the canvas loom measures with never will, since `measureText` paints nothing and so starts no
> download. The face only loads when text first paints in it.
>
> Loom used to hear about that through `document.fonts.ready`, which is one promise for the cycle *in
> flight when it is read*. Under a dev server the app boots through a graph of separate module
> requests, long after the document settled — so the promise was already resolved, the listeners fired
> at once against the fallback, and the face that downloaded seconds later notified nobody. The layout
> stayed measured for a typeface that was no longer being painted until something unrelated, a resize,
> forced a re-measure. A static build, where one bundle registers everything before the document is
> done, came out right — which is why the same scene at the same version could render correctly
> deployed and wrongly under `npm run dev`, and worst on Windows, where the fallback's metrics are
> furthest from the registered face.
>
> `0.9.2` listens for `loadingdone` on `document.fonts` instead, which fires at the end of *every*
> cycle and has no such window.

```ts title="If you need the hook yourself"
import { onFontsChanged } from "@loom-dev/renderer";

const stop = onFontsChanged(() => {
  // every text measurement taken before this is stale
});
```

`clearRegisteredFonts()` takes every registration back out, along with the `@font-face` rules loom
added — useful in tests, and rarely otherwise.

## `TextSize` is not a font size

Having the right typeface is half of matching the engine. The other half is drawing it at the right
size, and the two properties do not mean the same thing.

**Roblox fits the whole face into `TextSize`** — ascender to descender, which is why a one-line label
measures exactly `TextSize` tall. CSS `font-size` sets the em square instead, and a face's ascent plus
descent runs well past 1em. Painting `font-size: TextSize` therefore drew every glyph too big by that
font's own ratio: 17% for Roboto, 18% for Jura, 25% for Merriweather, 47% for Oswald. Everything
downstream inherited it — text measured that much wider, so it wrapped that much earlier, so
`AutomaticSize` boxes came out taller and wider, and a card sized to its text overran the column meant
to hold it. All of it looked like a wrap bug and none of it was one.

Since `0.9.3` loom divides by the face box, so `TextSize` means what the engine means by it.
`LineHeight` follows: the pitch the engine spends is `TextSize`-relative rather than font-size
relative, so it is set in pixels off `TextSize`, and a `<font size="…">` run inside `RichText`
converts through the metrics of the face that run lands in.

**Which box to divide by is itself a measurement.** The browser reports one
(`fontBoundingBoxAscent + Descent`) and it is not the number Roblox divides by — Roboto reports 1.17
where the engine sizes it as though it were 1.14, so every glyph came out about 2.6% small. Since
`0.9.5` loom carries the engine's own ratio for each family
[`@loom-dev/renderer/fonts` registers](#the-families-that-ship-a-face), solved against
`TextService:GetTextBoundsAsync` per-glyph advances at `TextSize` 18. Twenty-four of the twenty-eight
reproduce all 24 sampled glyphs exactly; `FredokaOne`, `Merriweather`, `Nunito`, `Oswald` and `DenkOne`
do not, and their fitted ratio is still closer than the browser's. **A family with no entry — anything
you registered yourself, `Gotham` included — keeps the measured box**, which is the honest answer when
nobody has solved the engine's.

> **A calibration only applies to a face the browser really has**
>
> A registration is a claim about a file the page still has to fetch, and until `0.9.6` the engine ratio
> was applied on the strength of that claim alone. When the fetch failed the browser painted the
> fallback while loom went on sizing the text as though the registered face were there — every advance
> off the wrong glyphs, wrapping in places the engine does not, `AutomaticSize` reporting a box that does
> not fit the text drawn in it.
>
> Only a dev server can land there: a static build carries its font files in its own output, so the face
> is always present. That is what made it read as a dev-only rendering bug rather than a font that
> failed to load. Against the same target at one width, a dev server whose face 404s wrapped a paragraph
> to ten lines where the build of the same source took nine.
>
> `0.9.6` applies the calibration only when the browser can actually paint the family and otherwise
> measures the face it really has, which is self-correcting — a font-loading cycle drops the metric
> caches and the label re-measures against whatever just landed. `familyIsAvailable` is exported for
> hosts that register their own faces and want the same answer.

## Where a line breaks

The engine does not measure a string the way a browser does, in two ways that compound.

**It spends each displayed grapheme on a half-pixel boundary.** Canvas measurement shapes and kerns a
whole run with fractional advances, which can come out a few percent narrower and wrap a long
paragraph at different words. Since `0.9.4` the renderer caches half-pixel grapheme advances per font,
invalidates them when a face changes, and keeps the fractional result rather than rounding it to a
whole pixel. React and Vide take their measurements from the same place, so a live preview and a
compiled scene no longer disagree — before that, the React adapter measured `TextBounds` itself while a
compiled scene went through the renderer.

**It kerns.** `AV` is 19.5 units wide where its glyphs are 10.5 and 10 alone, so since `0.9.5`
`shapedTextWidth` adds the run's kerning, quantized once for the run.

**And one function decides every wrap.** A label's box used to come from the measurer while the glyphs
inside it were left to CSS, which wraps on its own kerned run widths — so a label could reserve nine
lines and paint eight, ending short of a box built for it and breaking at different words than Studio.
Since `0.9.5` `wrapLines` is the single place a wrap is decided: measurement asks it how many lines a
label needs and the text layer asks it where to put the breaks it paints, keeping them in
`white-space: pre`. `RichText` runs go through the same wrap with the line carried across runs, each
measured in the font its `<font>` tag gave it.

> **Newlines and runs of spaces survive the paint**
>
> A newline in `Text` breaks the line in Roblox — wrapped or not, `RichText` or not, exactly as `<br/>`
> does — and a run of spaces stays a run of spaces. Loom measured it that way and then painted through
> HTML's defaults, `white-space: normal` and `nowrap`, which fold both away. A label written with line
> breaks in it could measure as twenty-three lines and paint as seventeen: a box a hundred pixels taller
> than the text inside it, with every sibling below pushed down by room nothing occupies.
>
> `0.9.3` paints `pre-wrap` when the label wraps and `pre` when it does not, so the paint has the breaks
> the measurement counted. Text with no newlines and no double spaces — most text — is unaffected.

## The warning

A family some text actually asked for, with nothing loaded behind it, is reported once:

```text
loom: no face is loaded for the Roblox font family "Gotham" — its text is painted
and measured in the system fallback instead, which is a different typeface per OS
and does not match the engine's layout. Register one with registerFont("Gotham",
{ family, faces }), or import "@loom-dev/renderer/fonts" for the families Roblox
licenses openly.
```

Two details make it trustworthy rather than noisy:

- **It waits for the font loading cycle to finish**, and re-queues while one is still in flight. A
  face that is downloading is late, not missing — and since the warning is raised while the text is
  being encoded, the paint that asks for the face may only just have started.
- **Availability is decided by probe-string width**, not `document.fonts.check()`. That method
  answers "would this font specification resolve", and an unknown family resolves — to the fallback —
  so it returns `true` for a family nobody has. Loom measures a probe string against two generics
  with wildly different metrics instead: a real family shifts at least one of them, a missing one
  leaves both exactly where the generic put them. This is what `familyIsAvailable` exposes.
- **A registration is not a face.** Until `0.9.6` the audit skipped any family that had one, on the
  assumption that a registration means a face. A registered face whose file never arrived is now
  reported like any other missing one, rather than being quietly mis-measured.

`Arial` and `Legacy` are exempt. Both land on a font every machine has, so there is nothing to load
and nothing to warn about.

## What is still approximate

Loading the right face pins the *typeface*, and `0.9.3`–`0.9.6` pinned the size, the advances and the
wrap. What is left is that a browser's text engine is still not Roblox's: shaping, hinting and
sub-pixel rounding differ.

Measured against `TextService:GetTextBoundsAsync` — Roboto at `TextSize` 18, a long paragraph laid out
at 50 widths from 320 to 1300 — the painted line count matches the engine at 49 of the 50. String
widths are exact on 6 of 10 sampled strings and never off by more than half a unit; they were off by
up to 9 before `0.9.5`, and the wrapped line count matched 34 times when CSS was doing the wrapping.

What remains is that the measurement runs about a percent roomy, so text wraps a hair early rather
than overflowing its box — the safe direction to be wrong in. Before `0.9.0` this was not a rounding
difference at all; it was a different font.
