Scrivr
Guides

Typography & Fonts

Give the editor an inventory of typefaces it owns, so canvas measurement and PDF export agree on what the document is set in.

Scrivr paints text onto a <canvas>, so layout depends on font metrics rather than on the browser laying text out for you. A family name like Inter is a request: what it resolves to depends on what the machine happens to have installed. That makes it useless on its own — an exporter cannot embed a request, and two machines can disagree about what the document looks like.

So the editor is given an inventory of faces it owns the bytes for. Every lane works from that one answer.

Scrivr never bundles a typeface. Fonts are licensed, large, and your choice — the engine is what resolves and records them.


Supplying a provider

A DefaultFontProvider needs a default face and, usually, the other weights and slants of it. Each face is a descriptor: registering it costs nothing until something resolves to it, and only then are its bytes fetched.

Register .ttf or .otf bytes rather than the smaller .woff2 the web would prefer. The same bytes serve measurement and embedding, and a PDF carries a font program — not a web container wrapped around one. A .woff2 face is refused at export with an error naming the family.

fonts.ts
import { DefaultFontProvider, type FontResource } from "@scrivr/react";
import regular from "@expo-google-fonts/inter/400Regular/Inter_400Regular.ttf?url";
import bold from "@expo-google-fonts/inter/700Bold/Inter_700Bold.ttf?url";
import italic from "@expo-google-fonts/inter/400Regular_Italic/Inter_400Regular_Italic.ttf?url";

const face = (
	id: string,
	url: string,
	weight: number,
	style: "normal" | "italic",
): FontResource => ({
	id,
	family: "Inter",
	weight,
	style,
	format: "ttf",
	// Inter is OFL-licensed, so it may travel inside an exported PDF.
	embedding: { allowed: true, source: "caller" },
	bytes: async () => (await fetch(url)).arrayBuffer(),
});

export const fonts = new DefaultFontProvider({
	default: face("inter-400", regular, 400, "normal"),
	resources: [
		face("inter-700", bold, 700, "normal"),
		face("inter-400-italic", italic, 400, "italic"),
	],
});
Editor.tsx
const editor = useScrivrEditor({ extensions: [StarterKit], fonts });

That is the whole setup. The editor installs the bytes in its measurement backend under a private alias, so measureText reads your face rather than whatever the operating system maps Inter to.

Register every weight and slant you care about. A request for bold resolves to the nearest face in the family, and with no bold registered the text is drawn in the regular weight.

What happens to a weight you do not own

A document can ask for bold where your inventory has only a regular. Scrivr draws it by thickening the face it has — and leans the face for a missing italic — rather than setting the heading in body text.

It does this itself rather than asking the browser to, and that is the whole trick. A browser's synthetic bold widens the advance of every glyph; a PDF's equivalent does not. Faking it on one side and not the other puts the two back into disagreement about where every following character goes, which is the overlapping-text failure a shared inventory removes. Stroking a glyph in place and shearing it both leave the advance alone, so the canvas and the exported file do the same thing to the same widths.

It is still a stand-in. A designed bold redraws its counters and respaces; a designed italic redraws its letterforms. So the substitution is reported too, as a whole face rather than a family name, and an application can tell the two cases apart:

const [missed] = editor.fontSubstitutions;
missed.request.weight;  // 700 — the document asked for bold
missed.resolved.weight; // 400 — it was thickened from the regular
missed.synthesis;       // { weight: { from: 400, to: 700 } }

Register the real faces when you can; nothing synthesised matches a drawn one.

Pick a file that covers the scripts you accept

Most font packages publish one file per script, selected in CSS by unicode-range. That arrangement assumes the browser picks a file per character, which it cannot do here: a face is one set of bytes, and those same bytes have to serve measurement and embedding alike.

A Latin-only file holds around 230 glyphs. Paste Cyrillic or Greek into a document using it and those characters have no glyph — on canvas the browser quietly substitutes something else, and in a PDF they are drawn as blanks.

So prefer an unsubsetted file per face, in a format a PDF can carry — @expo-google-fonts/inter publishes Inter as .ttf that way, covering Latin, Latin Extended, Greek, Cyrillic and Vietnamese in one file per face. The faces are fetched only when something resolves to them, so the catalogue costs nothing until it is used.


What happens to a font you do not own

A document can name anything. A .docx written in Word may ask for Aptos, Calibri or Cambria, and an application that registered Inter owns none of them.

Resolution prefers, in order: the exact face asked for, another weight or slant of the same family, then the nearest face in your default's family. It never invents a family name, and it reports which of those happened:

