From 7937669be1ba0bf5b338f829a8efea87aa136f32 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 22 Jun 2026 11:24:05 +0200 Subject: [PATCH 01/18] poc --- .../.bnexample.json | 12 + .../11-converting-blocks-to-pdf-ua/README.md | 13 + .../11-converting-blocks-to-pdf-ua/index.html | 14 + .../11-converting-blocks-to-pdf-ua/main.tsx | 11 + .../package.json | 33 +++ .../src/App.tsx | 268 +++++++++++++++++ .../src/fonts/GeistMono-Regular.ttf | Bin 0 -> 116248 bytes .../src/fonts/Inter_18pt-Bold.ttf | Bin 0 -> 344152 bytes .../src/fonts/Inter_18pt-BoldItalic.ttf | Bin 0 -> 348180 bytes .../src/fonts/Inter_18pt-Italic.ttf | Bin 0 -> 346480 bytes .../src/fonts/Inter_18pt-Regular.ttf | Bin 0 -> 342680 bytes .../src/fonts/NotoEmoji-Regular.ttf | Bin 0 -> 880860 bytes .../src/styles.css | 63 ++++ .../tsconfig.json | 29 ++ .../vite-env.d.ts | 1 + .../vite.config.ts | 31 ++ packages/xl-pdf-renderer-2/README.md | 88 ++++++ packages/xl-pdf-renderer-2/package.json | 67 +++++ packages/xl-pdf-renderer-2/src/index.ts | 42 +++ .../pdfua/__fixtures__/NotoEmoji-Regular.ttf | Bin 0 -> 880860 bytes .../src/pdfua/__fixtures__/tagged.pdf | Bin 0 -> 25729 bytes .../src/pdfua/compileBrowser.ts | 69 +++++ .../src/pdfua/golden.test.ts | 94 ++++++ .../src/pdfua/postProcess.test.ts | 55 ++++ .../src/pdfua/postProcess.ts | 79 +++++ .../src/typst/__snapshots__/testDocument.typ | 108 +++++++ .../src/typst/defaultSchema/blocks.ts | 126 ++++++++ .../src/typst/defaultSchema/index.ts | 15 + .../src/typst/defaultSchema/inlinecontent.ts | 24 ++ .../src/typst/defaultSchema/styles.ts | 31 ++ .../src/typst/typstExporter.test.ts | 40 +++ .../src/typst/typstExporter.ts | 262 +++++++++++++++++ packages/xl-pdf-renderer-2/src/typst/util.ts | 55 ++++ packages/xl-pdf-renderer-2/tsconfig.json | 36 +++ packages/xl-pdf-renderer-2/vite.config.ts | 68 +++++ packages/xl-pdf-renderer-2/vitestSetup.ts | 10 + playground/src/examples.gen.tsx | 25 ++ playground/vite.config.ts | 4 + pnpm-lock.yaml | 277 +++++++++++++++++- 39 files changed, 2042 insertions(+), 8 deletions(-) create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/GeistMono-Regular.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/Inter_18pt-Bold.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/Inter_18pt-BoldItalic.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/Inter_18pt-Italic.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/Inter_18pt-Regular.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/fonts/NotoEmoji-Regular.ttf create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/styles.css create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/tsconfig.json create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/vite-env.d.ts create mode 100644 examples/05-interoperability/11-converting-blocks-to-pdf-ua/vite.config.ts create mode 100644 packages/xl-pdf-renderer-2/README.md create mode 100644 packages/xl-pdf-renderer-2/package.json create mode 100644 packages/xl-pdf-renderer-2/src/index.ts create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/__fixtures__/NotoEmoji-Regular.ttf create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/__fixtures__/tagged.pdf create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/compileBrowser.ts create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/golden.test.ts create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/postProcess.test.ts create mode 100644 packages/xl-pdf-renderer-2/src/pdfua/postProcess.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/__snapshots__/testDocument.typ create mode 100644 packages/xl-pdf-renderer-2/src/typst/defaultSchema/blocks.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/defaultSchema/index.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/defaultSchema/inlinecontent.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/defaultSchema/styles.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/typstExporter.test.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/typstExporter.ts create mode 100644 packages/xl-pdf-renderer-2/src/typst/util.ts create mode 100644 packages/xl-pdf-renderer-2/tsconfig.json create mode 100644 packages/xl-pdf-renderer-2/vite.config.ts create mode 100644 packages/xl-pdf-renderer-2/vitestSetup.ts diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json new file mode 100644 index 0000000000..8be8e3c25d --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Interoperability", "Accessibility"], + "dependencies": { + "@blocknote/xl-pdf-renderer-2": "latest", + "@myriaddreamin/typst.ts": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0" + }, + "pro": true +} diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md new file mode 100644 index 0000000000..0399d93a5d --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/README.md @@ -0,0 +1,13 @@ +# 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-renderer-2`. 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](https://verapdf.org/) (`--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. diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html new file mode 100644 index 0000000000..46d4ed40aa --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/index.html @@ -0,0 +1,14 @@ + + + + + Exporting documents to tagged PDF (PDF/UA) + + + +
+ + + diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json new file mode 100644 index 0000000000..f2dbab6fac --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/package.json @@ -0,0 +1,33 @@ +{ + "name": "@blocknote/example-interoperability-converting-blocks-to-pdf-ua", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "@blocknote/xl-pdf-renderer-2": "latest", + "@myriaddreamin/typst.ts": "^0.7.0", + "@myriaddreamin/typst-ts-web-compiler": "^0.7.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "catalog:" + } +} diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx new file mode 100644 index 0000000000..8815968dc2 --- /dev/null +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-ua/src/App.tsx @@ -0,0 +1,268 @@ +import { + 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 { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + getPageBreakReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { + TypstExporter, + blocksToPdfUA, + typstDefaultSchemaMappings, +} from "@blocknote/xl-pdf-renderer-2"; +// 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) + an emoji font so the export +// matches the editor and works fully offline. +import interRegular from "./fonts/Inter_18pt-Regular.ttf?url"; +import interItalic from "./fonts/Inter_18pt-Italic.ttf?url"; +import interBold from "./fonts/Inter_18pt-Bold.ttf?url"; +import interBoldItalic from "./fonts/Inter_18pt-BoldItalic.ttf?url"; +import geistMono from "./fonts/GeistMono-Regular.ttf?url"; +import notoEmoji from "./fonts/NotoEmoji-Regular.ttf?url"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import "./styles.css"; + +// Fetch the bundled fonts once and reuse them across exports. +const FONT_URLS = [ + interRegular, + interItalic, + interBold, + interBoldItalic, + geistMono, + notoEmoji, +]; +let fontsPromise: Promise | undefined; +function loadFonts() { + if (!fontsPromise) { + fontsPromise = Promise.all( + FONT_URLS.map((url) => + fetch(url) + .then((r) => r.arrayBuffer()) + .then((b) => new Uint8Array(b)), + ), + ); + } + return fontsPromise; +} + +export default function App() { + const [pdfUrl, setPdfUrl] = useState(); + const [status, setStatus] = useState<"loading" | "ready" | "error">( + "loading", + ); + const urlRef = useRef(); + const debounce = useRef>(); + + // Creates a new editor instance with support for page breaks. + const editor = useCreateBlockNote({ + schema: withPageBreak(BlockNoteSchema.create()), + dictionary: locales.en, + tables: { + splitCells: true, + cellBackgroundColor: true, + cellTextColor: true, + headers: true, + }, + initialContent: [ + { + type: "heading", + props: { level: 1 }, + content: "Accessible PDF export", + }, + { + type: "paragraph", + content: [ + { type: "text", text: "This document exports to a ", styles: {} }, + { type: "text", text: "tagged PDF/UA-1", styles: { bold: true } }, + { + type: "text", + text: " file that screen readers can navigate. ", + styles: {}, + }, + { + type: "link", + content: "Learn more", + href: "https://www.blocknotejs.org", + }, + ], + }, + { type: "heading", props: { level: 2 }, content: "Highlights" }, + { + type: "bulletListItem", + content: "Headings, paragraphs and lists are exposed semantically", + children: [ + { + type: "bulletListItem", + content: "Nested lists become real L › LI structures", + }, + { type: "numberedListItem", content: "So do numbered lists" }, + ], + }, + { type: "checkListItem", props: { checked: true }, content: "Links" }, + { type: "checkListItem", content: "Tables with header rows" }, + { + type: "paragraph", + props: { backgroundColor: "blue", textAlignment: "center" }, + content: [ + { + type: "text", + text: "Colors and alignment are preserved too", + styles: { italic: true }, + }, + ], + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { cells: ["Quarter", "Revenue"] }, + { cells: ["Q1", "$1.2M"] }, + { cells: ["Q2", "$1.4M"] }, + ], + }, + }, + { + type: "codeBlock", + props: { language: "typescript" }, + content: `const pdf = await blocksToPdfUA(exporter, editor.document, {});`, + }, + { + type: "quote", + content: "Accessibility is a baseline, not a feature.", + }, + { type: "pageBreak" }, + { + type: "image", + props: { + url: "https://placehold.co/600x300.png", + caption: "Images export as tagged figures with alt text", + }, + }, + ], + }); + + // Additional Slash Menu items for page breaks. + const getSlashMenuItems = useMemo( + () => async (query: string) => + filterSuggestionItems( + combineByGroup( + getDefaultReactSlashMenuItems(editor), + getPageBreakReactSlashMenuItems(editor), + ), + query, + ), + [editor], + ); + + // Exports the editor document to a PDF/UA-1 file (Typst wasm + post-process). + const exportPdf = useCallback(async () => { + setStatus("loading"); + try { + const exporter = new TypstExporter( + editor.schema, + typstDefaultSchemaMappings, + { title: "BlockNote document", lang: "en" }, + ); + const bytes = await blocksToPdfUA(exporter, editor.document, { + getModule: () => compilerWasmUrl, + fonts: await loadFonts(), + preloadDefaultFonts: false, + }); + const url = URL.createObjectURL( + new Blob([bytes], { type: "application/pdf" }), + ); + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + } + urlRef.current = url; + setPdfUrl(url); + setStatus("ready"); + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + setStatus("error"); + } + }, [editor]); + + // Export on mount, and clean up the object URL on unmount. + useEffect(() => { + void exportPdf(); + return () => { + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Re-export (debounced) whenever the document changes. + const onChange = () => { + clearTimeout(debounce.current); + debounce.current = setTimeout(() => void exportPdf(), 600); + }; + + const onDownloadClick = () => { + if (!urlRef.current) { + return; + } + const link = document.createElement("a"); + link.href = urlRef.current; + 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 ( +
+
+
Editor Input
+
+ + + +
+
+
+
+ {label} + + Download + +
+
+ {pdfUrl ? ( +