Fonts and measurement

Resolve fonts for Word-accurate line wrapping and pagination with embedded fonts, open-licensed substitutes, or app-supplied files.

The editor uses HarfBuzz when it has usable font bytes. Without them, a browser uses canvas measurement when available. Other environments use fixed measurement.

The document still opens with fallback measurement. Neither fallback guarantees Word-compatible line wraps or page breaks.

Start here

Every font origin has the same shape: call it, and pass the result. To add another origin, add another argument.

import { packagedFonts } from '@docx-editor.dev/fonts';

const { document, fonts } = useDocxSource(url, { fonts: packagedFonts() });
import { packagedFonts } from '@docx-editor.dev/fonts';
import { googleFonts } from '@docx-editor.dev/fonts/google';

const { document, fonts } = useDocxSource(url, {
  fonts: [packagedFonts(), googleFonts()],
});

The list is a precedence order: the first origin that supplies a face wins, and later origins receive the resolved faces so they can skip duplicate downloads. Put preferred sources first.

googleFonts() fetches from a content delivery network. packagedFonts() and defaultFonts() load assets shipped with the package, typically from your own origin in a browser. No optional font source is enabled by default.

Choose a font source

SourceThird-party requestsLoadsMeasurement
Fonts embedded in the DOCXNoThe faces in the fileUses the embedded bytes
packagedFonts()NoThe packaged families in use, plus the default faceUses metric-compatible substitutes
defaultFonts()NoAll 20 faces of the five defaults, every timeUses metric-compatible substitutes
googleFonts()Yes, for catalogued familiesThe catalog families in use, plus the default faceUses the fetched faces
loadFonts with your URLsDepends on your URLsThe URLs you listUses admitted files
No usable sourceNoNothingCanvas or fixed fallback

Embedded fonts load automatically. The editor registers them under internal aliases. A document cannot replace a page-wide font family used by your application.

Understand the font notice

The packaged font notice lists rendered document families without an available compatible face. It excludes metric-compatible substitutions.

The notice also excludes font declarations that rendered text does not use. The font picker can still list those declared families.

The notice also excludes symbol faces, such as MS Gothic in a Word checkbox (w:sym). The editor maps symbols to Unicode where a mapping exists. Rendering still depends on the available font glyphs; a missing notice does not guarantee that every symbol can render.

The font picker is a separate list. It offers the families your configuration supplies and the families the document declares in w:rFonts, so a symbol face appears there when the file declares it as a run font, which Word's checkbox markup does.

Load Word-compatible defaults

The optional @docx-editor.dev/fonts package provides open-licensed substitutes.

Word fontSubstituteLicenseLoaded by default
CalibriCarlitoSIL OFLYes
CambriaCaladeaSIL OFLYes
Times New RomanLiberation SerifSIL OFLYes
ArialLiberation SansSIL OFLYes
Courier NewLiberation MonoSIL OFLYes
Century GothicTeX Gyre AdventorGUST Font LicenseNo

The first five match advance widths exactly. Rare kerning differences can still change an edge case.

TeX Gyre Adventor is close, not exact. It runs slightly narrow against Century Gothic across the recorded samples: 0.22%, 0.34%, 0.67%, and 0.85% at 40 pt bold. bun run check:font-width-fidelity holds it within 1%. Over a long line that is a fraction of a character, and a wrap point can still move.

Supply licensed font bytes when pixels must match the original face.

defaultFonts() loads the five families Word applies to a document by default. Century Gothic is not one of them, and its four assets add about 709 KB to every load, so you opt in:

import { ALL_WORD_DEFAULT_FAMILIES, defaultFonts } from '@docx-editor.dev/fonts';

const fonts = await defaultFonts({ families: ALL_WORD_DEFAULT_FAMILIES });

googleFonts() covers it on demand instead. It serves Century Gothic from the same packaged bytes, and only when a document names the family, so it makes no third-party request for it.

packagedFonts() loads a family only when a document names it, or when it is that document's default face. Narrow it further with allow:

const fonts = packagedFonts({ allow: ['Calibri'] });

Font binaries load as separate assets. Importing the package fetches nothing.

