Fonts and measurement

Supply font bytes for accurate DOCX line wrapping and pagination with embedded fonts, packaged substitutes, or your own files.

The editor shapes text with HarfBuzz when it has usable font bytes. Without them, it uses fallback measurement: canvas in a browser when available, fixed measurement elsewhere. The document still opens, but line and page breaks can differ from the original.

CSS fonts and font picker entries do not supply font bytes. Only a configured font source makes measurement accurate.

Configure document fonts

Pass resolvers to useDocxSource. Vue provides the same API.

import { useDocxSource } from '@docx-editor.dev/react';
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 source that supplies a face wins. No optional font source is enabled by default.

To pass fonts to the fonts prop directly, wrap them in useFonts. An inline resolver is a different function on each render. Each identity change calls setFonts() and resolves the fonts again. The editor keeps the document, selection, and undo history.

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 document={bytes} fonts={fonts} />;
}

Each useFonts argument is a resolver, a fragment, a configuration, or a promise for one. Arguments compose in order, and the first wins.

In Vue, a function argument is always the value, never a getter. Use computed(() => packagedFonts()) to build one lazily.

Choose a font source

SourceThird-party requestsLoads
Fonts embedded in the DOCXNoThe faces in the file, automatically
packagedFonts()NoThe packaged families the document uses, plus its default face
defaultFonts()NoAll 20 faces of the five defaults, before the document opens
googleFonts()Yes, for cataloged familiesThe catalog families the document uses, plus its default face
customFonts()Depends on your URLsAll configured faces when the editor resolves fonts
loadFonts()Depends on your URLsThe URLs you list, immediately
No usable sourceNoNothing; fallback measurement

@docx-editor.dev/fonts ships these open-licensed, metric-compatible substitutes:

Original fontSubstituteLicenseIn defaultFonts()
CalibriCarlitoSIL OFLYes
CambriaCaladeaSIL OFLYes
Times New RomanLiberation SerifSIL OFLYes
ArialLiberation SansSIL OFLYes
Courier NewLiberation MonoSIL OFLYes
Century GothicTeX Gyre AdventorGUST Font LicenseNo
Segoe UISelawikSIL OFLNo
  • packagedFonts() covers all seven families. It also serves Helvetica with the Liberation Sans faces.
  • To load all seven eagerly, call defaultFonts({ families: ALL_DEFAULT_FONT_FAMILIES }). The two extra families add about 797 KB.
  • Narrow packagedFonts() and googleFonts() with allow, for example packagedFonts({ allow: ['Calibri'] }).
  • Substitutes match advance widths, but kerning can still change line breaks. Supply licensed bytes when pixels must match.
  • Importing the package fetches nothing. Font binaries load as separate assets.

The preferred names DefaultFontFamily, DEFAULT_FONT_FAMILIES, and ALL_DEFAULT_FONT_FAMILIES replace the deprecated WordDefaultFamily, WORD_DOCUMENT_DEFAULT_FAMILIES, and ALL_WORD_DEFAULT_FAMILIES. The deprecated aliases still work.

Choose between lazy and eager loading

packagedFonts() and defaultFonts() measure the same for the families they share.

BehaviorpackagedFonts()defaultFonts()
RunsAfter the document is parsedBefore the document opens
CoversAll seven packaged familiesThe five common DOCX default fonts
LoadsThe families in the document, plus the default faceAll 20 faces, 7.4 MB
First layoutFallback measurement, then re-paginatesCorrect on the first pass
Undo history across the swapKeptKept

Pass defaultFonts when a visible re-pagination is worse than the extra bytes. useDocxSource then waits for fonts before it returns the document.

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

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

Load your own fonts

Use customFonts() for brand or licensed fonts. Put it first so its faces take precedence.

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

function Editor({ bytes }: { bytes: Uint8Array }) {
  const fonts = useFonts(
    customFonts({
      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' },
      ],
      onFailure: (failure) => console.warn(failure.request.family, failure.reason),
    }),
    packagedFonts()
  );
  return <DocxEditor document={bytes} fonts={fonts} />;
}

Creating the resolver fetches nothing. onFailure defaults to console.warn, and cancellation does not call it. Loaded fonts appear in the font picker.

Load font bytes eagerly

Call loadFonts() when you need validated bytes before you open a document.

import { createDocxEditor, loadFonts, type FontUrlSource } from '@docx-editor.dev/core/editor';

const sources: FontUrlSource[] = [
  { url: '/fonts/AcmeSans-Regular.ttf', family: 'Acme Sans', weight: 400, style: 'normal' },
];

const editor = createDocxEditor({ document: bytes, fonts: await loadFonts({ sources }) });
editor.attach(container);
loadFonts caseResult
One source failsReturns the valid sources and a typed failures list
Hash matchesAccepts the source
Hash differsRejects the source with hashMismatch
Hash omittedComputes a hash for the returned source
Same request repeatsUses the Cache API when available. Without it, each call fetches again.

Pin every URL that you do not control. Load once without a hash, then store each returned source.hash.

If you already have bytes, call createFontSource. It returns a typed failure instead of throwing.

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

const made = createFontSource(bytes, { family: 'Acme Sans', weight: 400, style: 'normal' });
const fonts = 'source' in made ? composeFontConfiguration({ sources: [made.source] }) : undefined;

Write your own resolver

Wrap a resolver in defineFontResolver. useDocxSource uses the marker to tell a resolver from a loader such as defaultFonts. useFonts accepts an unmarked resolver only as its first argument.

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