const shortfalls = await prepareDocumentFonts(doc, fonts, {
	portable: true,
	embeddable: true,
});
// [{ request:  { family: "Aptos", weight: 700 },
//    resolved: { family: "Inter", weight: 400 },
//    source: "default" }]

DOCX import runs this for you and raises a font-substituted diagnostic per face, so you can tell the user their contract is no longer set in Aptos rather than letting them discover it.

Telling the user, continuously

Import reports once and export reports at the end. For the state in between, the editor answers the same question about the document it is laying out now:

const substituted = useEditorState({
	editor,
	selector: (ctx) => ctx.editor.fontSubstitutions,
});

Same FontShortfall shape, memoised per layout so it only changes when the answer does.

For a font control, getActiveFontFamily() gives the family in effect at the selection together with the face it is drawn in:

editor.getActiveFontFamily();
// { requested: "Aptos", resolved: "Inter", substituted: true }

The precedence — inline mark, then block attribute, then the document default — is the editor's own rule, so reading it here keeps a control from drifting from the page it describes.

editor.fontFamilies lists what a picker may offer — one entry per family, not per face, because bold and italic are marks with their own controls. Each entry carries what backs it:

editor.fontFamilies;
// [{ family: "Inter", faces: [{ weight: 400 }, { weight: 700 }], portable: true },
//  { family: "Courier New", faces: [], portable: false }]

portable: false is a family only this device has: it draws on screen and an export cannot carry it, so its faces are unknown rather than assumed. For an owned family, faces says which weights and slants are designed — anything else is drawn by thickening or leaning the nearest one.

The playground registers three families on purpose, because these are the three states a control has to handle:

familyinventorywhat the control says
Interregular, italic, bold, bold italiceverything designed
Source Serif 4regular, italic, boldno bold italic — leaned from the bold
JetBrains Monoregularbold and italic both drawn from it
Georgia, Courier Newnone — systemCandidatesthis device only; an export substitutes

Add ?fonts=regular-only to reduce the inventory to a single face and watch every weight become a stand-in.

Scrivr deliberately stops there. Whether a substitution is a badge, a banner, or nothing at all depends on what the application is for, so the engine reports and the application decides.

A measurement backend that cannot install a face — a custom TextMeasurer without installFont, or an environment with no FontFace — reports every answer as one it cannot promise. That is honest rather than pessimistic: it has no way to guarantee the bytes are what is being measured.


Exporting

exportToPdf resolves the document's faces under { portable: true, embeddable: true }, embeds them, and paints the layout the editor already has. The PDF reproduces the page on screen — same line breaks, same pages — and is set in the face it was measured in rather than a base-14 lookalike chosen from the family name.

When the export cannot use what the screen resolved — a face the browser can draw but nobody owns the bytes for, or one whose licence forbids embedding — it lays the document out again against the faces it can carry, and reports why. That document may break lines differently from the screen, which is the honest outcome: it is set in different typefaces.

await exportToPdf(editor, {
	onFontShortfall: (faces) => {
		for (const f of faces) {
			console.warn(`${f.request.family} exported as ${f.resolved}`);
		}
	},
});

Two constraints shape that answer:

ConstraintMeaning
portableThe face must be usable off this machine. A systemCandidates family — one the browser can draw but nobody owns bytes for — is refused.
embeddableThe face's licence must affirmatively permit it to travel inside the file. Set embedding: { allowed: true } only when the licence or font metadata grants that permission; omission is treated as unknown and refused.

A face that resolves but cannot be embedded fails the export rather than silently falling back, because falling back would invalidate the geometry already measured.


Fonts that arrive late

You do not need to delay mounting the editor until fonts load. The editor lays out immediately with what it has, prepares the faces in the background, and when an answer changes it drops its measurement cache and reflows. Text may reflow once shortly after mount; it will not be silently mismeasured.

If your application swaps the inventory at runtime, a provider can notify the editor:

provider.subscribe((change) => {
	// "resource-added" | "resource-loaded" | "resource-failed" | "resource-removed"
});

The editor subscribes on your behalf; implementing subscribe on a custom FontProvider is what lets a late arrival trigger a reflow.


Custom providers

FontProvider is a small interface, so an inventory backed by a font server or a user's uploaded files is a matter of implementing three methods:

interface FontProvider {
	defaultRequest(): FontRequest;
	prepare(requests, constraints?): Promise<void>; // may fetch; runs before layout
	resolve(request, constraints?): FontResolution; // never blocks; layout is sync
	subscribe?(listener): () => void;
}

resolve is synchronous because measurement cannot wait. prepare is where acquisition happens, and it is always called before the geometry that depends on it.

On this page