Choose between lazy and eager loading

packagedFonts() and defaultFonts() measure identically for the families they share. They differ in when they run, what they cover, and what that costs.

BehaviorpackagedFonts()defaultFonts()
RunsAfter the document is parsedBefore the document opens
CoversAll six substituted familiesThe five Word applies by default
LoadsThe families in the document, plus the default faceAll 20 faces, 7.4 MB
First layoutFixed measurement, then re-paginatesCorrect on the first pass
Undo history across the swapCleared when the faces arriveKept

Century Gothic is the family that difference is for. defaultFonts() leaves it out because it would cost every document about 709 KB for a family most never name. packagedFonts() serves it from the same bundled bytes when a document asks.

Use defaultFonts() when a visible re-pagination is worse than the extra bytes. Use packagedFonts() otherwise.

import { defaultFonts } from '@docx-editor.dev/fonts';

const { document, fonts } = useDocxSource(url, { fonts: defaultFonts });

With defaultFonts, useDocxSource holds document back until the fonts settle, so the reader never sees the text reflow. An on-demand resolver has nothing to hold for: the families it answers about come from the parse, so the bytes have to go through first.

If you load default bytes separately, pass them to the paint-side installer. This avoids a second request for the same font files:

import { installDefaultFontFaces, loadDefaultFonts } from '@docx-editor.dev/fonts';

const loaded = await loadDefaultFonts();
await installDefaultFontFaces({ loaded: loaded.sources });

Load your own fonts

Use loadFonts for brand fonts or licensed Word fonts.

import { loadFonts } from '@docx-editor.dev/core/editor';

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:…',
    },
  ],
});

brand is a fragment. Compose it with the packaged substitutes the same way you compose any two origins, and put it first so your own faces win:

import { packagedFonts } from '@docx-editor.dev/fonts';

const { document, fonts } = useDocxSource(url, { fonts: [brand, packagedFonts()] });
loadFonts behaviorResult
One source failsReturns admitted sources and a typed failures list.
Hash matchesAdmits the source.
Hash differsRejects that source with hashMismatch.
Hash omittedComputes one for the returned source.
Same request repeatsUses the Cache API when available. Otherwise, each call fetches again.

Pin every URL that you do not control. To create pins, load once without a hash and store the returned source hashes.

const result = await loadFonts({ sources });
console.log(result.sources.map((source) => `${source.id}: ${source.hash}`));

Use createFontSource when you already have bytes from a file input, IndexedDB, or a bundler.

import { composeFontConfiguration, createFontSource } from '@docx-editor.dev/core/editor';

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);
}

This function returns a typed failure instead of throwing for invalid descriptors or bytes.

Load fonts on demand

packagedFonts() and googleFonts() are resolvers. The editor calls a resolver once per document load, after parsing, with the family names that document declares.

useDocxSource handles the identity of a resolver for you. When you pass one directly to the fonts prop, use useFonts instead: an inline resolver is a new function on each render, and that identity change rebuilds the editor.

import { packagedFonts } from '@docx-editor.dev/fonts';
import { googleFonts } from '@docx-editor.dev/fonts/google';
import { DocxEditor, useFonts } from '@docx-editor.dev/react';

function Editor({ bytes }: { bytes: Uint8Array }) {
  const fonts = useFonts(packagedFonts(), googleFonts());
  return <DocxEditor.Root document={bytes} fonts={fonts} />;
}

Every argument to useFonts takes the same union: a resolver, a fragment, a configuration, or a promise for one. Arguments compose first-wins, in order.

googleFonts() uses a generated catalog. Each face is pinned to an immutable google/fonts commit and includes a checked sha256: value. Most faces share one commit. A family whose current upstream version ships variable-only files is pinned to the last commit that carried static instances, so the catalog records more than one revision.

googleFonts({
  allow: ['Tinos', 'Carlito'],
  substitute: { Georgia: 'Tinos' },
});

An arbitrary substitute can change line breaks and pagination. Use a metric-compatible substitute when layout must remain stable.

