BlockNote DocsFeaturesExportPDF

PDF Export

It's possible to export BlockNote documents to PDF, completely client-side. The exporter is powered by the Typst typesetting engine (compiled to WebAssembly) and produces accessible, tagged PDF/UA-1 documents: the PDF carries a logical structure tree (headings, paragraphs, lists, tables, figures with alt text, links) that screen readers can navigate.

This feature is provided by the @blocknote/xl-pdf-exporter. xl- packages are fully open source, but released under a copyleft license. A commercial license for usage in closed source, proprietary products comes as part of the Business subscription.

First, install the @blocknote/xl-pdf-exporter package:

npm install @blocknote/xl-pdf-exporter

Then, create an instance of the PDFExporter class and export the document:

import {
  PDFExporter,
  typstDefaultSchemaMappings,
} from "@blocknote/xl-pdf-exporter";

// Create the exporter
const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings);

// Export the document - the result carries the PDF as bytes and as a Blob
const { blob, pdfUA } = await exporter.toPDF(editor.document, {
  title: "My document",
  lang: "en",
});

This works out of the box, fully offline: the export matches the editor's look (Inter body text, Geist Mono code) and handles math blocks and emoji — the default fonts ship inside the package and load lazily on the first export, and the compiler (a ~25MB wasm file from @blocknote/xl-typst-compiler) loads from the package's own files, emitted as an asset by your bundler. Nothing is ever fetched from a CDN.

The result's pdfUA field reports whether the document earned the PDF/UA-1 conformance claim — see PDF/UA conformance.

When repeatedly exporting changing content (e.g. a live preview), create a fresh exporter per export — construction is cheap, and an exporter instance accumulates the image assets it has resolved for as long as it lives.

See the full example with a live PDF preview below:

Customizing the PDF

The second parameter of toPDF is a single per-export options bag: the per-document options below, plus tryDeclarePdfUA, extra assets for caller-supplied markup, and a creationTimestamp:

const { blob } = await exporter.toPDF(editor.document, {
  // Document title - required for PDF/UA (also shown in the viewer's title bar)
  title: "My document",
  // Document author, written to the PDF metadata
  author: "John Doe",
  // BCP-47 language tag of the document's natural language
  lang: "en",
  // Typst paper name, e.g. "a4" (default) or "us-letter"
  paper: "a4",
  // Page margin as a Typst length
  margin: "48pt",
  // Raw Typst markup for the running page header / footer, e.g. a
  // page counter: "#context counter(page).display()"
  header: "My document",
  footer: "#context counter(page).display()",
});

Custom mappings / custom schemas

The PDFExporter constructor takes a schema and mappings parameter. A mapping defines how to convert a BlockNote schema element (a Block, Inline Content, or Style) — for this exporter, into a Typst markup string. The same mappings drive the standalone Typst export, so one custom-block mapping serves both formats.

If you're using a custom schema in your editor, or if you want to overwrite how default BlockNote elements are converted, you can pass your own mappings:

import {
  PDFExporter,
  typstDefaultSchemaMappings,
  strLit,
} from "@blocknote/xl-pdf-exporter";

new PDFExporter(schema, {
  ...typstDefaultSchemaMappings,
  blockMapping: {
    ...typstDefaultSchemaMappings.blockMapping,
    myCustomBlock: (block, exporter) => {
      // Return Typst markup; `strLit` safely embeds user text as a
      // Typst string literal.
      return `#${strLit("My custom block")}`;
    },
  },
});

For a block with inline content, render it the way the default mappings do: exporter.transformInlineContent(block.content).join("") (inline results are markup strings, so plain concatenation composes them).

Math & diagram blocks

The math and diagram blocks ship Typst mappings — math exports as native Typst equations (real text, not images), diagrams as embedded vector SVG — both carrying alt text, as PDF/UA requires:

import { diagramBlockMapping } from "@blocknote/diagram-block/typst-exporter";
import {
  inlineMathMapping,
  mathBlockMapping,
} from "@blocknote/math-block/typst-exporter";

new PDFExporter(editor.schema, {
  ...typstDefaultSchemaMappings,
  blockMapping: {
    ...typstDefaultSchemaMappings.blockMapping,
    mathBlock: mathBlockMapping,
    diagram: diagramBlockMapping,
  },
  inlineContentMapping: {
    ...typstDefaultSchemaMappings.inlineContentMapping,
    math: inlineMathMapping,
  },
});

Fonts & offline use

By default, exports use a font set matching the editor — Inter (body), Geist Mono (code), New Computer Modern Math (math blocks) and Noto Color Emoji (emoji, required for PDF/UA) — embedded in the package and loaded lazily on the first export, and the compiler wasm loads from @blocknote/xl-typst-compiler's own package files. Neither touches a CDN. The exporter's constructor options control all of it; to take explicit control of where the wasm is served from (e.g. self-hosting with caching headers), pass its URL or bytes:

import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url";

const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, {
  // The compiler wasm as an explicitly bundled asset (Vite shown here).
  wasm: compilerWasmUrl,
});

To take full control of fonts (e.g. a different look, or trimming the lazily loaded defaults — the emoji font alone is ~5MB), pass your own font bytes. Each option independently replaces its bundled default: supplying fonts keeps the default emoji font (and vice versa), and an explicit empty array disables one entirely:

