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.

Font picker choices

The toolbar always offers the standard font choices, including Arial, Calibri, Georgia, Times New Roman, and Courier New. Configured and document-specific families extend this list. Configured providers also contribute their supported families, even before those fonts load. The default dropdown includes a search field. Empty documents offer the same provider catalog as populated documents.

packagedFonts() lists its supported Word families. googleFonts() lists the families in its pinned static-font catalog. Each provider applies its allow option; Google also applies custom substitute mappings.

Custom resolvers can return supportedFamilies: ['Brand Sans', 'Brand Serif'] alongside their sources and substitutions. A catalog-only result is valid. Listing a family does not require its bytes.

A picker choice does not mean the font is installed or loaded. Opening the picker does not load font bytes. Your font configuration still controls measurement and font resolution.

Configure font sources

Pass a font resolver to useDocxSource. For packaged substitutes, use packagedFonts(). The examples use the React hook; Vue provides the same API. Call the hook inside a component.

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

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

To add Google Fonts, pass the resolvers in an array:

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 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 cataloged familiesThe catalog families in use, plus the default faceUses the fetched faces
customFonts()Depends on your URLsAll configured faces when the editor resolves fontsUses validated files
loadFonts with your URLsDepends on your URLsThe URLs you listUses validated 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 for the glyphs they cover. Kerning differences can still change line breaks.

TeX Gyre Adventor has slightly narrower advance widths than Century Gothic. The measured difference stays within 1% in bun run check:font-width-fidelity, but can still change line breaks.

Packaged faces do not cover every script. Liberation Sans has no Arabic glyphs. The editor preserves native family fallback for missing glyphs. Exact metrics still depend on the available font for that script.

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.

Relocate packaged fonts in Node or Bun

For a single-file executable, copy the contents of the fonts package's assets/ directory into a dedicated directory. Set DOCX_EDITOR_FONT_ASSET_ROOT before starting the process:

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

The directory must contain the packaged .ttf and .otf files with their original names. Use an absolute filesystem path or a file: URL. Relative paths, filesystem roots, and non-file URLs are ignored.

The package reads this setting when its module loads. Setting it after importing the package does not relocate the fonts. The setting changes packaged asset locations; it does not register custom fonts or enable a font source. Configure packagedFonts() or defaultFonts() as usual. Browser builds use their bundled asset URLs.

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 swapKept when the faces arriveKept

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 waits for fonts before returning the document. On-demand resolvers need the parsed document to identify fonts, so the first layout can use fallback measurement.

Editor-scoped fonts

defaultFonts() and packagedFonts() supply font bytes for the editor. Core registers these bytes under private font names. Your app's header and sidebar keep their existing fonts. Native fonts remain available for glyphs missing from the substitutes.

Upgrade from page-wide registration

Before 2.18.0, loaders could register substitutes under public names such as Arial. From 2.18.0, font loaders supply bytes for private editor registration only. If you already pass packagedFonts() or defaultFonts() through the editor's fonts option, keep that configuration.

The packagedFonts option install is deprecated and ignored, including true. Remove it while keeping your other loader options:

- fonts: packagedFonts({ install: true, onFailure })
+ fonts: packagedFonts({ onFailure })

installDefaultFontFaces() is deprecated. It does nothing and resolves to 0, without fetching or registering fonts. Remove its calls and supply packagedFonts() or defaultFonts() through the editor's fonts option instead. defaultFonts() has no install option. loadDefaultFonts() remains bytes-only.

If surrounding app text relied on these public fonts, configure those fonts separately with your app's CSS or font loader. Check the fonts in your app's headers, sidebars, and other text after upgrading.

Load your own fonts

Use customFonts() to supply brand fonts or licensed Word fonts to the editor. Put it first so your supplied faces take precedence.

import { customFonts } from '@docx-editor.dev/core/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(
    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(),
    googleFonts()
  );
  return <DocxEditor document={bytes} fonts={fonts} />;
}

customFonts() loads all configured faces when the editor resolves its fonts. Creating the resolver does not fetch files. It skips faces supplied by earlier sources, matching the family name without case sensitivity and matching weight and style.

Loaded fonts appear in the picker, including in blank documents. Select a font to apply it to document text.

The helper uses loadFonts for validation and caching. onFailure receives each failed face and defaults to console.warn. Cancellation does not trigger onFailure. Core registers the supplied bytes under private names, without changing fonts elsewhere in your app.