Resolver constraintRequired behavior
File-supplied family namesMatch them against a closed Map or catalog.
URL constructionNever build a URL from a document family name.
PrivacyA remote font host can learn which families the document uses.
UpdatesLoad another document or remount to apply changed resolver arguments.
CompositionuseFonts(packagedFonts(), googleFonts()) combines sources.

The editor caps the family list passed to a resolver.

googleFonts() resolves a family against closed sets, in this order:

  1. A redirect from the substitution map: your substitute entries merged over the built-in metric map, such as Calibri to Carlito. Your entries win, and they win over the other two steps as well, so you can redirect any family.
  2. A packaged face for a family the catalog cannot match. Century Gothic is the one. It reads the package's own assets, so it makes no third-party request.
  3. A direct catalog match on the name the document wrote.

Step 1 comes first, so substitute: { Lato: 'Tinos' } redirects Lato even though Lato is catalogued under its own name.

A family that none of the three answers resolves to nothing, and keeps whatever measurement your host already had. That is deliberate. Only a metric-compatible substitute keeps pagination Word-accurate, and a face picked on how a font describes itself is not one: word/fontTable.xml states a PANOSE classification, never an advance width, so nothing in the file bounds how much wider the substitute would run.

Serve the catalog from your own origin

googleFonts() fetches from a content delivery network. To send those requests somewhere else, pass fetcher. It replaces the fetch that resolver uses, so your function decides where each catalogued face comes from:

googleFonts({
  fetcher: (input) => fetch(rewriteToYourMirror(String(input))),
});

The bytes suit a mirror well. Each face is pinned to an immutable commit and carries a sha256: value. The engine re-derives that hash when it admits the bytes, so a substituted or corrupted file fails loudly rather than rendering.

packagedFonts() and defaultFonts() accept fetcher too, and their assets ship inside the package, so they read your own origin with or without it.

Both also register faces for painting, and that step can reach the browser's own FontFace loader, which takes a URL rather than your function. To be certain every byte goes through fetcher, pass install: false to packagedFonts(). Measurement is unaffected: painted glyphs then fall back to whatever the platform substitutes for the family name. defaultFonts() has no such option, so call loadDefaultFonts() instead when you need that guarantee.

Use fetcher for caching too. googleFonts() keeps its catalog fetches in memory, keyed by the fetcher you passed, which is what a browser tab needs. Nothing else caches. The packaged assets are re-read on every call. A server that renders in short-lived processes gets no reuse either way. Nothing is written to disk, and nothing is shared between workers. Wrapping fetcher is where you add that:

googleFonts({
  fetcher: async (input) => {
    const url = String(input);
    const hit = await yourCache.get(url);
    if (hit) return new Response(hit);
    const response = await fetch(url);
    const bytes = new Uint8Array(await response.clone().arrayBuffer());
    await yourCache.set(url, bytes);
    return response;
  },
});

Run without access to the network

A face that cannot be fetched is dropped, and the family it belonged to measures on the engine's fixed fallback instead.

Treat that as a layout problem, not a cosmetic one. Substitute bytes are what keep wrapping and pagination close to Word. A blocked request therefore changes where your pages break, not only how the text looks.

The document still opens. Each dropped face goes to the resolver's own onFailure option, which writes a console warning when you pass no handler:

googleFonts({
  onFailure: ({ family, url, diagnostic }) => report(family, url, diagnostic),
});

Two options work when your deployment cannot reach a content delivery network. Point fetcher at a mirror you control. Or leave googleFonts() out and use packagedFonts() alone, which reads only the bytes inside the package.

Write your own resolver

Wrap it in defineFontResolver. That mark is how useDocxSource tells a resolver, which it calls with a request, from a loader such as defaultFonts, which it calls with no arguments. TypeScript cannot separate the two, because a zero-argument function is assignable to a one-argument function type.

useFonts needs the mark only from the second argument on. Its first argument has never accepted a loader, so there is nothing to disambiguate and a bare resolver still works there.

In Vue, a function origin is always the value, never a getter to call. To build an origin lazily, use a computed:

const fonts = useFonts(computed(() => packagedFonts()));

useFonts(() => packagedFonts()) cannot work: nothing distinguishes it from a resolver that ignores its request. The editor reports it rather than composing an empty result.

