Fonts and measurement
How the editor resolves fonts for Word-accurate line wrap and pagination: embedded DOCX fonts, metric-compatible substitutes, and app-supplied font URLs.
The editor measures text with a real shaping engine (HarfBuzz) whenever it has font bytes to measure with. With no fonts at all it still works: layout falls back to a fixed-width estimate, but wrap and page-break points are then approximations rather than Word's. This guide covers the three ways fonts reach the editor, the two ways of timing them, and how they combine.
Font sources
1. Fonts embedded in the document, automatic. A .docx can carry the faces it was
written in. When it does, the editor extracts them on load and measures with them. No
configuration, no assets, no network: a document that brings its own fonts is
Word-accurate out of the box, in metrics and in pixels. The editor also registers the
embedded bytes with the browser so the painted pages show the real glyphs, not a platform
substitute.
Those faces register under an internal alias, never under the family name the document
declares. A .docx controls its own font names, and the browser's font registry is
page-wide, so registering a document's Segoe UI would repaint your application's own
buttons and dialogs with glyphs from that file.
2. Metric-compatible substitutes, one call. Most documents use Word's default
fonts (Calibri, Cambria, Times New Roman, Arial, Courier New), which are proprietary
and cannot be bundled. The optional @docx-editor.dev/fonts package ships open-licensed
faces built to match their metrics: identical advance widths, so lines wrap where Word
wraps them:
| Word font | Substitute | License |
|---|---|---|
| Calibri | Carlito | SIL OFL |
| Cambria | Caladea | SIL OFL |
| Times New Roman | Liberation Serif | SIL OFL |
| Arial | Liberation Sans | SIL OFL |
| Courier New | Liberation Mono | SIL OFL |
import { useEffect, useState } from 'react';
import { DocxEditor } from '@docx-editor.dev/react';
import { loadDefaultFonts, installDefaultFontFaces } from '@docx-editor.dev/fonts';
import type { FontConfigurationFragment } from '@docx-editor.dev/react';
function Editor({ bytes }: { bytes: Uint8Array }) {
// `settled` gates the FIRST mount. `fonts` is sampled at mount and an identity change
// remounts, so mounting before fonts resolve costs a visible remount, and a remount
// resets the undo stack and the caret. Waiting once avoids both.
const [fonts, setFonts] = useState<{
settled: boolean;
value?: FontConfigurationFragment;
}>({ settled: false });
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const fragment = await loadDefaultFonts(); // or { families: ['Calibri'] }
// Optional: also register the substitutes with the browser under the Word family
// names, so painted glyphs match the metrics layout measured with.
void installDefaultFontFaces();
if (!cancelled) setFonts({ settled: true, value: fragment });
} catch {
// A load failure settles with no fonts: the editor opens on the fixed measurer,
// which is the documented degradation.
if (!cancelled) setFonts({ settled: true });
}
})();
return () => {
cancelled = true;
};
}, []);
if (!fonts.settled) return null;
return <DocxEditor.Root document={bytes} fonts={fonts.value} />;
}Font binaries load lazily as separate assets, only for the families you ask for. Importing the package fetches nothing.
3. Your own fonts, explicit URLs. For brand fonts or licensed copies of the real
faces, loadFonts fetches URLs you specify and caches them locally. When you pin a
sha256: hash it gates admission hard. Pin it for any URL not under your sole
control:
// Also re-exported by the adapter: '@docx-editor.dev/react'.
import { loadFonts, composeFontConfiguration } from '@docx-editor.dev/react';
const brand = await loadFonts({
sources: [
{ url: '/fonts/AcmeSans-Regular.ttf', family: 'Acme Sans', weight: 400, style: 'normal' },
{
url: '/fonts/AcmeSans-Bold.ttf',
family: 'Acme Sans',
weight: 700,
style: 'normal',
hash: 'sha256:…',
},
],
});
const fonts = composeFontConfiguration(brand, await loadDefaultFonts());loadFonts never rejects for a single bad source: it returns the admitted sources plus
a typed failures list, so the editor can open with partial coverage.
You never have to compute a hash by hand. Pins are optional: omit hash for URLs on
your own origin and loadFonts computes one for you. When you do want to pin a
third-party URL, run it once without a pin and read the values back off the result:
const result = await loadFonts({ sources });
console.log(result.sources.map((source) => `${source.id}: ${source.hash}`));Paste those into your source list (or generate the list at build time, which is how
@docx-editor.dev/fonts bakes the hashes for its own assets).
Already hold the bytes, from a file input, IndexedDB, or a bundler import? createFontSource
turns them into a source directly, with the hash computed for you. It returns a typed
failure rather than throwing when the descriptor or the bytes are unusable:
import { createFontSource, composeFontConfiguration } from '@docx-editor.dev/react';
const made = createFontSource(bytes, { family: 'Acme Sans', weight: 400, style: 'normal' });
if ('source' in made) {
const fonts = composeFontConfiguration({ sources: [made.source] });
} else {
report(made.failure.reason); // 'malformed', 'overLimit', 'invalidRequest', …
}Loading upfront or on demand
Every path above resolves fonts before a document opens. You pick the faces, load the bytes, and hand the editor a value. Keep this as your default: it is the only path that makes no network request on open.
fonts also accepts a function. The editor calls it once per load, after parsing the
file, with the families that file declares. A document then loads only the faces it uses,
and one that names nothing you cover loads nothing:
import { DocxEditor, useFonts } from '@docx-editor.dev/react';
import { googleFonts } from '@docx-editor.dev/fonts/google';
function Editor({ bytes }: { bytes: Uint8Array }) {
const fonts = useFonts(googleFonts());
return <DocxEditor.Root document={bytes} fonts={fonts} />;
}googleFonts() serves those families from a generated catalog. Every face is pinned to
one google/fonts commit and carries a baked sha256: that the editor re-derives before
it admits the bytes. Use allow to narrow what it may ever fetch, and substitute to map
your own document family names onto catalog ones:
googleFonts({ allow: ['Tinos', 'Carlito'], substitute: { Georgia: 'Tinos' } });Write your own resolver when the bytes are yours. It takes the declared families and returns the same fragment shape as everything else:
// A Map, not an object: the families are file-derived, and `'constructor' in {}` is true.
const myBrandFaces = new Map([['Acme Sans', '/fonts/AcmeSans-Regular.ttf']]);
const fonts = useFonts(async ({ families, defaultFamily }) => {
const wanted = [defaultFamily, ...families].filter((family) => myBrandFaces.has(family));
return { sources: await loadMyFaces(wanted) };
});A resolver that fetches makes opening a document perform network requests, which the editor never
does on its own. The families come from the file, so treat them as lookup keys against a set you
shipped, and never build a URL out of one. Whoever serves the fonts also learns which families a
document uses. Stay on the upfront path if that matters. The editor caps how many families it
hands a resolver, and googleFonts() matches names only against its closed catalog.
useFonts exists because DocxEditor.Root rebuilds its instance when fonts changes
identity. An inline fonts={googleFonts()} is a new function on every render, which would
rebuild the editor forever. useFonts returns one resolver for the component's life. It
also merges origins, so on-demand and upfront faces compose:
const fonts = useFonts(googleFonts(), brandFragment);Because the resolver keeps its identity, the editor re-reads its arguments per load rather than per render. Changing them mid-document resolves nothing new. Load a document or remount for new fonts to take effect.
How sources combine
composeFontConfiguration(base, ...fragments) merges any number of fragments into the
one immutable configuration the editor samples per mount. Precedence is simple:
- Explicit sources win. A face you supply beats an embedded face with the same family, weight, and style.
- Embedded faces beat substitutions. A document that embeds Calibri uses those bytes; the Calibri→Carlito mapping applies only when no direct source exists.
- First fragment wins among equals, and duplicate faces are deduplicated rather than erroring.
Fallback behavior
Fonts never block a document from opening. The document mounts immediately on the
fixed measurer and swaps to shaped measurement in a single re-mount when fonts resolve.
Edits made before fonts resolve survive that remount, but the undo stack and caret
position do not, which is why the upfront sample above waits for fonts before the first
mount. A resolver skips that step. It runs after the document is parsed, so you have
nothing to gate the first mount on.
Every failure (a corrupt embedded face, a 404, a hash mismatch) degrades exactly one
face, reports a typed EditorFontError through onFontError, and leaves the rest
measuring accurately.
Observing font state
onFontError is a prop on DocxEditor.Root (and an option on createDocxEditor);
each error carries a typed code (missing, malformed, overLimit,
hashMismatch, …) and, where known, the face request it concerns. To answer "am I
measuring Word-accurately right now?", read fontMeasurement() on the editor
instance:
<DocxEditor.Root
document={bytes}
fonts={fragment}
onFontError={(error) => report(error.code, error.message)}
/>;
// Inside the tree:
const editor = useDocxEditor();
editor?.fontMeasurement();
// → { measurer: 'shaped' | 'fixed', resolving: boolean, producer?: string }{ measurer: 'fixed', resolving: false } is the steady state for a document with no
usable font source; resolving: true means shaped resolution is still in flight.
Caveats
- Metric-compatible is not identical. Advance widths match, so wrap points match;
rare kerning-pair differences can still move an edge case. Documents that must
render the true faces should supply licensed bytes via
loadFonts. - The editor never fetches fonts on its own.
loadFonts,loadDefaultFontsand any resolver you pass run because your code asked for them. Embedded fonts come from inside the file. Opening a document makes no network request unless you passed a resolver that fetches, which is the one case where a font request happens on open.
Next steps
- Props: Fonts:
fontsandonFontError - Word fidelity: how measurement affects pagination
- Custom styles: styles, themes, and the font picker
Custom styles & branding
Use a branded DOCX template to control headings, fonts, and table styles. Documents inherit your styles automatically and preserve them when saved.
Dark mode
Enable dark mode in the DOCX editor with the colorMode prop: theme the chrome, render the document canvas like Word dark view, and toggle from your own UI.