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).import compilerWasmUrl from "@myriaddreamin/typst-ts-web-compiler/wasm?url";// Bundle BlockNote's fonts (Inter + Geist Mono) + a color emoji font so the// export matches the editor and works fully offline, plus a math font (New// Computer Modern Math, Typst's default) for the math blocks - with// `preloadDefaultFonts: false` no fonts come from a CDN, so every needed// font must be bundled. Noto Color Emoji is the pure-COLRv1 build (~5MB),// which Typst renders in color. `new URL(<literal>, import.meta.url)` is// the bundler-portable asset reference (Vite and the docs site's Turbopack// both emit the file and return its URL) - but only with a full string// literal per file: a shared helper with a template path breaks Turbopack's// static analysis (every URL silently resolves to one file).const interRegular = new URL("./fonts/Inter_18pt-Regular.ttf", import.meta.url) .href;const interItalic = new URL("./fonts/Inter_18pt-Italic.ttf", import.meta.url) .href;const interBold = new URL("./fonts/Inter_18pt-Bold.ttf", import.meta.url).href;const interBoldItalic = new URL( "./fonts/Inter_18pt-BoldItalic.ttf", import.meta.url,).href;const geistMono = new URL("./fonts/GeistMono-Regular.ttf", import.meta.url) .href;const notoColorEmoji = new URL("./fonts/Noto-COLRv1.ttf", import.meta.url).href;const newCMMathRegular = new URL( "./fonts/NewCMMath-Regular.otf", import.meta.url,).href;const newCMMathBook = new URL("./fonts/NewCMMath-Book.otf", import.meta.url) .href;import { useCallback, useEffect, useMemo, useRef, useState } from "react";import "./styles.css";// Fetch the bundled fonts once and reuse them across exports. The emoji font is// kept separate so it can be passed via the dedicated `emojiFont` option.const BODY_FONT_URLS = [ interRegular, interItalic, interBold, interBoldItalic, geistMono, newCMMathRegular, newCMMathBook,];async function fetchFont(url: string) { const res = await fetch(url); if (!res.ok) { // A dev server's HTML fallback page must not be loaded as font bytes. throw new Error(`Failed to fetch font ${url}: ${res.status}`); } return new Uint8Array(await res.arrayBuffer());}let fontsPromise: | Promise<{ fonts: Uint8Array[]; emojiFont: Uint8Array }> | undefined;function loadFonts() { if (!fontsPromise) { fontsPromise = Promise.all([ Promise.all(BODY_FONT_URLS.map(fetchFont)), fetchFont(notoColorEmoji), ]).then(([fonts, emojiFont]) => ({ fonts, emojiFont })); // A transient fetch failure must not poison every later export - clear // the cache so the next export retries. fontsPromise.catch(() => { fontsPromise = undefined; }); } return fontsPromise;}/** * 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 { fonts, emojiFont } = await loadFonts(); const blob = await makeExporter().toBlob( blocks, { getModule: () => compilerWasmUrl, fonts, emojiFont, preloadDefaultFonts: false, }, { 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, }, }, // Noto Color Emoji is the internal family name of the bundled emoji // font; listing it lets ZWJ emoji (e.g. πΆββοΈ) shape correctly. { emojiFontFamily: "Noto Color Emoji" }, ), [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> );}.views { container-name: views; container-type: inline-size; display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; height: 100%; padding: 8px;}.view-wrapper { display: flex; flex-direction: column; height: calc(50% - 4px); width: 100%;}@container views (width > 1024px) { .view-wrapper { height: 100%; width: calc(50% - 4px); }}.view-label { color: #0090ff; display: flex; font-size: 12px; font-weight: bold; justify-content: space-between; margin-inline: 16px;}/* A real <button> (keyboard-focusable), restyled to match the label text. */.view-label-download { background: none; border: none; color: inherit; font: inherit; padding: 0; cursor: pointer; text-decoration: underline;}.view { border: solid #0090ff 1px; border-radius: 16px; flex: 1; height: 0; padding: 8px;}.view .bn-container { height: 100%; margin: 0; max-width: none; padding: 0;}.view .bn-editor { height: 100%; overflow: auto;}.view iframe { border: none; border-radius: 8px;}// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY.// Generated from shared/testDocumentBlocks.ts β run `pnpm run gen` to update.import type { PartialBlock } from "@blocknote/core";/** * The shared example/test document, as partial blocks. * * This file is intentionally self-contained β it has only a type-only import * from `@blocknote/core` (no runtime imports) β so the example generator can * copy it verbatim into each exporter playground example and the examples stay * runnable on their own (e.g. opened directly in StackBlitz). * * It is the single source of truth for both the playground examples' editor * `initialContent` and the exporters' `testDocument` unit-test fixture. */export const testDocumentBlocks: PartialBlock<any, any, any>[] = [ { type: "paragraph", content: [ { type: "text", text: "Welcome to this ", styles: { italic: true, }, }, { type: "text", text: "demo π!", styles: { italic: true, bold: true, }, }, ], children: [ { type: "paragraph", content: "Hello World nested", children: [ { type: "paragraph", content: "Hello World double nested", }, ], }, ], }, { type: "paragraph", content: [ { type: "text", text: "This paragraph has a background color", styles: { bold: true }, }, ], props: { backgroundColor: "red", }, }, { type: "paragraph", content: "Paragraph", }, // An empty paragraph: a blank line in the editor, which exporters must // preserve as vertical space rather than dropping the block. { type: "paragraph", }, { type: "heading", content: "Heading", }, { type: "heading", content: "Heading right", props: { textAlignment: "right", }, }, { type: "heading", content: "Heading 2", props: { level: 2 }, }, { type: "heading", content: "Heading 3", props: { level: 3 }, }, { type: "heading", content: "Heading 4", props: { level: 4 }, }, { type: "heading", content: "Heading 5", props: { level: 5 }, }, { type: "heading", content: "Heading 6", props: { level: 6 }, }, { type: "paragraph", content: "Emojis: π π π π ππ½ π πΆββοΈ", }, { type: "paragraph", content: "Centered paragraph", props: { textAlignment: "center", }, }, { type: "paragraph", content: "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", props: { textAlignment: "justify", }, }, { type: "pageBreak" }, { type: "bulletListItem", content: "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", children: [ { type: "bulletListItem", content: "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", }, { type: "bulletListItem", content: "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", props: { textAlignment: "right", }, }, { type: "numberedListItem", content: "Numbered List Item 1", }, { type: "numberedListItem", content: "Numbered List Item 2", children: [ { type: "numberedListItem", content: "Numbered List Item Nested 1", }, { type: "numberedListItem", content: "Numbered List Item Nested 2", }, { type: "numberedListItem", content: "Numbered List Item Nested funky right", props: { textAlignment: "right", backgroundColor: "red", textColor: "blue", }, }, { type: "numberedListItem", content: "Numbered List Item Nested funky center", props: { textAlignment: "center", backgroundColor: "red", textColor: "blue", }, }, ], }, ], }, { type: "numberedListItem", content: "Numbered List Item", }, { type: "checkListItem", content: "Check List Item", }, { type: "checkListItem", content: "Checked List Item", props: { checked: true, }, }, { type: "numberedListItem", content: "Numbered List Item starting at 5", props: { start: 5, }, }, { type: "numberedListItem", content: "Numbered List Item 6", }, { type: "toggleListItem", content: "Toggle List Item", children: [ { type: "paragraph", content: "Content nested inside the toggle list item.", }, { type: "bulletListItem", content: "A nested bullet inside the toggle", }, ], }, { type: "heading", content: "Toggle Heading", props: { level: 2, isToggleable: true, }, children: [ { type: "paragraph", content: "Content nested inside the toggle heading.", }, ], }, { type: "table", content: { type: "tableContent", columnWidths: [200, undefined, undefined], rows: [ { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, ], }, }, { type: "file", }, { type: "image", props: { url: "https://placehold.co/332x322.jpg", caption: "From https://placehold.co/332x322.jpg", }, }, { type: "image", props: { previewWidth: 200, url: "https://placehold.co/332x322.jpg", textAlignment: "right", }, }, { type: "video", props: { url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", caption: "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", }, }, { type: "audio", props: { url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", caption: "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", }, }, { type: "paragraph", }, { type: "audio", props: { caption: "Audio file caption", name: "audio.mp3", }, }, { type: "paragraph", content: [ { type: "text", text: "Inline Content:", styles: { bold: true }, }, ], }, { type: "paragraph", content: [ { type: "text", text: "Styled Text", styles: { bold: true, italic: true, textColor: "red", backgroundColor: "blue", }, }, { type: "text", text: " ", styles: {}, }, { type: "text", text: "underlined", styles: { underline: true }, }, { type: "text", text: " ", styles: {}, }, { type: "text", text: "strikethrough", styles: { strike: true }, }, { type: "text", text: " ", styles: {}, }, { type: "link", content: "Link", href: "https://www.blocknotejs.org", }, ], }, { type: "table", content: { type: "tableContent", headerRows: 1, rows: [ { cells: ["Table Header 1", "Table Header 2", "Table Header 3"], }, { cells: [ "Table Cell 4", [ { type: "text", text: "Table Cell Bold Colored 5", styles: { bold: true, textColor: "red", backgroundColor: "blue", }, }, ], "Table Cell 6", ], }, { cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], }, ], }, }, // An "advanced" table: two header rows and merged cells (colspan / // rowspan) - the features behind the editor's `splitCells` / // `headers` table options, which exporters must place correctly. { type: "table", content: { type: "tableContent", // Explicit widths for all three tracks: the merged first-row cell // means the row alone doesn't reveal the column count. columnWidths: [undefined, undefined, undefined], headerRows: 2, rows: [ { cells: [ { type: "tableCell", content: "Merged Header", props: { colspan: 2 }, }, { type: "tableCell", content: "Header C" }, ], }, { cells: ["Header A", "Header B", "Header C2"], }, { cells: [ { type: "tableCell", content: "Merged Rows", props: { rowspan: 2 }, }, { type: "tableCell", content: "Cell B1" }, { type: "tableCell", content: "Cell C1" }, ], }, { cells: ["Cell B2", "Cell C2"], }, ], }, }, // A hard line break (shift+enter) inside one paragraph - a single block // whose text spans two lines. { type: "paragraph", content: "A hard line break\nwithin a single paragraph", }, { type: "codeBlock", props: { language: "javascript", }, content: `const helloWorld = (message) => { console.log("Hello World", message);};`, }, { type: "paragraph", content: [ { type: "text", text: "Some inline code: ", styles: { bold: true }, }, { type: "text", text: "var foo = 'bar';", styles: { code: true }, }, ], }, { type: "columnList", children: [ { type: "column", props: { width: 0.8 }, children: [ { type: "paragraph", content: "This paragraph is in a column!" }, ], }, { type: "column", props: { width: 1.4 }, children: [{ type: "heading", content: "So is this heading!" }], }, { type: "column", props: { width: 0.8 }, children: [ { type: "paragraph", content: "You can have multiple blocks in a column too", }, { type: "bulletListItem", content: "Block 1" }, { type: "bulletListItem", content: "Block 2" }, { type: "bulletListItem", content: "Block 3" }, ], }, ], }, { type: "divider" }, { type: "quote", content: "All those moments will be lost in time, like tears in rain.", },];