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 fontSubstituteLicense
CalibriCarlitoSIL OFL
CambriaCaladeaSIL OFL
Times New RomanLiberation SerifSIL OFL
ArialLiberation SansSIL OFL
Courier NewLiberation MonoSIL 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:

  1. Explicit sources win. A face you supply beats an embedded face with the same family, weight, and style.
  2. Embedded faces beat substitutions. A document that embeds Calibri uses those bytes; the Calibri→Carlito mapping applies only when no direct source exists.
  3. 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, loadDefaultFonts and 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

On this page