Load font bytes eagerly

loadFonts() remains the lower-level eager loader. It starts loading every listed source when called and returns validated bytes with a typed failures list. Use it when you need font bytes before opening 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' },
];

async function mountEditor(container: HTMLElement, bytes: Uint8Array) {
  const editor = createDocxEditor({
    document: bytes,
    fonts: await loadFonts({ sources }),
  });
  editor.attach(container);
  return editor;
}
loadFonts behaviorResult
One source failsReturns validated sources and a typed failures list.
Hash matchesAccepts 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

customFonts(), packagedFonts(), and googleFonts() return resolvers. Creating them fetches nothing. When the editor resolves fonts, customFonts() loads all configured faces. packagedFonts() and googleFonts() load families requested by the document or its default font. The editor calls resolvers again when typing, formatting, or another edit introduces a new family. Selecting a font for an empty paragraph also requests it.

Font arrivals update the current editing session. Selection, pending typing formatting, and undo history survive the reflow. Previously supplied faces are passed as resolvedFaces, so providers can skip redundant downloads. The editor requests each document family once per load. Reload the document to retry a failed request.

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 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.
UpdatesNew-family requests read current arguments. Reload to replace existing faces.
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 cataloged under its own name.

If no source matches, the host keeps its fallback measurement. The resolver does not choose substitutes from PANOSE, the font classification in word/fontTable.xml. That classification does not describe glyph widths, so it cannot predict compatible line breaks.

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 cataloged face comes from:

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

Each face is pinned to an immutable commit and a sha256: hash. The engine checks the downloaded bytes against that hash and rejects mismatches.

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

Core reuses the loaded bytes for private registration without another request.

Use fetcher to add persistent or shared caching. googleFonts() caches catalog requests in memory, keyed by fetcher. Packaged loaders read their assets on each call. This cache does not persist to disk or share data between workers:

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 your resolver in defineFontResolver. This lets useDocxSource distinguish a resolver that receives a font request from a loader such as defaultFonts that takes no arguments.

useFonts accepts an unmarked resolver as its first argument. Wrap resolvers passed as later arguments in defineFontResolver.

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 faces already loaded by earlier sources, including weight and style. Use it to skip duplicate downloads. Composition removes duplicate faces even if your resolver ignores this list.

Sources resolve sequentially so each receives the faces supplied by earlier sources. Put preferred sources first; when either source is suitable, put the faster one 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.

Custom text measurement

If you supply a custom TextMeasurer, you can implement the optional inkBounds(text, style) method for more accurate CJK punctuation fitting. The built-in canvas and HarfBuzz measurers provide this information when available.

Return { left, right } as conservative horizontal ink coordinates for one grapheme, in points from its origin. Include font size and horizontal scale, but exclude trailing character spacing. These coordinates describe the visible ink, not the glyph's advance width. Return undefined when you cannot provide reliable bounds; the editor keeps its advance-based layout behavior.

For canvas metrics, measure left-aligned text and negate actualBoundingBoxLeft. Convert both coordinates from pixels to points before applying the horizontal scale. Supplying bounds enables supported optical CJK fits, so line breaks can change.

Arabic and bidirectional paragraphs

Text paragraphs use the inherited w:bidi setting for default alignment and visual order. Arabic and Latin text keep their logical document offsets when the editor places words from right to left. List markers and suffix spacing follow the paragraph direction in body text and table cells. Logical list indents resolve after inherited and direct paragraph direction.

The shaper uses the resolved script and direction. Adjacent source runs with identical formatting and metadata shape together, preserving Arabic joining. When a font lacks a glyph, browser layout uses host fallback measurement instead of the missing-glyph box. Supply an Arabic-capable font for consistent browser and server metrics. Caret movement uses whole-run cluster advances when font bytes are available. Without cluster data, caret placement uses a bounded approximation within the text band. Comment, search, and remote selection highlights follow separate visual bands in mixed-direction text. Arrow keys collapse selections to available caret positions. At mixed-direction boundaries, a selected glyph edge can lack a distinct insertion position. Partial ligature highlights can extend beyond their caret position.

Bidirectional paragraphs with tabs or inline atoms retain the existing placement path. Complex-script font selection and joining across formatting boundaries remain partial.

Next steps

On this page