Skip to content

Fonts

json-to-office never embeds font bytes into a generated .docx or .pptx — the output always relies on fonts installed on the reader's machine. This page explains why, which fonts are safe to use, and how the library handles everything else: substitution, strict validation, and registering custom typefaces for preview rendering.

Why no embedding?

Correct font embedding in OOXML is a minefield: Word for Mac silently falls back for non-regular/bold/italic weights, fsType license bits can forbid embedding, WOFF/WOFF2 aren't spec-compliant inside OOXML, and Google Fonts metadata routinely needs byte-level patching before Word accepts it. Substitution to office-installed fonts sidesteps every one of those failure modes and keeps output predictable.

SAFE_FONTS

These 15 families ship with Microsoft Office, Windows, or macOS. Referencing them guarantees the output renders as authored on any machine, with no substitution and no warnings:

Arial, Calibri, Cambria, Consolas, Courier New, Georgia,
Segoe UI, Tahoma, Times New Roman, Trebuchet MS, Verdana,
Helvetica, Helvetica Neue, Menlo, Monaco

Matching is case-insensitive. Every other family is "non-safe" and goes through the export-mode pre-pass below.

Export modes

Every referenced font family — collected from the document and the active theme — is checked against SAFE_FONTS before rendering. What happens to non-safe references depends on the mode, set via options.fonts.mode (library) or --font-mode (CLI):

ModeBehavior
custom (default)References are kept as authored. Readers with the font installed see the intended typeface; everyone else gets the host's fallback. Emits a single FONT_MODE_CUSTOM warning when non-safe fonts are present and a fonts option was passed.
substituteEvery non-safe family in the document and theme is rewritten to a SAFE_FONTS equivalent at generate time, so output renders identically everywhere. Emits a single FONT_MODE_SUBSTITUTED warning listing the swaps.

The default substitution map

In substitute mode, each non-safe family is mapped in this order:

  1. Explicit built-in overrides for popular families — e.g. Inter → Calibri, Playfair Display → Georgia, JetBrains Mono → Consolas.
  2. Category fallback, using the family's category from the bundled Google Fonts catalog: sans → Calibri, serif → Georgia, mono → Consolas, display → Georgia, handwriting → Segoe UI.
  3. Calibri as the final default.

Overriding substitutions

Force specific swaps with options.fonts.substitution (library) or repeatable --font-substitute flags (CLI). Anything you don't override falls back to the defaults above.

bash
jto docx generate report.json \
  --font-mode substitute \
  --font-substitute Inter=Calibri \
  --font-substitute "Playfair Display=Georgia"
ts
import { generateBufferFromJson } from '@json-to-office/core-docx';

const buffer = await generateBufferFromJson(json, {
  fonts: {
    mode: 'substitute',
    substitution: {
      Inter: 'Calibri',
      'Playfair Display': 'Georgia',
    },
  },
});

Strict mode

options.fonts.strict: true (library) or --strict-fonts (CLI) turns unresolved font references into hard failures instead of warnings: generation throws on any family that is neither in SAFE_FONTS nor backed by a registered entry.

The check runs after the export-mode pre-pass, which makes the two modes behave differently under strict:

  • In custom mode, every non-safe reference survives to validation — strict throws.
  • In substitute mode, non-safe references have already been rewritten to safe fonts — strict only throws if something slipped past the rewrite (e.g. a custom substitution targeting another non-safe family).

WARNING

Strict is a library/CLI feature only. The dev-server HTTP API strips strict from client-supplied options.fonts on POST /api/<format>/generate, so a stray font reference can't be turned into a predictable 500. See the CLI reference for that endpoint.

Registering non-safe fonts

To use a font outside SAFE_FONTS, register it. There are three places to do that, and they compose — later ones win on a family (or id) collision:

WhereHowUse it for
ThemefontRegistry at the theme rootA brand font every document using that theme should get.
Documentprops.fontRegistryA font that travels with the JSON — survives export, re-import, and sharing.
Runtimeoptions.fonts.extraEntriesHost-supplied overrides that should not be written into the document.

Each registry entry has an id, a family (the display name you reference from font.family / fontFace / theme.fonts.*), an optional category (sans | serif | mono | display | handwriting — used by the substitution fallback), and one or more sources. Six source kinds are supported:

