diff --git a/docs/.gitignore b/docs/.gitignore index c12953bff8..b5f41ae2c3 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -30,3 +30,7 @@ next-env.d.ts /content/examples/*/* /components/example/generated/ sqlite.db + +# Auto-generated by Next.js postinstall (agent rules); not repo content. +/AGENTS.md +/CLAUDE.md diff --git a/docs/content/docs/features/blocks/math.mdx b/docs/content/docs/features/blocks/math.mdx index 83378c6aa6..d8f2a7ae60 100644 --- a/docs/content/docs/features/blocks/math.mdx +++ b/docs/content/docs/features/blocks/math.mdx @@ -142,10 +142,6 @@ covered; a valid formula using an uncovered command fails the export loudly (rather than silently exporting something else), while invalid LaTeX renders the error placeholder as in other formats. -The deprecated [react-pdf exporter](/docs/features/export/pdf#deprecated-the-react-pdf-exporter)'s -mappings remain available from `@blocknote/math-block/pdf-exporter` during its -deprecation window. - ### Email With the [email exporter](/docs/features/export/email), math exports as images with the LaTeX source as the alt text: math blocks are rasterized to PNG in the browser (and embedded as SVG elsewhere), inline math is always embedded as SVG: diff --git a/docs/content/docs/features/export/pdf.mdx b/docs/content/docs/features/export/pdf.mdx index 1d096961da..fc577af1ca 100644 --- a/docs/content/docs/features/export/pdf.mdx +++ b/docs/content/docs/features/export/pdf.mdx @@ -51,10 +51,10 @@ This works out of the box, fully offline: exports match the editor's look, and everything needed (the default fonts and the compiler itself) ships inside the package. Nothing is fetched from a CDN. -See the [full example](/examples/interoperability/converting-blocks-to-pdf-ua) +See the [full example](/examples/interoperability/converting-blocks-to-pdf) with a live PDF preview below: - + ### Customizing the PDF @@ -253,4 +253,5 @@ import { Note that its mappings are react-pdf mappings; when migrating to the new exporter, custom blocks need a [Typst mapping](#custom-mappings--custom-schemas) -instead. +instead. The old exporter's example lives on at +[converting-blocks-to-pdf-react-pdf-deprecated](/examples/interoperability/converting-blocks-to-pdf-react-pdf-deprecated). diff --git a/docs/package.json b/docs/package.json index 9e4e282da9..76294747a1 100644 --- a/docs/package.json +++ b/docs/package.json @@ -52,7 +52,6 @@ "@polar-sh/sdk": "^0.42.2", "@react-email/components": "^1.0.4", "@react-email/render": "^2.0.4", - "@react-pdf/math": "^2.0.1", "@react-pdf/renderer": "^4.5.1", "@sentry/nextjs": "^10.34.0", "@shikijs/core": "^4.4.3", diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json index abf12f3d8e..0a39b03402 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/.bnexample.json @@ -2,16 +2,14 @@ "playground": true, "docs": true, "author": "yousefed", - "tags": ["Interoperability"], + "tags": ["Interoperability", "Accessibility"], "sharedTestDocument": true, "dependencies": { - "@blocknote/diagram-block": "latest", - "@blocknote/math-block": "latest", - "@blocknote/xl-multi-column": "latest", "@blocknote/xl-pdf-exporter": "latest", - "@react-pdf/math": "^2.0.1", - "@react-pdf/renderer": "^4.5.1", - "mathjax-full": "^3.2.2" + "@blocknote/xl-multi-column": "latest", + "@blocknote/math-block": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/xl-typst-compiler": "latest" }, "pro": true } diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/README.md b/examples/05-interoperability/05-converting-blocks-to-pdf/README.md index c147d90bb4..44c5f3cf2f 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/README.md +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/README.md @@ -1,11 +1,13 @@ -# Exporting documents to PDF (react-pdf, deprecated) +# Exporting documents to PDF (PDF/UA) -> **Deprecated:** this example uses the react-pdf based exporter -> (`@blocknote/xl-pdf-exporter/react-pdf`), which is deprecated and will be -> removed after a few releases. Use the Typst-based `PDFExporter` instead - -> see the "Exporting documents to tagged PDF (PDF/UA)" example - which -> produces accessible, tagged PDF/UA-1 output. +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. -This example exports the current document (all blocks) as a PDF file and downloads it to your computer. +**Try it out:** Edit the document — the PDF preview updates live. Click +"Download" to save it, then verify it with a tool like +[veraPDF](https://verapdf.org/) (`--flavour ua1`) or the Acrobat Tags panel. -**Try it out:** Edit the document and click "Download .pdf" at the top to download the PDF file. +> The first export downloads the Typst compiler (wasm) and fonts, so it may take +> a moment. Images render as tagged placeholder figures for now. diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/index.html b/examples/05-interoperability/05-converting-blocks-to-pdf/index.html index 4d59fc32bf..3aa02420e0 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/index.html +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/index.html @@ -2,7 +2,7 @@ - Exporting documents to PDF (react-pdf, deprecated) + Exporting documents to PDF (PDF/UA) diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/package.json b/examples/05-interoperability/05-converting-blocks-to-pdf/package.json index 30c28d0430..dc44362e3f 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/package.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/package.json @@ -20,13 +20,11 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", - "@blocknote/diagram-block": "latest", - "@blocknote/math-block": "latest", - "@blocknote/xl-multi-column": "latest", "@blocknote/xl-pdf-exporter": "latest", - "@react-pdf/math": "^2.0.1", - "@react-pdf/renderer": "^4.5.1", - "mathjax-full": "^3.2.2" + "@blocknote/xl-multi-column": "latest", + "@blocknote/math-block": "latest", + "@blocknote/diagram-block": "latest", + "@blocknote/xl-typst-compiler": "latest" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx index c60aa61749..bfce34f37a 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/src/App.tsx @@ -1,5 +1,6 @@ import { testDocumentBlocks } from "./testDocumentBlocks"; import { + Block, BlockNoteSchema, combineByGroup, withPageBreak, @@ -10,45 +11,126 @@ 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, - multiColumnDropCursor, locales as multiColumnLocales, + multiColumnDropCursor, withMultiColumn, } from "@blocknote/xl-multi-column"; -import { - PDFExporter, - pdfDefaultSchemaMappings, -} from "@blocknote/xl-pdf-exporter/react-pdf"; -import { diagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; -import { - inlineMathMapping, - mathBlockMapping, -} from "@blocknote/math-block/pdf-exporter"; -import { pdf, PDFViewer } from "@react-pdf/renderer"; -import { JSX, useEffect, useMemo, useReducer, useState } from "react"; +// Bundle the Typst compiler wasm explicitly (it would otherwise load from +// the package's own files - also CDN-free - but an explicit URL keeps the +// bundling visible in this example). +// 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 "@blocknote/xl-typst-compiler/wasm?url"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "./styles.css"; -export default function App() { - // Stores the editor's contents as JSX for download and displaying the PDF - // using ReactPDF's `PDFViewer` component. - const [pdfDocument, setPDFDocument] = useState(); - const [renders, forceRerender] = useReducer((s) => s + 1, 0); +/** + * 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, + blocks: Block[], +) { + const [pdfUrl, setPdfUrl] = useState(); + const [status, setStatus] = useState< + "loading" | "ready" | "unclaimed" | "error" + >("loading"); + + useEffect(() => { + let stale = false; + setStatus("loading"); + void (async () => { + try { + const result = await makeExporter().toPDF(blocks, { + title: "BlockNote document", + lang: "en", + }); + if (stale) { + return; + } + // A document that fails to compile (e.g. text no supplied font + // covers) is an expected outcome, reported in the result. + if (result.error) { + // eslint-disable-next-line no-console + console.error( + "PDF export failed:", + result.compileErrors.map((d) => d.message).join("; "), + ); + setStatus("error"); + return; + } + // A nonconforming document (e.g. one not starting with an H1) still + // exports - tagged but without the PDF/UA-1 claim; surface why, and + // show a distinct status instead of claiming conformance. + if (!result.pdfUA.declared && result.pdfUA.reason === "nonconforming") { + // eslint-disable-next-line no-console + console.info( + "Exported without PDF/UA-1 declaration:", + result.pdfUA.violations.map((v) => v.message).join("; "), + ); + } + setPdfUrl(URL.createObjectURL(result.blob)); + setStatus(result.pdfUA.declared ? "ready" : "unclaimed"); + } 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 }; +} - // Creates a new editor instance. +export default function App() { + // Creates a new editor instance with support for page breaks. const editor = useCreateBlockNote({ - // Adds support for page breaks & multi-column blocks. // Adds support for math & diagram blocks. schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ blockSpecs: { @@ -64,14 +146,12 @@ export default function App() { ...locales.en, multi_column: multiColumnLocales.en, }, - // Adds support for advanced table features. tables: { splitCells: true, cellBackgroundColor: true, cellTextColor: true, headers: true, }, - // Sets initial editor content. initialContent: [ ...testDocumentBlocks, // The math & diagram blocks aren't part of the shared test document, @@ -105,7 +185,7 @@ export default function App() { ], }); - // Additional Slash Menu items for page breaks and multi-column blocks. + // Additional Slash Menu items for page breaks. const getSlashMenuItems = useMemo( () => async (query: string) => filterSuggestionItems( @@ -119,54 +199,71 @@ export default function App() { [editor], ); - // Exports the editor document to PDF whenever it changes. - const onChange = async () => { - const exporter = new PDFExporter(editor.schema, { - ...pdfDefaultSchemaMappings, - blockMapping: { - ...pdfDefaultSchemaMappings.blockMapping, - // Embeds diagrams as images instead of their Mermaid source. - diagram: diagramBlockMapping, - // Renders math blocks as formulas instead of their LaTeX source. - mathBlock: mathBlockMapping, - }, - inlineContentMapping: { - ...pdfDefaultSchemaMappings.inlineContentMapping, - // Renders inline math as formula images instead of its LaTeX source. - math: inlineMathMapping, - }, - }); - const pdfDocument = await exporter.toReactPDFDocument(editor.document); - setPDFDocument(pdfDocument); - forceRerender(); - }; + // 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, + }, + }, + { + // The bundled compiler wasm (see the import above) - engine setup + // belongs to the exporter, per-document facts go to toPDF. + wasm: compilerWasmUrl, + }, + ), + [editor], + ); - // Exports the inital editor document to PDF. - useEffect(() => { - void onChange(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // 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); - // Downloads the PDF. - const onDownloadClick = async () => { - const blob = await pdf(pdfDocument).toBlob(); + const debounceTimer = useRef>(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 = window.URL.createObjectURL(blob); - link.download = "My Document (blocknote export).pdf"; + link.href = pdfUrl; + link.download = "blocknote (pdf-ua).pdf"; document.body.appendChild(link); - link.dispatchEvent( - new MouseEvent("click", { - bubbles: true, - cancelable: true, - view: window, - }), - ); + link.click(); link.remove(); - window.URL.revokeObjectURL(link.href); }; - // Renders the editor instance and PDF view. + const label = + status === "loading" + ? "Generating…" + : status === "error" + ? "Export failed (see console)" + : status === "unclaimed" + ? "Tagged PDF, no UA-1 claim (see console)" + : "✓ Tagged PDF/UA-1"; + return (
@@ -182,15 +279,24 @@ export default function App() {
- PDF Output - + {label} +
- - {pdfDocument} - + {pdfUrl ? ( +