const brandFonts = defineFontResolver(async ({ families, defaultFamily, resolvedFaces }) => {
  // Match names against a closed set. Skip faces in resolvedFaces.
  const wanted = [defaultFamily, ...families].filter((family) => BRAND.has(family));
  return { sources: await loadBrandFaces(wanted) };
});
Request fieldContents
familiesFamilies the document declares, including w:sym, SYMBOL field, and numbering marker faces
defaultFamilyThe face for runs that name no font
resolvedFacesFaces that earlier sources already supplied

A resolver can also return supportedFamilies to add catalog entries to the font picker without bytes.

Control loading and caching

Resolvers run when the editor resolves fonts and again when an edit adds a family that is not loaded. Font arrivals keep the selection, pending formatting, and undo history.

RuleBehavior
PrecedenceAn explicit source beats an embedded face. An embedded face beats a substitution. The first fragment wins between equal sources.
DuplicatesComposition removes duplicate faces.
RetryThe editor requests each family once per load. Reload the document to retry a failed request.
UpdatesLater family requests read current arguments. Reload to replace loaded faces.
Family listThe editor caps the family list passed to a resolver.

composeFontConfiguration(base, ...fragments) builds one immutable configuration with the same precedence.

Configure Google Fonts

googleFonts() uses a catalog pinned to immutable google/fonts commits with checked sha256: hashes. Pass substitute to redirect families.

googleFonts({
  allow: ['Tinos', 'Carlito'],
  substitute: { Georgia: 'Tinos' },
  fetcher: (input) => fetch(rewriteToYourMirror(String(input))),
  onFailure: ({ family, url, diagnostic }) => report(family, url, diagnostic),
});
OptionDescription
allowLimits the families the resolver serves
substituteRedirects a family. Your entries win over the built-in metric map.
fetcherReplaces fetch, for a mirror or a persistent cache. packagedFonts() and defaultFonts() accept it too.
onFailureReceives each dropped face. Defaults to a console warning.
  • The engine rejects downloaded bytes that do not match their hash.
  • googleFonts() caches requests in memory per fetcher. The cache does not persist to disk or cross worker boundaries.
  • Packaged loaders read the requested assets on each call. Supply fetcher for a persistent or shared cache.
  • Century Gothic and Segoe UI come from packaged bytes, with no third-party request.
  • An arbitrary substitute can change pagination. Prefer a metric-compatible one.
  • Without network access, a dropped face falls back and page breaks move. Use a mirror through fetcher, or use packagedFonts() alone.

Handle font failures

Fonts never block a document from opening. Each failed face falls back on its own.

HostNotification
React rootonFontError
Vue root@font-error
createDocxEditoronFontError option
EditorFontError codeCause
missingNo bytes are available for the font
malformedThe bytes are not a valid font
overLimitThe font exceeds the font byte budget
hashMismatchThe bytes do not match the pinned hash
wasmUnavailableThe HarfBuzz WASM asset did not load

These are the common codes. For the full list, see the EditorFontErrorCode type.

Read editor.fontMeasurement() to inspect measurement.

FieldMeaning
measurer'shaped' or 'fixed'
resolvingtrue while font resolution runs
producerOptional source identifier

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

Deploy font assets

Serve the HarfBuzz WASM asset

Webpack, Turbopack, and Vite emit harfbuzz.wasm automatically. Some esbuild, Bun, and library builds do not. Without it, the editor reports wasmUnavailable and uses fixed measurement.

Copy the file from the installed core package after each 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'
);

Call setHarfBuzzWasmUrl before you create the first editor:

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

setHarfBuzzWasmUrl('/static/harfbuzz.wasm');
  • The shaper reads the URL once per module instance. A later call warns and does nothing.
  • A Web Worker has its own module instance and needs its own call.
  • Use a URL that your application controls. Never build it from user input or remote configuration.
  • Cross-origin hosting needs the origin in connect-src. WebAssembly needs wasm-unsafe-eval in script-src.
  • The shaper rejects a stale or mismatched binary. An overrides entry for harfbuzzjs has no effect; upgrade @docx-editor.dev/core.

Relocate packaged fonts in Node or Bun

For a single-file executable, copy the fonts package's assets/ files into one directory. Set DOCX_EDITOR_FONT_ASSET_ROOT before the package is imported:

DOCX_EDITOR_FONT_ASSET_ROOT=/opt/my-app/fonts ./my-app

Use an absolute path or a file: URL, and keep the original file names. The package ignores relative paths, filesystem roots, and other URL schemes, and it reads the setting when its module loads. The setting only moves assets; it does not enable a font source.

Upgrade from page-wide registration

Since 2.18.0, font loaders supply bytes for private editor registration only. Your app's own text keeps its fonts.

  • The packagedFonts option install is ignored, including true. Remove it.
  • installDefaultFontFaces() does nothing and resolves to 0. Pass packagedFonts() or defaultFonts() through the fonts option instead.
  • If app text relied on page-wide fonts, load them with your own CSS.
- fonts: packagedFonts({ install: true, onFailure })
+ fonts: packagedFonts({ onFailure })

Limits

  • Never build a font URL from a document family name. Match file-supplied names against a closed Map or catalog.
  • A remote font host can learn which families a document uses.
  • Embedded fonts register under private aliases. A document cannot replace a page-wide font family in your app.
  • Packaged faces do not cover every script. For example, Liberation Sans has no Arabic glyphs. Native fallback renders missing glyphs.
  • The font notice lists rendered families with no compatible face. It excludes metric-compatible substitutions.
  • A custom TextMeasurer can implement inkBounds(text, style) for CJK punctuation fitting. Return { left, right }: conservative horizontal ink bounds for one grapheme, in points from its origin. Include font size and horizontal scale, not trailing character spacing. Return undefined when bounds are unknown. To measure, use left-aligned text, negate actualBoundingBoxLeft, and convert pixels to points before you apply horizontal scale.
  • Font slot selection, font name parsing, and theme font defaults follow DOCX feature support.

Next steps