KindFieldsMeaning
safefamilyAn office-installed font; nothing to fetch.
googlefamily, weights (default [400, 700]), italics (default false)Fetched from Google Fonts at generate time.
filepath (.ttf/.otf; relative paths resolve against the JSON document's directory or baseDir), weight, italicA local font file.
datadata (base64 or data: URL), weight, italicFont bytes inlined in the entry — keeps the setup self-contained.
urlurl (HTTPS TTF/OTF), weight, italicA direct CDN URL — useful to bypass metadata defects in Google's redistributed files.
variableurl (variable TTF), weight (required), italic, axesA variable font; the wght axis (plus any axes) is pinned via HarfBuzz to emit a clean static TTF.
ts
const buffer = await generateBufferFromJson(json, {
  fonts: {
    extraEntries: [
      {
        id: 'inter',
        family: 'Inter',
        category: 'sans',
        sources: [
          { kind: 'google', family: 'Inter', weights: [400, 600, 700] },
        ],
      },
      {
        id: 'brand-serif',
        family: 'Brand Serif',
        category: 'serif',
        sources: [
          { kind: 'file', path: './fonts/BrandSerif-Regular.ttf', weight: 400 },
        ],
      },
    ],
  },
});

The same entry shape goes in the document itself, which is the portable option — nothing outside the JSON is needed:

json
{
  "name": "docx",
  "props": {
    "fontRegistry": [
      {
        "id": "brand-sans",
        "family": "Brand Sans",
        "category": "sans",
        "sources": [{ "kind": "data", "data": "AAEAAA...", "weight": 400 }]
      }
    ]
  }
}

A fontRegistry is a declaration, not a reference: the families named inside it (including a google source's family) are not treated as fonts the document uses, and substitute mode never rewrites them. Only actual font.family / fontFace / theme.fonts.* references count.

Registration is for rendering fidelity, not embedding

Registered font bytes are materialized (fetched, cached, pinned) only for the pipelines that need real font files on disk, and are registered with the OS before LibreOffice runs. There are two:

  • the LibreOffice preview, so the in-browser PDF preview shows the real typeface; and
  • visual rasterization, where an out-of-process LibreOffice renders the nested slide to a PNG.

The second one has a consequence worth knowing: a document containing a visual materializes its fonts — including Google Fonts network fetches — even on a plain CLI jto docx generate, with no preview involved, because the rasterizer cannot render text without the files. Documents with no visual never fetch.

The downloaded .docx/.pptx bytes are unaffected either way: no embedding, ever.

From the playground

The Fonts dialog has a Custom tab that writes into the open document's props.fontRegistry for you:

  • Upload a font file — drop in a .ttf or .otf (2 MB each). It is base64-encoded in the browser and stored as a kind: "data" source, so the font travels with the document. WOFF and WOFF2 are rejected: the preview stager registers every face as a .ttf, so a web font would silently fail to load. Family and weight are guessed from the filename (Geist-SemiBold.ttf → Geist at 600) and you can reference the family straight away.
  • Embed any Google font — not just the curated catalog. The family is fetched server-side and stored as kind: "data" sources too.

Because the bytes live inside the JSON, the whole document is capped at 12 MB before it exceeds the server's request limit; the dialog refuses a write that would cross it.

Uploaded and embedded fonts also render in the fast in-browser preview, which synthesizes an @font-face block from the registry. Intermediate weights work there too: a run authored as fontWeight: 300 is written into the DOCX as the sub-family Brand Sans Light, and the preview registers a matching face under that exact name.

CLI font flags

All flags apply to jto docx generate and jto pptx generate; see the CLI reference for the full command surface.

FlagDescription
--font <name=path>Register a font file (repeatable): <family>=<path to .ttf/.otf>.
--fonts-dir <path>Scan a directory for .ttf/.otf files and auto-register them by filename.
--font-mode <mode>custom (default) or substitute.
--font-substitute <family=safe>Map a non-safe family to a specific safe font (repeatable; used with --font-mode substitute).
--strict-fontsFail generation on unresolved font references.
--no-google-fontsSets fonts.googleFonts.enabled: false. Forwarded to font resolution, but has no effect on generate output because generate never fetches — fetching happens only in the dev-server preview pipeline.
--font-cache-dir <path>Sets fonts.googleFonts.cacheDir. Forwarded the same way, and inert on generate output for the same reason.

Warning codes

Font handling surfaces structured warnings you can collect programmatically (via the warnings option) or read from CLI output:

CodeMeaning
FONT_UNRESOLVEDA family is neither in SAFE_FONTS nor registered; output relies on host fallback. Becomes a thrown error under strict mode.
FONT_MODE_SUBSTITUTEDSubstitute mode rewrote one or more families (the warning lists the swaps).
FONT_MODE_CUSTOMCustom mode kept non-safe references as authored.
FONT_METADATA_DEFECT:*Non-fatal TTF metadata issues found by the registry validator: WEIGHT_CLASS_MISMATCH, SUBFAMILY_MISMATCH, LEGACY_SUBFAMILY_MISMATCH. fsType embedding-permission bits are intentionally not checked — output never embeds fonts, so permission warnings would be noise.
FONT_OVERRIDE_LOCALA caller-supplied extraEntries entry took precedence over the Google Fonts auto-fetch for a referenced family. Emitted only by the dev-server (playground preview) pipeline — never by CLI generate or the library.

fontWeight vs bold

Anywhere text formatting is accepted (document props, theme fonts, style presets), you can set either:

  • bold: true — equivalent to fontWeight: 700, or
  • fontWeight — an integer from 100 to 900, for finer control with families that ship intermediate weights (e.g. 300 Light, 600 SemiBold).

When both are set, fontWeight wins. Combine with a google or variable source entry that declares the weights you use so the preview can render them faithfully.

TIP

The bundled Google Fonts catalog (POPULAR_GOOGLE_FONTS) is a curated snapshot in @json-to-office/shared. Maintainers refresh it with GOOGLE_FONTS_API_KEY=... pnpm --filter @json-to-office/shared update:fonts-catalog. See Contributing.

Released under the MIT License.