import { defineFontResolver } from '@docx-editor.dev/core/editor';

const faceKey = (family: string, weight: number, style: string) =>
  // Case-folded: Word matches font names that way, and so do both shipped resolvers.
  `${family.trim().toLowerCase()} ${weight} ${style}`;

const brandFonts = defineFontResolver(async ({ families, defaultFamily, resolvedFaces }) => {
  const already = new Set(
    (resolvedFaces ?? []).map((face) => faceKey(face.family, face.weight, face.style))
  );
  // `defaultFamily` alongside `families`: a run that authors no font is measured in the
  // default face, so a resolver that ignores it never serves that run.
  const wanted = [defaultFamily, ...families].filter(
    (family) =>
      BRAND.has(family) &&
      // Load a family unless EVERY face of it is already covered. Skipping a partly
      // covered family leaves its other faces with no bytes at all.
      !BRAND_FACES.every((face) => already.has(faceKey(family, face.weight, face.style)))
  );
  return { sources: await loadBrandFaces(wanted) };
});

The request carries families, the names that document declares, and defaultFamily, the face a run naming no font resolves to. Both count as declared.

families also includes faces used by w:sym, SYMBOL fields, and numbering markers. Unused numbering definitions do not add requests. Supply usable bytes for these faces to render their authored glyphs. The editor maps private-use symbols to Unicode where a mapping exists and preserves the character otherwise. Supplied faces are also available in the font picker.

resolvedFaces lists the faces earlier origins in the same composition can already paint. It reports faces rather than families, and only faces backed by bytes, so skipping one cannot lose one. That is what makes honoring the list an optimization rather than a requirement: composition drops a duplicate face either way, so ignoring it only costs bytes.

Origins resolve one after another, not in parallel, so each can be told what the ones before it covered. That costs one extra origin's latency. Order origins cheapest-first.

Source precedence

composeFontConfiguration(base, ...fragments) creates one immutable configuration.

PriorityRule
1An explicit source beats an embedded face with the same family, weight, and style.
2An embedded face beats a substitution.
3The first fragment wins between equal sources.
4Duplicate faces are removed.

Handle failures

Fonts never block a document from opening. Each failed face falls back independently.

Failure pathNotification
React rootonFontError
Vue root@font-error
createDocxEditoronFontError option

Each EditorFontError has a typed code. Known codes include missing, malformed, overLimit, hashMismatch, and wasmUnavailable.

Read editor.fontMeasurement() to inspect measurement:

FieldMeaning
measurer'shaped' or 'fixed'
resolvingtrue while font resolution is in progress
producerOptional source identifier

{ measurer: 'fixed', resolving: false } means no usable font source remains.

Serve the HarfBuzz WASM asset

Webpack, Turbopack, and Vite emit the bundled WebAssembly asset automatically. Some esbuild, Bun, and library builds do not emit new URL(..., import.meta.url) assets.

Without harfbuzz.wasm, the editor reports wasmUnavailable and uses fixed measurement. Rendering continues, but line and page breaks can differ from Word.

For affected builds:

  1. Copy harfbuzz.wasm from the installed core package into your served assets.
  2. Call setHarfBuzzWasmUrl before you create the first editor.
  3. Repeat the copy after each package upgrade.
import { copyFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

await copyFile(
  fileURLToPath(import.meta.resolve('@docx-editor.dev/core/harfbuzz.wasm')),
  'public/static/harfbuzz.wasm'
);
import { setHarfBuzzWasmUrl } from '@docx-editor.dev/core/layout';

setHarfBuzzWasmUrl('/static/harfbuzz.wasm');

The shaper reads this URL once per module instance. A later call warns and does nothing. A Web Worker has a separate module instance and needs its own call.

Use a URL that your application controls. Do not build it from user input or remote configuration.

Cross-origin hosting needs that origin in connect-src. WebAssembly needs wasm-unsafe-eval in script-src.

The shaper rejects a missing, stale, or version-mismatched binary. An npm overrides entry for harfbuzzjs does not change bundled shaper code. Upgrade @docx-editor.dev/core instead.

For engine implementation details, see Architecture.

Next steps

On this page