const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, {
  // Font bytes (Uint8Array) to load into the compiler - or a promise of
  // them, so lazy loading fits the sync constructor.
  fonts: [myBodyFont, myMonoFont],
  // An emoji-capable font. Browsers give the compiler no access to OS
  // fonts, so without one emoji render as missing glyphs (and fail PDF/UA).
  emojiFont: myEmojiFont,
});

The compiler ships no fonts of its own and never falls back silently: text that no supplied font covers fails the export loudly rather than rendering substituted glyphs. The wasm module is loaded once per page; fonts are per-export options, so different exports on the same page can freely use different font sets.

When passing custom fonts, set the exporter's font families to match — Typst selects fonts by the family name embedded in the font file itself (defaults: "Inter 18pt" body, "Geist Mono" code, "Noto Color Emoji" emoji). A mismatch shows up as an unknown font family entry in the result's compileWarnings, naming exactly what didn't resolve. To cover scripts the default fonts don't (e.g. CJK), extend rather than replace: the bundled defaults are exported as loaders, so spreading them plus your addition is plain composition, and a fontFamily list makes Typst fall back per glyph — Latin stays Inter, CJK comes from the added font:

import {
  loadDefaultBodyFonts,
  PDFExporter,
  typstDefaultSchemaMappings,
} from "@blocknote/xl-pdf-exporter";
import notoSansSCUrl from "./NotoSansSC-Regular.ttf?url";

// Family names and font bytes side by side - the whole font setup is
// constructor configuration.
const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, {
  fontFamily: ["Inter 18pt", "Noto Sans SC"],
  fonts: loadDefaultBodyFonts().then(async (fonts) => [
    ...fonts,
    new Uint8Array(await (await fetch(notoSansSCUrl)).arrayBuffer()),
  ]),
});

const result = await exporter.toPDF(editor.document, {
  title: "文档",
  lang: "zh",
});

(The family name in fontFamily must match what the font file itself declares — "Noto Sans SC" here; a mismatch shows up in compileWarnings.)

PDF/UA conformance

The produced PDF always carries the tagged (accessible) structure tree. On top of that, the exporter declares PDF/UA-1 conformance when — and only when — the document earns it: Typst validates conformance during the compile, and a nonconforming document is automatically exported as tagged-but-unclaimed instead (an honest output rather than a false claim), with the violations reported in the result:

const { blob, pdfUA } = await exporter.toPDF(editor.document, {
  title: "My document",
  lang: "en",
});
if (!pdfUA.declared && pdfUA.reason === "nonconforming") {
  // e.g. "PDF/UA-1 error: the first heading must be of level 1"
  console.info(pdfUA.violations.map((v) => v.message));
}

What conformance requires of the document:

  • Title and language: pass title and lang in the document options. Without a title, Typst's validation withholds the claim. The language is required to attempt the claim at all — exporting without lang throws (pass tryDeclarePdfUA: false to skip the claim instead), because the PDF would otherwise declare Typst's default language (English), and a wrong language declaration is an accessibility defect no validator can catch.
  • Headings: the first heading must be level 1, and levels must be consecutive (no jumping from H1 to H3).
  • Alt text: every image needs it. BlockNote's image block has no dedicated alt field yet, so the caption (or file name) is used — give images captions.

Pass tryDeclarePdfUA: false in the export options to skip the validation and claim entirely (e.g. for a live preview, where the validation compile would be wasted work). Conformance of the claim is enforced at compile time by the Typst engine itself — a declared export has also been verified against veraPDF's --flavour ua1 checks (0 failed checks) if you need independent validation.

Exporter options

The split between the two option surfaces matches the other exporters: the constructor configures everything the exporter is — styling, the font family names and the font bytes they resolve against, the compiler wasm — while toPDF's options carry the facts of the individual export: the document metadata and page setup, plus the conformance policy (tryDeclarePdfUA).

The PDFExporter constructor takes an optional third options parameter:

const defaultOptions = {
  // a function to resolve external resources (e.g. images) in order to avoid
  // CORS issues; by default, this calls a BlockNote hosted server-side proxy
  resolveFileUrl: corsProxyResolveFileUrl,
  // the strings rendered into the exported document (file link texts, error
  // placeholders); pass a locale from @blocknote/core/locales (or your
  // editor's dictionary) to export in another language
  dictionary: locales.en,
  // the colors used for highlighting, background colors and font colors
  colors: COLORS_DEFAULT, // defaults from @blocknote/core
  // font families, see "Fonts & offline use" above
  fontFamily: "Inter 18pt",
  monoFontFamily: "Geist Mono",
  // base font size in points
  fontSize: 12,
};

Exporting Typst markup

The underlying Typst source export is available standalone (e.g. to compile with your own Typst toolchain, including server-side) — see Typst export.

Deprecated: the react-pdf exporter

Previous versions of @blocknote/xl-pdf-exporter exported PDFs with react-pdf, producing untagged (not accessible) documents. That exporter is deprecated and will be removed after a few releases; until then it remains available unchanged from the @blocknote/xl-pdf-exporter/react-pdf subpath:

import {
  PDFExporter,
  pdfDefaultSchemaMappings,
} from "@blocknote/xl-pdf-exporter/react-pdf";

Note that its mappings are react-pdf mappings — when migrating to the new exporter, custom blocks need a Typst mapping instead.