Exporting documents to tagged PDF (PDF/UA)

This example exports the current document to an accessible, tagged PDF/UA-1 file using the Typst-powered @blocknote/xl-pdf-exporter. Unlike a plain PDF, a tagged PDF carries a logical structure tree (headings, paragraphs, lists, tables, figures with alt text, links) that screen readers can navigate.

Try it out: Edit the document — the PDF preview updates live. Click "Download" to save it, then verify it with a tool like veraPDF (--flavour ua1) or the Acrobat Tags panel.

The first export downloads the Typst compiler (wasm) and fonts, so it may take a moment. Images render as tagged placeholder figures for now.

import { testDocumentBlocks } from "./testDocumentBlocks";import {  Block,  BlockNoteSchema,  combineByGroup,  withPageBreak,} from "@blocknote/core";import { filterSuggestionItems } from "@blocknote/core/extensions";import "@blocknote/core/fonts/inter.css";import * as locales from "@blocknote/core/locales";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { createReactDiagramBlockSpec } from "@blocknote/diagram-block";import { diagramBlockMapping } from "@blocknote/diagram-block/typst-exporter";import {  createReactInlineMathSpec,  createReactMathBlockSpec,} from "@blocknote/math-block";import {  inlineMathMapping,  mathBlockMapping,} from "@blocknote/math-block/typst-exporter";import {  SuggestionMenuController,  getDefaultReactSlashMenuItems,  getPageBreakReactSlashMenuItems,  useCreateBlockNote,} from "@blocknote/react";import {  PDFExporter,  typstDefaultSchemaMappings,} from "@blocknote/xl-pdf-exporter";import {  getMultiColumnSlashMenuItems,  locales as multiColumnLocales,  multiColumnDropCursor,  withMultiColumn,} from "@blocknote/xl-multi-column";// Bundle the Typst compiler wasm so it resolves locally (no CDN / importer).// Fonts need no setup: the exporter's bundled defaults (Inter, Geist Mono,// math, emoji - matching the editor) load lazily from the package.import compilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url";import { useCallback, useEffect, useMemo, useRef, useState } from "react";import "./styles.css";/** * Exports the given document to a PDF/UA object URL, re-exporting whenever * `blocks` changes. * * The effect-with-cleanup idiom keeps only the newest result: when a newer * version (or unmount) invalidates the effect, the cleanup marks the running * export stale and its result is dropped. Overlapping exports are *safe* - * the exporter serializes its shared compile stage internally - but like any * async calls they may complete out of call order, and which result to * display is this component's concern, not the exporter's. */function usePdfUA(  makeExporter: () => PDFExporter<any, any, any>,  blocks: Block<any, any, any>[],) {  const [pdfUrl, setPdfUrl] = useState<string>();  const [status, setStatus] = useState<"loading" | "ready" | "error">(    "loading",  );  useEffect(() => {    let stale = false;    setStatus("loading");    void (async () => {      try {        const blob = await makeExporter().toBlob(          blocks,          { getModule: () => compilerWasmUrl },          { title: "BlockNote document", lang: "en" },        );        if (stale) {          return;        }        setPdfUrl(URL.createObjectURL(blob));        setStatus("ready");      } catch (e) {        if (stale) {          return;        }        // eslint-disable-next-line no-console        console.error(e);        setStatus("error");      }    })();    return () => {      stale = true;    };  }, [makeExporter, blocks]);  // Each object URL is revoked when replaced by the next one (and the last  // one on unmount).  useEffect(() => {    return () => {      if (pdfUrl) {        URL.revokeObjectURL(pdfUrl);      }    };  }, [pdfUrl]);  return { pdfUrl, status };}export default function App() {  // Creates a new editor instance with support for page breaks.  const editor = useCreateBlockNote({    // Adds support for math & diagram blocks.    schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({      blockSpecs: {        mathBlock: createReactMathBlockSpec(),        diagram: createReactDiagramBlockSpec(),      },      inlineContentSpecs: {        math: createReactInlineMathSpec(),      },    }),    dropCursor: multiColumnDropCursor,    dictionary: {      ...locales.en,      multi_column: multiColumnLocales.en,    },    tables: {      splitCells: true,      cellBackgroundColor: true,      cellTextColor: true,      headers: true,    },    initialContent: [      ...testDocumentBlocks,      // The math & diagram blocks aren't part of the shared test document,      // since the exporter unit tests' schemas don't register them, so they're      // appended here instead.      {        type: "mathBlock",        content: "a^2 = \\sqrt{b^2 + c^2}",      },      {        type: "diagram",        content: `graph TD  A[Start] --> B{Works?}  B -->|Yes| C[Ship it]  B -->|No| A`,      },      {        type: "paragraph",        content: [          {            type: "text",            text: "Inline math: ",            styles: {},          },          {            type: "math",            content: "e^{i\\pi} + 1 = 0",          },        ],      },    ],  });  // Additional Slash Menu items for page breaks.  const getSlashMenuItems = useMemo(    () => async (query: string) =>      filterSuggestionItems(        combineByGroup(          getDefaultReactSlashMenuItems(editor),          getPageBreakReactSlashMenuItems(editor),          getMultiColumnSlashMenuItems(editor),        ),        query,      ),    [editor],  );  // A fresh exporter per export: its asset registry is append-only for the  // exporter's lifetime, so reusing one across re-exports would accumulate  // every image/diagram variant it has ever rendered.  const makeExporter = useCallback(    () =>      new PDFExporter(editor.schema, {        ...typstDefaultSchemaMappings,        blockMapping: {          ...typstDefaultSchemaMappings.blockMapping,          // Renders math blocks as native Typst equations, and diagrams as          // embedded images - both carrying alt text for PDF/UA.          mathBlock: mathBlockMapping,          diagram: diagramBlockMapping,        },        inlineContentMapping: {          ...typstDefaultSchemaMappings.inlineContentMapping,          math: inlineMathMapping,        },      }),    [editor],  );  // The document snapshot driving the export - the export effect depends on  // the data it exports. Updated debounced: reading `editor.document`  // converts the whole document to blocks, so it shouldn't run (and the  // export shouldn't restart) on every keystroke.  const [blocks, setBlocks] = useState(() => editor.document);  const { pdfUrl, status } = usePdfUA(makeExporter, blocks);  const debounceTimer = useRef<ReturnType<typeof setTimeout>>(undefined);  useEffect(() => () => clearTimeout(debounceTimer.current), []);  const onChange = () => {    clearTimeout(debounceTimer.current);    debounceTimer.current = setTimeout(() => setBlocks(editor.document), 600);  };  const onDownloadClick = () => {    if (!pdfUrl) {      return;    }    const link = document.createElement("a");    link.href = pdfUrl;    link.download = "blocknote (pdf-ua).pdf";    document.body.appendChild(link);    link.click();    link.remove();  };  const label =    status === "loading"      ? "Generating…"      : status === "error"        ? "Export failed (see console)"        : "✓ Tagged PDF/UA-1";  return (    <div className="views">      <div className="view-wrapper">        <div className="view-label">Editor Input</div>        <div className="view">          <BlockNoteView editor={editor} slashMenu={false} onChange={onChange}>            <SuggestionMenuController              triggerCharacter={"/"}              getItems={getSlashMenuItems}            />          </BlockNoteView>        </div>      </div>      <div className="view-wrapper">        <div className="view-label">          {label}          <button            type="button"            className="view-label-download"            onClick={onDownloadClick}          >            Download          </button>        </div>        <div className="view">          {pdfUrl ? (            <iframe              title="PDF/UA output"              height="100%"              width="100%"              src={pdfUrl}            />          ) : null}        </div>      </div>    </div>  );}