diff --git a/.claude/skills/docs-skill/SKILL.md b/.claude/skills/docs-skill/SKILL.md index ad44bea9c5..e0791f408f 100644 --- a/.claude/skills/docs-skill/SKILL.md +++ b/.claude/skills/docs-skill/SKILL.md @@ -18,8 +18,16 @@ The documentation site lives in `docs/content/docs` (fumadocs + Next.js). These - **Live examples cap the setup they demonstrate.** Place an `` embed after the prose has introduced everything the example's code uses — an embed whose `App.tsx` shows APIs the page never mentions teaches by confusion. Introduced means named, given a purpose, and linked — the example itself (and the linked component pages) can carry the full wiring; expanding every integration inline pushes the example too far down the page. Not at the top (a demo without context motivates but doesn't teach) and not at the bottom (readers rarely reach it); at the end of the "getting it working" narrative, before advanced/optional topics. If an example uses more API than the page should cover, simplify the example rather than the rule. - **Signature snippets tell the truth for one API.** A snippet formatted as a type signature documents exactly that export — never fold variant differences ("only for the X subpath…") into a doc-comment inside it. Show the signature that is true everywhere, and describe variant-specific options as prose in the variant's own section. +- **Docs follow the dependency direction.** When a package builds on another, the base layer's page owns the shared mechanisms (mappings, formats, behaviors) and never points "up" to a consumer page for its own concepts; consumer pages link down. Consumer pages still document their own API surfaces (option lists, signatures) explicitly rather than delegating them, per the signature rule below. Name sections for every layer they serve ("Typst / PDF", not just "PDF") so the base layer isn't erased. +- **Confine a cross-cutting concept to one designated section per page.** A page's headline concept (a conformance standard, an offline guarantee) gets one owning section; the intro may link to it once, and other sections mention it only where the reader must act on it there. Sprinkled re-mentions read as emphasis when written but age as duplication. +- **The hero snippet is the happy path only.** The first usage snippet shows the shortest end-to-end flow and nothing else; auxiliary mechanisms (asset maps, secondary outputs, tuning knobs) move to their own short sections even when genuinely relevant. Same for prose caveats aimed at a niche audience (a live-preview memory note): put them where that audience looks, not in the getting-started flow. - **Caveats must be actionable.** Only note a limitation if the reader can do something with it (install a package, avoid a pattern, pass an option). Speculative hedges ("rare X may behave differently") and defensive implementation details (what a function guards against internally) erode trust without helping anyone act — cut them. +## Prose style + +- Avoid em-dash-heavy prose; prefer commas, colons, semicolons, periods, or parentheses, choosing per sentence rather than substituting mechanically. An em-dash is fine occasionally; several per section reads as filler. +- Before listing something as a _requirement_, verify the reader can actually fail it. A "requirement" the implementation always satisfies automatically (e.g. auto-derived alt text) is at most a quality tip, phrased as one. + ## Verifying content - **Verify snippets against the actual package exports, not memory or existing docs.** APIs drift; grep the package source for every symbol a snippet imports (`export function X` / `export const X`) and check option names and shapes. Content copied forward without this check stays wrong after refactors. diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index 982d9ea03b..ccb9220c55 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -51,11 +51,11 @@ End-to-end tests run in vitest browser mode (chromium, firefox and webkit) insid bash tests/docker-run.sh -e CI=1 -- --run [filters] ``` -A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). +**Never run the browser suite natively** (`vp test -c vite.config.browser.ts` outside the container) — not even "just one file". Two failure modes, both silent: the screenshot matcher _seeds_ a new `-darwin`/`-win32` baseline for every screenshot test that has none (passing without comparing anything, and littering the tree with hundreds of unvetted PNGs — these are gitignored as a backstop, and only `-linux` baselines are tracked), and several suites genuinely behave differently outside Linux (e.g. caret placement is font-metric-dependent, so platform-shared JSON snapshots mismatch). If Docker isn't running, notify the user to launch it instead of falling back to a native run. -Screenshot baselines can be regenerated with the `-u` argument, which must come **after** the filters (`--run -u`): written as `--run -u `, the filter is parsed as the flag's value and the **whole** suite runs in update mode, silently rewriting unrelated baselines. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`--linux.png`); after regenerating, always inspect the images before committing them. +A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). -If Docker isn't running, notify the user to launch it. +Screenshot baselines can be regenerated with `--update=true` (as `e2e:updateSnaps` does). Always attach the value: vitest declares the flag as `-u, --update [type]` (it also accepts `new`/`all`/`none`), so a bare `-u` before a filter swallows the filter as its value and silently runs the **whole** suite in update mode. `--update=true` cannot, so filters stay filters in any position. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`--linux.png`); after regenerating, always inspect the images before committing them. When testing a visual change, prefer writing screenshots to verify that the change is working as expected. diff --git a/.dockerignore b/.dockerignore index 87697e5b29..87239fb0a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,5 +13,9 @@ .git **/test-results **/blob-report +# The Typst compiler's Rust build tree (GBs) and wasm output: never image +# content - docker-run.sh bind-mounts pkg/ at run time. +**/rust/target +packages/xl-typst-compiler/pkg **/playwright-report tests/.vitest-attachments diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 00aead0595..cc5b7941bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,10 @@ jobs: - name: Install Dependencies run: vp install + # `vp run -r build` includes @blocknote/xl-typst-compiler, whose build + # task compiles its Rust wasm when missing or stale (rustup is + # preinstalled on the runners; the pinned toolchain + wasm32 target + # auto-provision from rust/rust-toolchain.toml). - name: Build packages run: vp run -r build @@ -52,7 +56,58 @@ jobs: - name: Soft release id: soft-release - run: vp dlx pkg-pr-new publish './packages/*' # TODO disabled only for AI branch--compact + # xl-typst-compiler is excluded: its wasm makes the package ~25MB, + # which pkg.pr.new rejects (413; multipart uploads are whitelisted - + # https://github.com/stackblitz-labs/pkg.pr.new/blob/main/.whitelist). + # Preview installs of the PDF exporter therefore lack the compiler + # until the repo is whitelisted or the wasm ships separately. + # TODO disabled only for AI branch--compact + run: | + packages=() + for dir in ./packages/*/; do + if [[ "$dir" != *"/xl-typst-compiler/"* ]]; then + packages+=("${dir%/}") + fi + done + vp dlx pkg-pr-new publish "${packages[@]}" + + build-typst-compiler: + # The one package the e2e suite consumes through build outputs instead of + # src/ aliases: @blocknote/xl-typst-compiler (its /pkg and /wasm subpaths + # are wasm build artifacts - see tests/vite.config.browser.ts). Built once + # on a bare runner - the Playwright container has no C toolchain for the + # Rust build - and shared with the shards as an artifact. + name: Build Typst compiler + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 100 + persist-credentials: false + + - uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 + with: + node-version-file: ".node-version" + cache: true + + - name: Install dependencies + run: vp install + + # Compiles the Rust wasm when missing/stale (rustup is bootstrapped by + # the build task itself) plus the TS wrapper's dist/types. + - name: Build Typst compiler package + run: vp run --filter @blocknote/xl-typst-compiler build + + - name: Upload compiler build outputs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: typst-compiler-build + path: | + packages/xl-typst-compiler/pkg + packages/xl-typst-compiler/dist + packages/xl-typst-compiler/types + include-hidden-files: true e2e: # Vitest Browser Mode runs in the Playwright Linux container — the same @@ -60,10 +115,13 @@ jobs: # The suite resolves every `@blocknote/*` import to its `src/` via the # aliases in `tests/vite.config.browser.ts` (vite transpiles the package # sources on the fly), so the packages do NOT need to be built to `dist` - # first — `vp install` + the checked-out sources are enough. + # first — `vp install` + the checked-out sources are enough (the one + # exception, xl-typst-compiler, arrives prebuilt from the + # build-typst-compiler job). name: "E2E - ${{ matrix.browser }} (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})" runs-on: ubuntu-latest timeout-minutes: 30 + needs: build-typst-compiler container: image: mcr.microsoft.com/playwright:v1.60.0-noble strategy: @@ -86,6 +144,12 @@ jobs: - name: Install dependencies run: vp install + - name: Download Typst compiler build outputs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: typst-compiler-build + path: packages/xl-typst-compiler + # No preview server: Vitest Browser Mode serves the tests + mounted example # apps itself. `--browser` selects this matrix job's browser and # `--shard=/` splits that browser's test files across two @@ -110,6 +174,9 @@ jobs: with: name: e2e-attachments-${{ matrix.browser }}-${{ matrix.shardIndex }} path: tests/.vitest-attachments/ + # The attachments dir is a dot-directory; without this the action + # silently uploads nothing ("No files were found"). + include-hidden-files: true retention-days: 7 merge-reports: diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 01bb86785f..59e58c459d 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -42,6 +42,10 @@ jobs: - name: Prebuild run: cp README.md packages/core/README.md && cp README.md packages/react/README.md + # `vp run -r build` includes @blocknote/xl-typst-compiler, whose build + # task compiles its Rust wasm (published inside the package as pkg/); + # rustup auto-provisions the pinned toolchain from + # rust/rust-toolchain.toml. - name: Build packages run: vp run -r build diff --git a/AGENTS.md b/AGENTS.md index 22b10b6527..d55aadd9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ All commands below are listed under `package.json` in the project root. See `vit - `vp run preview`: Previews the build on port 3000. - `vp run test`: Runs unit tests. Append with `-u` to update snapshots. Append with a file name to target only that file. - To run individual unit tests, use `vp run test `. For example, `vp run test packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts`. -- `vp run e2e`: Runs end-to-end tests. Append with a file name to target only that file. +- `vp run e2e`: Runs end-to-end tests (always in Docker - NEVER run the browser suite natively; it seeds bogus per-platform snapshots). Append with a file name to target only that file. - `vp run e2e:updateSnaps`: Runs end-to-end tests & updates snapshots. Append with a file name to target only that file. - `vp help`: Prints a list of all available commands. @@ -40,3 +40,4 @@ When writing a new feature, bug fix, or other modification, it may not be immedi # Additional Notes - Do not create git commits, unless asked for directly, and do not add Co-Authored-By lines to commits. +- **The exporters mirror the editor's look, and that parity is guarded by review, not types.** The exporter packages (`xl-typst-exporter`/`xl-pdf-exporter`, `xl-docx-exporter`, `xl-odt-exporter`, `xl-email-exporter`) hardcode editor-derived styling constants (heading scale, spacing, list markers, code-block chrome, ...), each annotated with the `packages/core/src/editor/Block.css` rule it mirrors — keep those comments when touching either side. When changing visual rules in `Block.css` (or adding a block type), regenerate the exporter visual baselines and review them against the editor ground truth: the static-equality baseline (`tests/src/end-to-end/static`) renders the _same shared test document_ as the typst PDF baselines (`tests/src/end-to-end/exporters`), so fidelity drift shows up as a side-by-side diff in the same PR. 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/app/demo/_components/DemoEditor.tsx b/docs/app/demo/_components/DemoEditor.tsx index 6db43ca411..c4e690582c 100644 --- a/docs/app/demo/_components/DemoEditor.tsx +++ b/docs/app/demo/_components/DemoEditor.tsx @@ -46,9 +46,11 @@ import { } from "@blocknote/xl-odt-exporter"; import { PDFExporter, - pdfDefaultSchemaMappings, + typstDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; -import { pdf } from "@react-pdf/renderer"; +// Bundle the Typst compiler wasm (resolved to a local asset in +// next.config.ts) instead of loading it from a CDN. +import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url"; import { DefaultChatTransport } from "ai"; import { useTheme } from "next-themes"; import { useEffect, useMemo, useState } from "react"; @@ -345,9 +347,25 @@ function DemoEditorInner({ let filename = `blocknote-export.${format}`; if (format === "pdf") { - const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); - const pdfDocs = await exporter.toReactPDFDocument(editor.document); - blob = await pdf(pdfDocs).toBlob(); + // Tagged PDF/UA-1 via the Typst-based exporter; the default fonts + // (matching the editor) load lazily from the package. + const exporter = new PDFExporter( + editor.schema, + typstDefaultSchemaMappings, + { wasm: compilerWasmUrl }, + ); + const result = await exporter.toPDF(editor.document, { + title: "BlockNote demo document", + lang: "en", + }); + if (result.error) { + throw new Error( + `PDF export failed: ${result.compileErrors + .map((d) => d.message) + .join("; ")}`, + ); + } + blob = result.blob; } else if (format === "docx") { const exporter = new DOCXExporter( editor.schema, diff --git a/docs/components/typstCompilerWasmUrl.ts b/docs/components/typstCompilerWasmUrl.ts new file mode 100644 index 0000000000..8d8a113291 --- /dev/null +++ b/docs/components/typstCompilerWasmUrl.ts @@ -0,0 +1,11 @@ +// The Typst compiler wasm for the docs build (see next.config.ts): the +// pdf-ua example bundles it via Vite's `?url`; under the docs site the +// import is aliased here. `new URL(..., import.meta.url)` is Turbopack's +// static-asset reference: the ~25MB wasm is emitted as a hashed static +// file and served by the site itself - no CDN involved, and the version +// can't skew from the installed package because it IS the installed file. +const url = new URL( + "../node_modules/@blocknote/xl-typst-compiler/pkg/blocknote_typst_wasm_bg.wasm", + import.meta.url, +).href; +export default url; diff --git a/docs/content/docs/features/blocks/diagrams.mdx b/docs/content/docs/features/blocks/diagrams.mdx index e06f57455e..605759d0ce 100644 --- a/docs/content/docs/features/blocks/diagrams.mdx +++ b/docs/content/docs/features/blocks/diagrams.mdx @@ -68,7 +68,7 @@ The example below wires them all up. Diagrams export to every format BlockNote supports. [Markdown](/docs/features/export/markdown) works out of the box — diagrams export as ` ```mermaid ` fenced code blocks, their common Markdown notation. -The [PDF](/docs/features/export/pdf), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the [PDF](/docs/features/export/pdf), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters work the same way with their respective subpaths: +The [PDF](/docs/features/export/pdf) (and [Typst](/docs/features/export/typst)), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the others work the same way with their respective subpaths: ```typescript import { @@ -76,7 +76,8 @@ import { docxDefaultSchemaMappings, } from "@blocknote/xl-docx-exporter"; import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; -// ...or "@blocknote/diagram-block/pdf-exporter", +// ...or "@blocknote/diagram-block/typst-exporter" (PDF + Typst - embeds +// vector SVG with labels as real text), // "@blocknote/diagram-block/odt-exporter", // "@blocknote/diagram-block/email-exporter" diff --git a/docs/content/docs/features/blocks/math.mdx b/docs/content/docs/features/blocks/math.mdx index 048686f2b8..d8f2a7ae60 100644 --- a/docs/content/docs/features/blocks/math.mdx +++ b/docs/content/docs/features/blocks/math.mdx @@ -119,36 +119,28 @@ import { // Spread into the ODTExporter's mappings exactly as for DOCX above. ``` -### PDF +### Typst / PDF -With the [PDF exporter](/docs/features/export/pdf), math blocks export as vector formulas — no rasterization, so they also work server-side out of the box. Inline math is rasterized to images that flow with the text: +With the [Typst exporter](/docs/features/export/typst) and the +[PDF exporter](/docs/features/export/pdf) built on it (they share mappings), +math blocks and inline math export as **native Typst equations**: real, +selectable text, no rasterization, so they also work server-side out of the +box: ```typescript import { - createInlineMathMapping, + inlineMathMapping, mathBlockMapping, -} from "@blocknote/math-block/pdf-exporter"; - -// Spread into the PDFExporter's mappings as for DOCX above - note that -// inline math is a factory here: `math: createInlineMathMapping()`. -``` - -The inline math factory takes one option: +} from "@blocknote/math-block/typst-exporter"; -```typescript -createInlineMathMapping(options?: { - /** - * Rasterizes the formula SVG to an image. Defaults to the built-in - * canvas rasterizer, which only works in the browser - when exporting - * server-side, pass one backed by e.g. `@resvg/resvg-js` or `sharp`; - * without it, a server-side export throws. The `RasterizeSVG` type is - * exported from the same subpath. - */ - rasterize?: RasterizeSVG; -}); +// Spread into the PDFExporter's mappings as for DOCX above: +// `mathBlock: mathBlockMapping` and `math: inlineMathMapping`. ``` -Math blocks require the `@react-pdf/math` package (a peer dependency of the PDF mapping). +The LaTeX is converted to Typst's math notation. Most KaTeX commands are +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. ### Email diff --git a/docs/content/docs/features/export/meta.json b/docs/content/docs/features/export/meta.json index eba425dcaf..de3ccac056 100644 --- a/docs/content/docs/features/export/meta.json +++ b/docs/content/docs/features/export/meta.json @@ -1,4 +1,4 @@ { "title": "Export", - "pages": ["markdown", "html", "pdf", "docx", "email", "odt", "..."] + "pages": ["markdown", "html", "pdf", "docx", "email", "odt", "typst", "..."] } diff --git a/docs/content/docs/features/export/pdf.mdx b/docs/content/docs/features/export/pdf.mdx index ffea275c0f..fc577af1ca 100644 --- a/docs/content/docs/features/export/pdf.mdx +++ b/docs/content/docs/features/export/pdf.mdx @@ -1,6 +1,6 @@ --- title: PDF -description: Export BlockNote documents to a PDF. +description: Export BlockNote documents to an accessible, tagged PDF (PDF/UA). imageTitle: PDF Export path: /docs/export-to-pdf --- @@ -8,6 +8,9 @@ path: /docs/export-to-pdf # PDF Export It's possible to export BlockNote documents to PDF, completely client-side. +The exporter is powered by the [Typst](https://typst.app) typesetting engine +(compiled to WebAssembly) and produces accessible, tagged documents; see +[PDF/UA conformance](#pdfua-conformance). This feature is provided by the `@blocknote/xl-pdf-exporter`. `xl-` packages @@ -16,98 +19,239 @@ It's possible to export BlockNote documents to PDF, completely client-side. [Business subscription](/pricing). -First, install the `@blocknote/xl-pdf-exporter` and `@react-pdf/renderer` packages: +First, install the `@blocknote/xl-pdf-exporter` package: ```bash -npm install @blocknote/xl-pdf-exporter @react-pdf/renderer +npm install @blocknote/xl-pdf-exporter ``` -Then, create an instance of the `PDFExporter` class. This exposes the following methods: +Then, create an instance of the `PDFExporter` class and export the document: ```typescript import { PDFExporter, - pdfDefaultSchemaMappings, + typstDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; -import * as ReactPDF from "@react-pdf/renderer"; // Create the exporter -const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings); - -// Convert the blocks to a react-pdf document -const pdfDocument = await exporter.toReactPDFDocument(editor.document); +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings); -// Use react-pdf to write to file: -await ReactPDF.render(pdfDocument, `filename.pdf`); +// Export the document; the result carries the PDF as bytes and as a Blob +const result = await exporter.toPDF(editor.document, { + title: "My document", + lang: "en", +}); +if (!result.error) { + const url = URL.createObjectURL(result.blob); + // e.g. open or download `url` +} ``` -See the [full example](/examples/interoperability/converting-blocks-to-pdf) with live PDF preview below: +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) +with a live PDF preview below: ### Customizing the PDF -`toReactPDFDocument` takes an optional `options` parameter, which allows you to customize the header and footer of the PDF: +`toPDF` takes per-export options: the document metadata and page setup. + +```typescript +const result = await exporter.toPDF(editor.document, { + // Document title, 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()", +}); +``` + +The remaining export options are +[`tryDeclarePdfUA`](#pdfua-conformance), `assets` (extra files for +caller-supplied header/footer markup), and `creationTimestamp` (a fixed +Unix timestamp for byte-reproducible output). -Example usage: +A document that fails to compile (e.g. text in a script the loaded fonts +don't cover) is reported in the result as `{ error: "compile-failed" }` +with the compiler's diagnostics, rather than thrown. + +### PDF/UA conformance + +The produced PDF is always _tagged_: it carries a logical structure tree +(headings, paragraphs, lists, tables, figures with alt text, links) that +screen readers can navigate. 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 +exported as tagged-but-unclaimed instead, with the violations reported in +the result: ```typescript -import { Text } from "@react-pdf/renderer"; -const pdfDocument = await exporter.toReactPDFDocument(editor.document, { - header: Header, - footer: Footer, +const result = await exporter.toPDF(editor.document, { + title: "My document", + lang: "en", }); +if ( + !result.error && + !result.pdfUA.declared && + result.pdfUA.reason === "nonconforming" +) { + // e.g. "PDF/UA-1 error: the first heading must be of level 1" + console.info(result.pdfUA.violations.map((v) => v.message)); +} ``` +What conformance requires of the document: + +- **Title and language**: pass `title` and `lang` in the export options. + `lang` is required to attempt the claim at all: a wrong language + declaration is an accessibility defect no validator can catch, so + exporting without it throws. +- **Headings**: the first heading must be level 1, and levels must be + consecutive (no jumping from H1 to H3). + +Images always get the alt text PDF/UA requires, derived from the caption +with the file name as fallback. Captions make far better alt text, so +encourage them. + +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. For live previews, also create a fresh exporter per +export, since an instance accumulates the image assets it resolves. + ### 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) to a React-PDF element. -If you're using a [custom schema](/docs/features/custom-schemas) in your editor, or if you want to overwrite how default BlockNote elements are converted to PDF, you can pass your own `mappings`: +The `PDFExporter` constructor takes a `schema` and `mappings` parameter. A +_mapping_ converts a BlockNote schema element into a **Typst markup +string**, and the mappings are shared with the standalone Typst export. See +[custom mappings](/docs/features/export/typst#custom-mappings--custom-schemas) +there; everything on writing them applies to this exporter unchanged. + +### Math & diagram blocks + +The [math](/docs/features/blocks/math) and +[diagram](/docs/features/blocks/diagrams) blocks ship their own Typst +mappings: math exports as native Typst equations (real text, not images), +diagrams as embedded vector graphics. See +[exporting math](/docs/features/blocks/math#typst--pdf) and +[exporting diagrams](/docs/features/blocks/diagrams#exporting) for the setup. -For example, use the following code in case your schema has an `extraBlock` type: +### Fonts + +By default, exports use a bundled font set matching the editor: Inter +(body), Geist Mono (code), New Computer Modern Math (math), and Noto Color +Emoji. To use your own fonts instead, pass the font bytes and the matching +family names to the constructor: ```typescript -import { PDFExporter, pdfDefaultSchemaMappings } from "@blocknote/xl-pdf-exporter"; -import { Text } from "@react-pdf/renderer"; - -new PDFExporter(schema, { - blockMapping: { - ...pdfDefaultSchemaMappings.blockMapping, - myCustomBlock: (block, exporter) => { - return My custom block; - }, - }, - inlineContentMapping: pdfDefaultSchemaMappings.inlineContentMapping, - styleMapping: pdfDefaultSchemaMappings.styleMapping, +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, { + fontFamily: "My Font", + monoFontFamily: "My Mono Font", + // Uint8Array[], or a promise of them + fonts: [myFontBytes, myMonoFontBytes], + // An emoji-capable font; replaces the default independently of `fonts` + emojiFont: myEmojiFontBytes, }); ``` -### Math & diagram blocks +Typst selects fonts by the family name embedded in the font file itself, so +the `fontFamily` options must match what your files declare. A mismatch +shows up as an `unknown font family` entry in the result's +`compileWarnings`. + +To extend the defaults rather than replace them (e.g. adding a CJK font as +a per-glyph fallback), spread the exported default loaders: + +```typescript +import { + DEFAULT_FONT_FAMILY, + loadDefaultBodyFonts, + PDFExporter, + typstDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; + +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, { + fontFamily: [DEFAULT_FONT_FAMILY, "Noto Sans SC"], + fonts: loadDefaultBodyFonts().then((fonts) => [...fonts, notoSansSCBytes]), +}); +``` -The [math](/docs/features/blocks/math) and [diagram](/docs/features/blocks/diagrams) blocks ship their own PDF mappings — math blocks export as vector formulas, inline math as images flowing with the text, diagrams as embedded images. See [exporting math](/docs/features/blocks/math#pdf) and [exporting diagrams](/docs/features/blocks/diagrams#exporting) for the setup. +### Self-hosting the compiler + +The Typst compiler is a ~25MB wasm file from `@blocknote/xl-typst-compiler`, +by default emitted as an asset by your bundler. To control where it is +served from (e.g. with caching headers), pass its URL or bytes: + +```typescript +import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url"; + +const exporter = new PDFExporter(editor.schema, typstDefaultSchemaMappings, { + wasm: compilerWasmUrl, +}); +``` ### Exporter options -The `PDFExporter` constructor takes an optional `options` parameter. -While conversion happens on the client-side, the default setup uses two server based resources: +The `PDFExporter` constructor takes an optional third `options` parameter: ```typescript const defaultOptions = { - // emoji source, this is passed to the react-pdf library (https://react-pdf.org/fonts#registeremojisource) - // these are loaded from cloudflare + twemoji by default - emojiSource: { - format: "png", - url: "https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/72x72/", - }, - // a function to resolve external resources in order to avoid CORS issues - // by default, this calls a BlockNote hosted server-side proxy to resolve files + // 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 to use in the PDF for things like highlighting, background colors and font colors. + // the colors used for highlighting, background colors and font colors colors: COLORS_DEFAULT, // defaults from @blocknote/core + // font families (DEFAULT_FONT_FAMILY / DEFAULT_MONO_FONT_FAMILY) and the + // font bytes they resolve against - see "Fonts" above + fontFamily: "Inter 18pt", + monoFontFamily: "Geist Mono", + fonts: loadDefaultBodyFonts(), + emojiFont: loadDefaultEmojiFont(), + // base font size in points + fontSize: 12, + // where the compiler wasm loads from - see "Self-hosting the compiler" + wasm: undefined, // the packaged wasm }; ``` + +### 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](/docs/features/export/typst). + +### Deprecated: the react-pdf exporter + +Previous versions of `@blocknote/xl-pdf-exporter` exported PDFs with +[react-pdf](https://react-pdf.org), 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: + +```typescript +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](#custom-mappings--custom-schemas) +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/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx new file mode 100644 index 0000000000..4a5e0ddaa3 --- /dev/null +++ b/docs/content/docs/features/export/typst.mdx @@ -0,0 +1,155 @@ +--- +title: Typst +description: Export BlockNote documents to Typst markup. +imageTitle: Typst Export +path: /docs/export-to-typst +--- + +# Typst Export + +It's possible to export BlockNote documents to [Typst](https://typst.app) +markup, for example to post-process documents in a Typst workflow, or to +compile them to PDF with your own Typst toolchain (including server-side). +This exporter is also the foundation of the +[PDF exporter](/docs/features/export/pdf), which compiles its output to +accessible, tagged PDF/UA-1 in the browser. + + + This feature is provided by the `@blocknote/xl-typst-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](/pricing). + + +First, install the `@blocknote/xl-typst-exporter` package: + +```bash +npm install @blocknote/xl-typst-exporter +``` + +Then, create an instance of the `TypstExporter` class: + +```typescript +import { + TypstExporter, + typstDefaultSchemaMappings, +} from "@blocknote/xl-typst-exporter"; + +// Create the exporter +const exporter = new TypstExporter(editor.schema, typstDefaultSchemaMappings); + +// Convert the blocks to a Typst source string +const typst = await exporter.toTypst(editor.document, { + title: "My document", + lang: "en", +}); +``` + +### Images & assets + +The markup references files by virtual paths: the document's images (like +`/assets/asset-0`) and a bundled code-highlighting theme the preamble +always references. After `toTypst`, the exporter's `assetFiles` map holds +the bytes for those paths. Map them into your Typst compiler's filesystem +before compiling; every document needs this, not only those with images: + +```typescript +const assets = exporter.assetFiles; // Map +``` + +(The [PDF exporter](/docs/features/export/pdf) does this automatically.) + +### Customizing the output + +`toTypst` takes per-export options: the document metadata and page setup. + +```typescript +const typst = await exporter.toTypst(editor.document, { + // Document title, written to the document metadata + title: "My document", + // Document author + 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()", +}); +``` + +Only the options you pass are emitted; the markup declares no title, +author, or language on its own. + +### Custom mappings / custom schemas + +A _mapping_ defines how to convert a BlockNote schema element (a Block, +Inline Content, or Style) into a **Typst markup string**. The same mappings +drive the [PDF exporter](/docs/features/export/pdf), so one custom-block +mapping serves both formats. + +If you're using a [custom schema](/docs/features/custom-schemas) in your +editor, or if you want to overwrite how default BlockNote elements are +converted, you can pass your own `mappings`: + +```typescript +import { + TypstExporter, + typstDefaultSchemaMappings, + strLit, +} from "@blocknote/xl-typst-exporter"; + +new TypstExporter(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](/docs/features/blocks/math) and +[diagram](/docs/features/blocks/diagrams) blocks ship their own Typst +mappings. See [exporting math](/docs/features/blocks/math#typst--pdf) and +[exporting diagrams](/docs/features/blocks/diagrams#exporting) for the +setup. + +### Exporter options + +The `TypstExporter` constructor takes an optional third `options` parameter: + +```typescript +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 + // the font families the markup references - they must match fonts loaded + // into whichever Typst compiler you use (exported as DEFAULT_FONT_FAMILY + // and DEFAULT_MONO_FONT_FAMILY) + fontFamily: "Inter 18pt", + monoFontFamily: "Geist Mono", + // base font size in points + fontSize: 12, +}; +``` diff --git a/docs/content/docs/foundations/supported-formats.mdx b/docs/content/docs/foundations/supported-formats.mdx index c5e302e19a..0124782e9e 100644 --- a/docs/content/docs/foundations/supported-formats.mdx +++ b/docs/content/docs/foundations/supported-formats.mdx @@ -29,6 +29,7 @@ See the table below for a summary of the formats we support and their lossiness: | **[DOCX](/docs/features/export/docx)** (`@blocknote/xl-docx-exporter`) | ❌ | ✅ | ✅ | | **[ODT](/docs/features/export/odt)** (`@blocknote/xl-odt-exporter`) | ❌ | ✅ | ✅ | | **[Email](/docs/features/export/email)** (`@blocknote/xl-email-exporter`) | ❌ | ✅ | ✅ | +| **[Typst](/docs/features/export/typst)** (`@blocknote/xl-typst-exporter`) | ❌ | ✅ | ✅ | **Tip:** It's recommended to use **BlockNote JSON (`editor.document`)** for @@ -248,3 +249,5 @@ BlockNote can also export to these additional formats: - Via the [`@blocknote/xl-odt-exporter` package](/docs/features/export/odt) - Email - Via the [`@blocknote/xl-email-exporter` package](/docs/features/export/email) +- Typst + - Via the [`@blocknote/xl-typst-exporter` package](/docs/features/export/typst) diff --git a/docs/content/docs/react/components/index.mdx b/docs/content/docs/react/components/index.mdx index 153e79b43a..a5a31ab084 100644 --- a/docs/content/docs/react/components/index.mdx +++ b/docs/content/docs/react/components/index.mdx @@ -25,7 +25,7 @@ By default, all floating UI elements (toolbars, menus, table handles, etc.) port portalElements={{ // Global default for any element not listed below. default: document.body, - // Per-element overrides. Values can be HTMLElement, a CSS selector, or null (= document.body). + // Per-element overrides. Values can be an HTMLElement or a CSS selector. tableHandles: ".bn-container", }} /> diff --git a/docs/next.config.ts b/docs/next.config.ts index 149bb5a0dd..e6dc33bd82 100644 --- a/docs/next.config.ts +++ b/docs/next.config.ts @@ -24,6 +24,21 @@ const config = { useTypeScriptCli: true, }, redirects, + // `next build` runs Turbopack; the demo examples are written for Vite, so + // give their `?url` asset imports (fonts, the Typst compiler wasm) the + // same URL-string semantics here. + turbopack: { + resolveAlias: { + // Both key forms on purpose: Turbopack matches the alias key against + // the request with its query in some resolution paths and without it + // in others, so covering `/wasm` and `/wasm?url` makes the demo's + // `?url` import resolve in both. + "@blocknote/xl-typst-compiler/wasm": + "./components/typstCompilerWasmUrl.ts", + "@blocknote/xl-typst-compiler/wasm?url": + "./components/typstCompilerWasmUrl.ts", + }, + }, images: { remotePatterns: [ { diff --git a/docs/package.json b/docs/package.json index a2771cd1cf..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", @@ -112,7 +111,9 @@ "y-partykit": "^0.0.25", "y-websocket": "^2.1.0", "yjs": "^13.6.27", - "zod": "^4.3.5" + "zod": "^4.3.5", + "@blocknote/xl-typst-exporter": "workspace:*", + "@blocknote/xl-typst-compiler": "workspace:*" }, "devDependencies": { "@blocknote/code-block": "workspace:*", @@ -126,7 +127,6 @@ "@blocknote/xl-email-exporter": "workspace:*", "@blocknote/xl-multi-column": "workspace:*", "@blocknote/xl-odt-exporter": "workspace:*", - "@blocknote/xl-pdf-exporter": "workspace:*", "@tailwindcss/postcss": "^4.1.18", "@types/better-sqlite3": "^7.6.13", "@types/mdx": "^2.0.13", diff --git a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx index a86d8c9f01..2c27dcd6a3 100644 --- a/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx +++ b/examples/03-ui-components/11-uppy-file-panel/src/FileReplaceButton.tsx @@ -8,7 +8,7 @@ import { useBlockNoteEditor, useComponentsContext, useDictionary, - useEditorPortalElement, + usePortalElement, useSelectedBlocks, } from "@blocknote/react"; import { useCallback, useEffect, useState } from "react"; @@ -24,7 +24,7 @@ export const FileReplaceButton = () => { const dict = useDictionary(); const Components = useComponentsContext()!; // Portal necessary to properly show popover on mobile. - const editorPortalElement = useEditorPortalElement(); + const editorPortalElement = usePortalElement(); const editor = useBlockNoteEditor< BlockSchema, @@ -68,7 +68,7 @@ export const FileReplaceButton = () => { open={isOpen} onOpenChange={setIsOpen} position={"bottom"} - portalRoot={editorPortalElement} + portalElement={editorPortalElement} > 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 483bc312d7..54b4ff5885 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/index.html +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/index.html @@ -5,7 +5,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> - Exporting documents to PDF + 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 956e045e8f..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,4 +1,6 @@ +import { testDocumentBlocks } from "./testDocumentBlocks"; import { + Block, BlockNoteSchema, combineByGroup, withPageBreak, @@ -9,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"; -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"); - // Creates a new editor instance. + 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 }; +} + +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: { @@ -63,292 +146,17 @@ 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: [ - { - 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: "divider" }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "This one too, but it's blue", - styles: { italic: true }, - }, - ], - props: { - backgroundColor: "blue", - }, - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "heading", - content: "Heading", - }, - { - type: "heading", - content: "Heading right", - props: { - textAlignment: "right", - }, - }, - { - 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: "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. 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: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, - ], - }, - }, - { - type: "pageBreak", - }, - { - 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: "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: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell 1", "Table Cell 2", "Table Cell 3"], - }, - { - cells: [ - "Table Cell 4", - [ - { - type: "text", - text: "Table Cell Bold 5", - styles: { - bold: true, - }, - }, - ], - "Table Cell 6", - ], - }, - { - cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], - }, - ], - }, - }, - { - type: "codeBlock", - props: { - language: "javascript", - }, - content: `const helloWorld = (message) => { - console.log("Hello World", message); -};`, - }, + ...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}", @@ -374,63 +182,10 @@ export default function App() { }, ], }, - { - 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", - }, - ], - }, - ], - }, ], }); - // 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( @@ -444,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 (
@@ -507,15 +279,24 @@ export default function App() {
- PDF Output - + {label} +
- - {pdfDocument} - + {pdfUrl ? ( +