diff --git a/.claude/skills/testing-skill/SKILL.md b/.claude/skills/testing-skill/SKILL.md index ab70133c11..982d9ea03b 100644 --- a/.claude/skills/testing-skill/SKILL.md +++ b/.claude/skills/testing-skill/SKILL.md @@ -17,9 +17,13 @@ In most cases, once a feature, bug fix, or other modification has been written, `/packages/xl-*`: Contain tests for functionality included in a given `xl-*` package. +### Colocated Browser Unit Tests + +`packages/*/src/**/*.browser.test.{ts,tsx}`: Unit tests for browser-only implementations (e.g. canvas or DOM-dependent code) live next to the code they test, with a `.browser.test` suffix. They run as part of the browser suite in Docker (the `tests` package's browser config includes them); the packages' own node-mode vitest configs exclude them. Use this when the unit under test genuinely needs a real browser — everything else should be a plain node unit test. + ### End-to-End Tests -`tests/src/end-to-end`: Any test which interacts with the editor UI or simulates user interaction goes here. New subdirectories can be added if the functionality being tested is not covered by any of the existing ones. Important note about existing E2E tests - many are written poorly and should only loosely be used as reference. We want to avoid abstraction layers and `waitForTimeout` as much as possible. +`tests/src/end-to-end`: Tests that need a real browser and span multiple packages go here — chiefly tests which interact with the editor UI or simulate user interaction, but also browser integration tests that exercise complete flows without interaction (e.g. exporting a full document, static rendering). New subdirectories can be added if the functionality being tested is not covered by any of the existing ones. Important note about existing E2E tests - many are written poorly and should only loosely be used as reference. We want to avoid abstraction layers and `waitForTimeout` as much as possible. ## When & How to Add Tests @@ -29,6 +33,8 @@ However, this may not be true when adding edge case handling or a new feature, w We want to avoid adding end-to-end tests where it's possible to use unit tests instead. +**Don't use jsdom** (`@vitest-environment jsdom`) in new tests. It's a murky middle ground — `document` exists but rendering doesn't — which makes browser-capability checks pass while the capability itself is broken. Use the default node environment with pluggable seams for logic, and the browser suite (`tests/src/end-to-end`, vitest browser mode in Docker) for anything that needs real rendering. + ## Running & Updating Tests ### Unit Tests @@ -39,24 +45,18 @@ Updating tests can be done by adding the `-u` argument, i.e. `vp run test -u`. A ### End-to-End Tests -End-to-end tests run inside a docker container. While its possible to run them outside of it, we do not have existing snapshots to compare results with, and the results sometimes differ to when they're run within Docker, so it's not worth doing. - -To run end-to-end tests, you must first build the project and run the preview. You can do this by running `vp start` from the root directory. +End-to-end tests run in vitest browser mode (chromium, firefox and webkit) inside a Docker container, so screenshot baselines are identical locally and on CI. Run them from the repository root: -You can then run the tests from the `/tests` directory using the following command: - -``` -docker run --rm -e RUN_IN_DOCKER=true --network host -v $(pwd)/..:/work/ -w /work/tests -it mcr.microsoft.com/playwright:v1.51.1-noble npx playwright test +```bash +bash tests/docker-run.sh -e CI=1 -- --run [filters] ``` -A specific test file may be targeted by appending its name, i.e. `... npx playwright test fileName`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass). +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). -Updating tests can be done by adding the `-u` argument, i.e. `... npx playwright test -u`. All of the other things you can do to scope which tests to target still apply. +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. -Note that running this command may result in errors or other issues, listed below along with what to do when encountered: - -- **Tests failing to navigating to preview**: project should be built and the preview started, after which the command should be run again. -- **Docker not running**: the user should be notified to launch Docker. -- **Incorrect Playwright image version**: update Playwright images and re-run the command. +If Docker isn't running, notify the user to launch it. When testing a visual change, prefer writing screenshots to verify that the change is working as expected. + +**Screenshots of tall content**: browser-suite tests run inside a tester iframe sized to the browser window (1280x720), and element screenshots only contain what the iframe actually paints — anything below its fold captures as blank white, silently. Growing the iframe with `page.viewport()` alone doesn't fix this at full resolution: the harness scales the iframe down to fit the window, shrinking the resulting baseline (`static.test.tsx` accepts that trade-off). For full-resolution captures use `screenshotFull` (`tests/src/utils/screenshotFull.ts`), which grows the iframe past the content and neutralizes the harness's scale transform during the capture — the same mechanism upstream Vitest adopted in vitest-dev/vitest#9745 (milestone 5.0.0; the util can be deleted once vite-plus ships it). Always eyeball newly generated baselines for truncation. diff --git a/AGENTS.md b/AGENTS.md index 8b992c6744..22b10b6527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,13 @@ BlockNote is a block-based rich text editor for the web. It's designed as a batteries-included product that offers a solid user experience with minimal setup. However, it also offers extensibility via plugins and custom block types. +# Code Conventions + +- **Leverage the type system so mistakes surface at compile time, not runtime.** Model states and outcomes explicitly: discriminated unions over boolean flags with optional fields, no `any` or casts that hide a case a caller should handle, exhaustive `switch`es over union members. If the compiler can enforce a contract, prefer that over documentation or runtime checks. +- **Expected failures are values, not exceptions.** When an operation can fail as part of normal use (canonical example: parsing user input, like LaTeX or Mermaid source), that failure is part of the function's contract — so it belongs in the return type. Catch it at the lowest level (the small adapter around the throwing third-party call) and convert it into a Result-style discriminated union (e.g. `{ error?: undefined; ...data } | { error: string }`). The failure then propagates through the type system, and the compiler forces every caller to decide how to handle it. Exceptions don't appear in TypeScript signatures, so a thrown expected error is invisible to callers — and a `try/catch` around a whole pipeline conflates expected failures with genuine bugs. +- **Exceptions are only for unexpected failures** — broken invariants, environment or infrastructure problems, programmer errors. Let them propagate and fail loudly; don't catch-and-continue. Corollary: never render a caught exception's message into user-facing output (documents, UI) — a catch-all can capture anything, and arbitrary messages can leak internals. Only messages carried by typed expected-error results are known-safe to display. +- **Prefer `function name() {}` declarations over `const name = () => {}`** for named functions (anonymous callbacks and returned closures can stay arrows). + # Common Commands All commands below are listed under `package.json` in the project root. See `vite.config.ts` for relevant configuration settings. diff --git a/docs/content/docs/features/blocks/code-blocks.mdx b/docs/content/docs/features/blocks/code-blocks.mdx index 77ae02327a..3b190a4c79 100644 --- a/docs/content/docs/features/blocks/code-blocks.mdx +++ b/docs/content/docs/features/blocks/code-blocks.mdx @@ -218,7 +218,9 @@ const editor = useCreateBlockNote({ }); ``` -The math block renders LaTeX as MathML (via [Temml](https://temml.org/)) for the browser to display natively. Exporting to HTML produces a MathML `` element, and pasting MathML back in is converted to LaTeX. Additionally, the source code is rendered to an `annotation` element in the HTML export for lossless round-trip conversion. +The math block renders LaTeX as MathML (via [KaTeX](https://katex.org/)) for the browser to display natively. Exporting to HTML produces a MathML `` element, and pasting MathML back in is converted to LaTeX. Additionally, the source code is rendered to an `annotation` element in the HTML export for lossless round-trip conversion. + +Math and diagram blocks can also be exported to [Markdown](/docs/features/export/markdown), [PDF](/docs/features/export/pdf), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) — see each format's docs for the mappings to wire up. diff --git a/docs/content/docs/features/export/docx.mdx b/docs/content/docs/features/export/docx.mdx index 2536daa6bb..956511da9d 100644 --- a/docs/content/docs/features/export/docx.mdx +++ b/docs/content/docs/features/export/docx.mdx @@ -110,6 +110,53 @@ new DOCXExporter(schema, { }); ``` +### Math & diagram blocks + +The [math and diagram blocks](/docs/features/blocks/code-blocks#code-blocks-with-previews) live in separate packages, and so do their DOCX mappings. Spread them into the default mappings to export math as native (editable) Word equations and diagrams as embedded images: + +```typescript +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from "@blocknote/xl-docx-exporter"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/math-block/docx-exporter"; +import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; + +const exporter = new DOCXExporter(editor.schema, { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + diagram: createDiagramBlockMapping(), + }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + inlineMath: inlineMathMapping, + }, +}); +``` + +Invalid LaTeX or Mermaid sources render an error placeholder identifying the offending source, mirroring the editor. + +Math exports fine server-side (the LaTeX is converted to OMML without rendering). Rendering diagrams to images, however, requires a browser — when exporting server-side, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by [`@mermaid-js/mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) or a [Kroki](https://kroki.io) server); without one, the export throws: + +```typescript +import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; +import type { RenderDiagram } from "@blocknote/diagram-block/docx-exporter"; + +const renderDiagram: RenderDiagram = async (source) => { + // Render the Mermaid source to an image with your renderer of choice. + return { + image: { data: pngBytes, mimeType: "image/png", width, height }, + }; +}; + +createDiagramBlockMapping({ renderDiagram }); +``` + ### Exporter options The `DOCXExporter` constructor takes an optional `options` parameter. @@ -120,6 +167,10 @@ const defaultOptions = { // 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 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 Docx for things like highlighting, background colors and font colors. colors: COLORS_DEFAULT, // defaults from @blocknote/core }; diff --git a/docs/content/docs/features/export/email.mdx b/docs/content/docs/features/export/email.mdx index b36f7ccd58..64703e5b8b 100644 --- a/docs/content/docs/features/export/email.mdx +++ b/docs/content/docs/features/export/email.mdx @@ -116,6 +116,64 @@ new ReactEmailExporter(schema, { }); ``` +### Math & diagram blocks + +The [math and diagram blocks](/docs/features/blocks/code-blocks#code-blocks-with-previews) live in separate packages, and so do their email mappings. Spread them into the default mappings to export math and diagrams as images, with the LaTeX/Mermaid source as the alt text: + +```typescript +import { + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { + createInlineMathMapping, + createMathBlockMapping, +} from "@blocknote/math-block/email-exporter"; +import { createDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter"; + +const exporter = new ReactEmailExporter(editor.schema, { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + math: createMathBlockMapping(), + diagram: createDiagramBlockMapping(), + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + inlineMath: createInlineMathMapping(), + }, +}); +``` + +Invalid LaTeX or Mermaid sources render an error placeholder identifying the offending source, mirroring the editor. + +**Image delivery.** By default the images are embedded as data URLs — self-contained, but some email clients (notably Gmail and Outlook for Windows) don't display data URL images and show the alt text instead. For those, deliver the images as inline `cid:` attachments (the most widely supported way to embed generated images) and pass the collected attachments to your mailer at send time: + +```typescript +import { createCIDImageDelivery } from "@blocknote/xl-email-exporter"; + +const imageDelivery = createCIDImageDelivery(); +const exporter = new ReactEmailExporter(editor.schema, { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + math: createMathBlockMapping({ imageDelivery }), + diagram: createDiagramBlockMapping({ imageDelivery }), + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + inlineMath: createInlineMathMapping({ imageDelivery }), + }, +}); + +const html = await exporter.toReactEmailDocument(editor.document); + +// e.g. with nodemailer (works the same with other mailers): +await transporter.sendMail({ html, attachments: imageDelivery.attachments }); +``` + +**Server-side rendering.** Emails are usually rendered server-side at send time. Math handles this out of the box: block math is rasterized to PNG in the browser and embedded as SVG elsewhere (pass `rasterize` to `createMathBlockMapping`, e.g. backed by [`@resvg/resvg-js`](https://github.com/nlopes/resvg-js), to get PNGs server-side too — more email clients display them); inline math is always embedded as SVG. Rendering diagrams, however, requires a browser — when exporting server-side, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by [`@mermaid-js/mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) or a [Kroki](https://kroki.io) server); without one, the export throws. + ### Exporter options The `ReactEmailExporter` constructor takes an optional `options` parameter. @@ -126,6 +184,10 @@ const defaultOptions = { // 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 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 email for things like highlighting, background colors and font colors. colors: COLORS_DEFAULT, // defaults from @blocknote/core }; diff --git a/docs/content/docs/features/export/markdown.mdx b/docs/content/docs/features/export/markdown.mdx index b151728ea1..9f8d17e887 100644 --- a/docs/content/docs/features/export/markdown.mdx +++ b/docs/content/docs/features/export/markdown.mdx @@ -33,6 +33,8 @@ const markdownFromBlocks = editor.blocksToMarkdownLossy(blocks); The output is simplified as Markdown does not support all features of BlockNote (e.g.: children of blocks which aren't list items are un-nested and certain styles are removed). +[Math and diagram blocks](/docs/features/blocks/code-blocks#code-blocks-with-previews) export to their common Markdown notations: math blocks as `$$` blocks, inline math as `$...$` spans, and diagrams as ` ```mermaid ` fenced code blocks. + **Demo** diff --git a/docs/content/docs/features/export/odt.mdx b/docs/content/docs/features/export/odt.mdx index 3f5248a875..5f77ee5704 100644 --- a/docs/content/docs/features/export/odt.mdx +++ b/docs/content/docs/features/export/odt.mdx @@ -85,6 +85,39 @@ new ODTExporter(schema, { }); ``` +### Math & diagram blocks + +The [math and diagram blocks](/docs/features/blocks/code-blocks#code-blocks-with-previews) live in separate packages, and so do their ODT mappings. Spread them into the default mappings to export math as native (editable) formula objects and diagrams as embedded images: + +```typescript +import { + ODTExporter, + odtDefaultSchemaMappings, +} from "@blocknote/xl-odt-exporter"; +import { + inlineMathMapping, + mathBlockMapping, +} from "@blocknote/math-block/odt-exporter"; +import { createDiagramBlockMapping } from "@blocknote/diagram-block/odt-exporter"; + +const exporter = new ODTExporter(editor.schema, { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + diagram: createDiagramBlockMapping(), + }, + inlineContentMapping: { + ...odtDefaultSchemaMappings.inlineContentMapping, + inlineMath: inlineMathMapping, + }, +}); +``` + +Invalid LaTeX or Mermaid sources render an error placeholder identifying the offending source, mirroring the editor. + +Math exports fine server-side (the LaTeX is converted to MathML without rendering). Rendering diagrams to images, however, requires a browser — when exporting server-side, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by [`@mermaid-js/mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) or a [Kroki](https://kroki.io) server); without one, the export throws. + ### Exporter options The `ODTExporter` constructor takes an optional `options` parameter. @@ -95,6 +128,10 @@ const defaultOptions = { // 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 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 ODT for things like highlighting, background colors and font colors. colors: COLORS_DEFAULT, // defaults from @blocknote/core }; diff --git a/docs/content/docs/features/export/pdf.mdx b/docs/content/docs/features/export/pdf.mdx index 08594cf96f..629940c59d 100644 --- a/docs/content/docs/features/export/pdf.mdx +++ b/docs/content/docs/features/export/pdf.mdx @@ -83,6 +83,42 @@ new PDFExporter(schema, { }); ``` +### Math & diagram blocks + +The [math and diagram blocks](/docs/features/blocks/code-blocks#code-blocks-with-previews) live in separate packages, and so do their PDF mappings. Spread them into the default mappings to export math blocks as vector formulas (via `@react-pdf/math`), inline math as images flowing with the text, and diagrams as embedded images: + +```typescript +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +import { + createInlineMathMapping, + mathBlockMapping, +} from "@blocknote/math-block/pdf-exporter"; +import { createDiagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; + +const exporter = new PDFExporter(editor.schema, { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + math: mathBlockMapping, + diagram: createDiagramBlockMapping(), + }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + inlineMath: createInlineMathMapping(), + }, +}); +``` + +Math blocks require the `@react-pdf/math` package. Invalid LaTeX or Mermaid sources render an error placeholder identifying the offending source, mirroring the editor. Note that the PDF exporter renders LaTeX with MathJax while the editor (and the DOCX/ODT exporters) use KaTeX — rare constructs supported by only one of the two may render differently. + +Math blocks export fine server-side (they're vector output, no rasterization involved). Inline math and diagrams, however, are rasterized to images, which the built-in implementations can only do in the browser. When exporting server-side, plug in your own — without one, the export throws: + +- `createInlineMathMapping({ rasterize })` — a `RasterizeSVG` function, e.g. backed by [`@resvg/resvg-js`](https://github.com/nlopes/resvg-js) or [`sharp`](https://sharp.pixelplumbing.com/). +- `createDiagramBlockMapping({ renderDiagram })` — a `RenderDiagram` function, e.g. backed by [`@mermaid-js/mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) or a [Kroki](https://kroki.io) server. + ### Exporter options The `PDFExporter` constructor takes an optional `options` parameter. @@ -99,6 +135,10 @@ const defaultOptions = { // 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 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. colors: COLORS_DEFAULT, // defaults from @blocknote/core }; diff --git a/docs/package.json b/docs/package.json index 1639cbe5bc..b7d9870a82 100644 --- a/docs/package.json +++ b/docs/package.json @@ -55,11 +55,11 @@ "@react-pdf/math": "^2.0.1", "@react-pdf/renderer": "^4.5.1", "@sentry/nextjs": "^10.34.0", - "@shikijs/core": "^4", - "@shikijs/engine-javascript": "^4", - "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4", - "@shikijs/types": "^4", + "@shikijs/core": "^4.4.3", + "@shikijs/engine-javascript": "^4.4.3", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3", + "@shikijs/types": "^4.4.3", "@tiptap/core": "^3.29.2", "@uppy/core": "^3.13.1", "@uppy/dashboard": "^3.9.1", @@ -73,11 +73,11 @@ "@uppy/webcam": "^3.4.2", "@uppy/xhr-upload": "^3.4.0", "@vercel/analytics": "^1.6.1", + "@y-sweet/react": "^0.6.3", "@y/prosemirror": "^2.0.0-6", "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", "@y/y": "^14.0.0-rc.23", - "@y-sweet/react": "^0.6.3", "ai": "^6.0.5", "better-auth": "~1.4.15", "better-sqlite3": "^12.6.2", @@ -92,6 +92,7 @@ "katex": "^0.16.11", "lib0": "1.0.0-rc.22", "lucide-react": "^0.562.0", + "mathjax-full": "^3.2.2", "mermaid": "^11.0.0", "motion": "^12.28.1", "next": "^16.2.7", @@ -105,7 +106,7 @@ "react-icons": "^5.5.0", "react-use-measure": "^2.1.7", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4", + "shiki": "^4.4.3", "tailwind-merge": "^3.4.0", "y-partykit": "^0.0.25", "y-websocket": "^2.1.0", diff --git a/examples/04-theming/07-custom-code-block/.bnexample.json b/examples/04-theming/07-custom-code-block/.bnexample.json index 84166710e3..0ab79ff45e 100644 --- a/examples/04-theming/07-custom-code-block/.bnexample.json +++ b/examples/04-theming/07-custom-code-block/.bnexample.json @@ -5,10 +5,10 @@ "tags": ["Basic"], "dependencies": { "@blocknote/code-block": "latest", - "@shikijs/core": "^4", - "@shikijs/engine-javascript": "^4", - "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4", - "@shikijs/types": "^4" + "@shikijs/core": "^4.4.3", + "@shikijs/engine-javascript": "^4.4.3", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3", + "@shikijs/types": "^4.4.3" } } diff --git a/examples/04-theming/07-custom-code-block/package.json b/examples/04-theming/07-custom-code-block/package.json index 6c70f6b12b..07a7589951 100644 --- a/examples/04-theming/07-custom-code-block/package.json +++ b/examples/04-theming/07-custom-code-block/package.json @@ -21,11 +21,11 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "@blocknote/code-block": "latest", - "@shikijs/core": "^4", - "@shikijs/engine-javascript": "^4", - "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4", - "@shikijs/types": "^4" + "@shikijs/core": "^4.4.3", + "@shikijs/engine-javascript": "^4.4.3", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3", + "@shikijs/types": "^4.4.3" }, "devDependencies": { "@types/react": "^19.2.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 335103b4d6..553ec97d2c 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,15 @@ "playground": true, "docs": true, "author": "yousefed", - "tags": [ - "Interoperability" - ], + "tags": ["Interoperability"], "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" + "@react-pdf/renderer": "^4.5.1", + "mathjax-full": "^3.2.2" }, "pro": true } 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 739babf091..30c28d0430 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/package.json +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/package.json @@ -25,7 +25,8 @@ "@blocknote/xl-multi-column": "latest", "@blocknote/xl-pdf-exporter": "latest", "@react-pdf/math": "^2.0.1", - "@react-pdf/renderer": "^4.5.1" + "@react-pdf/renderer": "^4.5.1", + "mathjax-full": "^3.2.2" }, "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 242705fec0..956e045e8f 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 @@ -29,8 +29,11 @@ import { PDFExporter, pdfDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; -import { diagramBlockMapping } from "@blocknote/xl-pdf-exporter/diagram-block"; -import { mathBlockMapping } from "@blocknote/xl-pdf-exporter/math-block"; +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"; @@ -452,6 +455,11 @@ export default function App() { // 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); diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json index b00619a661..f370c604f4 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json +++ b/examples/05-interoperability/06-converting-blocks-to-docx/.bnexample.json @@ -2,9 +2,7 @@ "playground": true, "docs": true, "author": "yousefed", - "tags": [ - "" - ], + "tags": [""], "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx index b0a11df889..8907bf760f 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx +++ b/examples/05-interoperability/06-converting-blocks-to-docx/src/App.tsx @@ -23,11 +23,11 @@ import { DOCXExporter, docxDefaultSchemaMappings, } from "@blocknote/xl-docx-exporter"; -import { diagramBlockMapping } from "@blocknote/xl-docx-exporter/diagram-block"; +import { diagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; import { inlineMathMapping, mathBlockMapping, -} from "@blocknote/xl-docx-exporter/math-block"; +} from "@blocknote/math-block/docx-exporter"; import { getMultiColumnSlashMenuItems, multiColumnDropCursor, diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json index 8a2998aae2..3fee215859 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json +++ b/examples/05-interoperability/07-converting-blocks-to-odt/.bnexample.json @@ -2,9 +2,7 @@ "playground": true, "docs": true, "author": "areknawo", - "tags": [ - "" - ], + "tags": [""], "dependencies": { "@blocknote/diagram-block": "latest", "@blocknote/math-block": "latest", diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx index 8ceddb0d4d..518617bafd 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx +++ b/examples/05-interoperability/07-converting-blocks-to-odt/src/App.tsx @@ -23,11 +23,11 @@ import { ODTExporter, odtDefaultSchemaMappings, } from "@blocknote/xl-odt-exporter"; -import { diagramBlockMapping } from "@blocknote/xl-odt-exporter/diagram-block"; +import { diagramBlockMapping } from "@blocknote/diagram-block/odt-exporter"; import { inlineMathMapping, mathBlockMapping, -} from "@blocknote/xl-odt-exporter/math-block"; +} from "@blocknote/math-block/odt-exporter"; import { getMultiColumnSlashMenuItems, multiColumnDropCursor, diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json index 34a9fffa9c..12f951eec2 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/.bnexample.json @@ -4,6 +4,8 @@ "author": "jmarbutt", "tags": [""], "dependencies": { + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-email-exporter": "latest", "@react-email/render": "^2.0.4" }, diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/package.json b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json index 409f449bbd..c69fbbbcc0 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/package.json +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/package.json @@ -20,6 +20,8 @@ "@mantine/hooks": "^9.0.2", "react": "^19.2.3", "react-dom": "^19.2.3", + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-email-exporter": "latest", "@react-email/render": "^2.0.4" }, diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx b/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx index 9db579a8d1..86640c049b 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/src/App.tsx @@ -17,6 +17,16 @@ import { useCreateBlockNote, usePrefersColorScheme, } from "@blocknote/react"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { createDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; +import { + createInlineMathMapping, + createMathBlockMapping, +} from "@blocknote/math-block/email-exporter"; import { ReactEmailExporter, reactEmailDefaultSchemaMappings, @@ -33,7 +43,16 @@ export default function App() { // Creates a new editor instance. const editor = useCreateBlockNote({ // Adds support for page breaks. - schema: withPageBreak(BlockNoteSchema.create()), + // Adds support for math & diagram blocks. + schema: withPageBreak(BlockNoteSchema.create()).extend({ + blockSpecs: { + mathBlock: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { + math: createReactInlineMathSpec(), + }, + }), // Adds support for advanced table features. tables: { splitCells: true, @@ -320,6 +339,31 @@ export default function App() { console.log("Hello World", message); };`, }, + { + type: "mathBlock", + content: "a^2 = \\sqrt{b^2 + c^2}", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline math: ", + styles: {}, + }, + { + type: "math", + content: "e^{i\\pi} + 1 = 0", + }, + ], + }, + { + type: "diagram", + content: `graph TD + A[Start] --> B{Works?} + B -->|Yes| C[Ship it] + B -->|No| A`, + }, ], }); @@ -345,7 +389,24 @@ export default function App() { existingContext?.colorSchemePreference || systemColorScheme; const exporter = new ReactEmailExporter( editor.schema, - reactEmailDefaultSchemaMappings, + { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + // Renders math blocks & diagrams as images with the source as alt + // text. Embedded as data URLs by default - when actually sending + // emails, deliver them as inline attachments instead (which Gmail + // & Outlook also display) by passing an `imageDelivery` from + // `createCIDImageDelivery()` to each mapping, and handing its + // `attachments` to your mailer alongside the HTML. + mathBlock: createMathBlockMapping(), + diagram: createDiagramBlockMapping(), + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + math: createInlineMathMapping(), + }, + }, { colors: colorScheme === "dark" ? COLORS_DARK_MODE_DEFAULT : COLORS_DEFAULT, diff --git a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts index f01d84cc60..e485ed3f87 100644 --- a/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts +++ b/examples/07-collaboration/14-suggestion-gallery/src/scenarios.ts @@ -1,4 +1,4 @@ -import { testDocument } from "@blocknote/shared/testDocument"; +import { testDocument } from "@shared/testDocument.js"; import type { GalleryEditor, GalleryPartialBlock } from "./gallerySchema"; diff --git a/examples/07-collaboration/14-suggestion-gallery/tsconfig.json b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json index 93fa81bee8..2aa62c56e6 100644 --- a/examples/07-collaboration/14-suggestion-gallery/tsconfig.json +++ b/examples/07-collaboration/14-suggestion-gallery/tsconfig.json @@ -15,7 +15,10 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "composite": true + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } }, "include": ["."], "__ADD_FOR_LOCAL_DEV_references": [ diff --git a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts index 95ed8cc314..298fb8d817 100644 --- a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts +++ b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts @@ -16,6 +16,10 @@ export default defineConfig(((conf: { command: string }) => ({ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) ? {} : ({ + // The repo-wide alias for the shared test-utils package this + // example depends on (private, so it only resolves inside the + // monorepo). + "@shared": path.resolve(__dirname, "../../../shared/"), // Comment out the lines below to load a built version of blocknote // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( diff --git a/packages/code-block/package.json b/packages/code-block/package.json index 6c6c60a25e..6eb39206c6 100644 --- a/packages/code-block/package.json +++ b/packages/code-block/package.json @@ -49,13 +49,13 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@shikijs/core": "^4", - "@shikijs/engine-javascript": "^4", - "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4" + "@shikijs/core": "^4.4.3", + "@shikijs/engine-javascript": "^4.4.3", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3" }, "optionalDependencies": { - "@shikijs/types": "^4" + "@shikijs/types": "^4.4.3" }, "devDependencies": { "rimraf": "^5.0.10", diff --git a/packages/core/package.json b/packages/core/package.json index d43e6cb8e4..280cb73de6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -96,7 +96,7 @@ "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", - "@shikijs/types": "^4", + "@shikijs/types": "^4.4.3", "@tiptap/core": "^3.29.2", "@tiptap/extension-bold": "^3.29.2", "@tiptap/extension-code": "^3.29.2", @@ -127,9 +127,9 @@ "yjs": "^13.6.27" }, "peerDependencies": { - "@y/y": "^14.0.0-rc.23", "@y/prosemirror": "^2.0.0-6", "@y/protocols": "^1.0.6-rc.1", + "@y/y": "^14.0.0-rc.23", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" diff --git a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts index e74ecb68d3..553247fddb 100644 --- a/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts +++ b/packages/core/src/api/exporters/markdown/htmlToMarkdown.ts @@ -78,6 +78,8 @@ function serializeNode(node: Node, ctx: SerializeContext): string { return serializeTable(el, ctx); case "hr": return ctx.indent + "***\n\n"; + case "math": + return serializeMathBlock(el, ctx); case "img": return serializeImage(el, ctx); case "video": @@ -175,18 +177,41 @@ function serializeCodeBlock(el: HTMLElement, ctx: SerializeContext): string { // For empty code blocks, don't add a newline between the fences if (!code) { - return ctx.indent + fence + language + "\n" + fence + "\n\n"; - } + return ctx.indent + fence + language + "\n" + ctx.indent + fence + "\n\n"; + } + + // Every (non-blank) line carries the indent - inside a list item or + // blockquote, an unindented line would end the parent container. Blank + // lines stay blank: they don't terminate an indented fence, and indenting + // them would add trailing whitespace. + const lines = [ + fence + language, + ...(code.endsWith("\n") ? code.slice(0, -1) : code).split("\n"), + fence, + ]; + return ( + lines.map((line) => (line ? ctx.indent + line : line)).join("\n") + "\n\n" + ); +} +// The LaTeX source of a MathML element, taken from the annotation KaTeX +// embeds in its output (also what external HTML parsing reads). Falls back to +// the element's text content for MathML from other sources. +function extractMathLatexSource(el: Element): string { + const annotation = el.querySelector( + 'annotation[encoding="application/x-tex"]', + ); + return (annotation?.textContent ?? el.textContent ?? "").trim(); +} + +function serializeMathBlock(el: HTMLElement, ctx: SerializeContext): string { + const latex = extractMathLatexSource(el); + // Every (non-blank) line carries the indent - inside a list item or + // blockquote, an unindented line would end the parent container. return ( - ctx.indent + - fence + - language + - "\n" + - code + - (code.endsWith("\n") ? "" : "\n") + - fence + - "\n\n" + ["$$", ...latex.split("\n"), "$$"] + .map((line) => (line ? ctx.indent + line : line)) + .join("\n") + "\n\n" ); } @@ -775,6 +800,16 @@ function serializeInlineContent(el: Element): string { case "br": result += "\\\n"; break; + case "math": { + // Inline math — emit its LaTeX source as a math span. Collapsed to + // a single line, as $...$ spans cannot contain newlines. + const latex = extractMathLatexSource(childEl) + .split("\n") + .map((line) => line.trim()) + .join(" "); + result += `$${latex}$`; + break; + } case "span": // Color spans, etc. — strip the tag, keep content result += serializeInlineContent(childEl); diff --git a/packages/core/src/exporter/ExportImage.ts b/packages/core/src/exporter/ExportImage.ts new file mode 100644 index 0000000000..8d7fb30c07 --- /dev/null +++ b/packages/core/src/exporter/ExportImage.ts @@ -0,0 +1,50 @@ +/** + * An image generated during export (e.g. a rendered formula or diagram): the + * encoded image bytes plus the dimensions to display it at. + * + * The bytes (rather than e.g. a data URL string or a `Blob`) are the source + * of truth: they carry no encoding ambiguity, work in every environment, and + * are readable synchronously - each output format converts them at its own + * boundary (data URL for HTML-based targets, raw bytes for DOCX, base64 for + * email attachments). + */ +export type ExportImage = { + /** MIME type of `data`, e.g. `"image/png"` or `"image/svg+xml"`. */ + mimeType: string; + /** The encoded image bytes. */ + data: Uint8Array; + /** + * Dimensions to display the image at, in the target format's units (CSS + * pixels, points, ...). For raster images, `data`'s own pixel dimensions + * may be larger - images are often rendered at 2-4x for sharpness. + */ + width: number; + height: number; +}; + +/** + * Encodes bytes as base64. This papers over a platform gap: until + * `Uint8Array.prototype.toBase64()` (ES2026) is available in every runtime + * BlockNote supports, the only universal built-in is `btoa`, which takes + * binary *strings*. Uses `toBase64` when the runtime has it. + */ +export function bytesToBase64(bytes: Uint8Array): string { + if ("toBase64" in bytes && typeof bytes.toBase64 === "function") { + return (bytes as Uint8Array & { toBase64(): string }).toBase64(); + } + + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** + * Encodes an {@link ExportImage}'s bytes as a base64 data URL, for targets + * that take image sources as URLs (HTML `src` attributes, react-pdf image + * sources, ...). + */ +export function exportImageToDataURL(image: ExportImage): string { + return `data:${image.mimeType};base64,${bytesToBase64(image.data)}`; +} diff --git a/packages/core/src/exporter/Exporter.test.ts b/packages/core/src/exporter/Exporter.test.ts new file mode 100644 index 0000000000..e1fd22d9e8 --- /dev/null +++ b/packages/core/src/exporter/Exporter.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; +import { COLORS_DEFAULT } from "../editor/defaultColors.js"; +import { StyledText } from "../schema/index.js"; +import { Exporter } from "./Exporter.js"; + +// A minimal concrete exporter with empty mappings, to exercise the +// missing-mapping errors thrown when a document contains block types the +// mappings don't cover (e.g. blocks from separate packages, like math, +// without their exporter mappings spread in). +class TestExporter extends Exporter { + constructor() { + super( + BlockNoteSchema.create(), + { blockMapping: {}, inlineContentMapping: {}, styleMapping: {} } as any, + { colors: COLORS_DEFAULT }, + ); + } + + public transformStyledText(_styledText: StyledText) { + return undefined; + } +} + +describe("Exporter missing mappings", () => { + it("throws a descriptive error for an unmapped block type", async () => { + await expect( + new TestExporter().mapBlock({ type: "math" } as any, 0, 0), + ).rejects.toThrow( + 'missing a block mapping for block type "math". If this block comes from a separate package, spread that package\'s exporter mappings', + ); + }); + + it("throws a descriptive error for an unmapped inline content type", () => { + expect(() => + new TestExporter().mapInlineContent({ type: "inlineMath" } as any), + ).toThrow( + 'missing an inline content mapping for inline content type "inlineMath"', + ); + }); + + it("throws a descriptive error for an unmapped style", () => { + expect(() => new TestExporter().mapStyles({ bold: true } as any)).toThrow( + 'missing a style mapping for style "bold"', + ); + }); +}); diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index f42e89e6f4..9c7a2650fd 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -1,5 +1,7 @@ import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; import { COLORS_DEFAULT } from "../editor/defaultColors.js"; +import type { Dictionary } from "../i18n/dictionary.js"; +import { en } from "../i18n/locales/index.js"; import { BlockFromConfig, BlockSchema, @@ -34,6 +36,20 @@ export type ExporterOptions = { * Colors to use for background of blocks, font colors, and highlight colors */ colors: typeof COLORS_DEFAULT; + /** + * The strings an exporter renders into the produced document (file link + * texts, error placeholders). Accepts a locale from + * `@blocknote/core/locales` or an editor dictionary; block packages that + * ship their own exporter strings (e.g. math, diagram) read their sections + * from this same object, exactly as they do from an editor dictionary. + * + * @default the English strings + */ + dictionary?: { exporter: Dictionary["exporter"] } & { + // Block packages read their own sections (e.g. `math`, `diagram`) from + // the same object; their types live with those packages. + [blockDictionary: string]: unknown; + }; }; export abstract class Exporter< B extends BlockSchema, @@ -54,6 +70,15 @@ export abstract class Exporter< public readonly options: ExporterOptions, ) {} + /** + * The strings this exporter renders into the produced document - the + * `exporter` section of the configured dictionary (the `dictionary` + * option of {@link ExporterOptions}), or the English defaults. + */ + public get dictionary(): Dictionary["exporter"] { + return this.options.dictionary?.exporter ?? en.exporter; + } + public async resolveFile(url: string) { if (!this.options?.resolveFileUrl) { return (await fetch(url)).blob(); @@ -67,17 +92,26 @@ export abstract class Exporter< public mapStyles(styles: Styles) { const stylesArray = Object.entries(styles).map(([key, value]) => { - const mappedStyle = this.mappings.styleMapping[key](value, this); + const mapping = this.mappings.styleMapping[key]; + if (!mapping) { + throw new Error( + `Exporter is missing a style mapping for style "${key}". If this style comes from a separate package, spread that package's exporter mappings into your styleMapping.`, + ); + } + const mappedStyle = mapping(value, this); return mappedStyle; }); return stylesArray; } public mapInlineContent(inlineContent: InlineContent) { - return this.mappings.inlineContentMapping[inlineContent.type]( - inlineContent, - this, - ); + const mapping = this.mappings.inlineContentMapping[inlineContent.type]; + if (!mapping) { + throw new Error( + `Exporter is missing an inline content mapping for inline content type "${inlineContent.type}". If this inline content comes from a separate package, spread that package's exporter mappings into your inlineContentMapping.`, + ); + } + return mapping(inlineContent, this); } public transformInlineContent(inlineContentArray: InlineContent[]) { @@ -92,12 +126,12 @@ export abstract class Exporter< numberedListIndex: number, children?: Array>, ) { - return this.mappings.blockMapping[block.type]( - block, - this, - nestingLevel, - numberedListIndex, - children, - ); + const mapping = this.mappings.blockMapping[block.type]; + if (!mapping) { + throw new Error( + `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, + ); + } + return mapping(block, this, nestingLevel, numberedListIndex, children); } } diff --git a/packages/core/src/exporter/index.ts b/packages/core/src/exporter/index.ts index e9d6a7bb03..8dcf4c2c2b 100644 --- a/packages/core/src/exporter/index.ts +++ b/packages/core/src/exporter/index.ts @@ -1,2 +1,3 @@ export * from "./Exporter.js"; +export * from "./ExportImage.js"; export * from "./mapping.js"; diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index fbb8beaf36..094671d920 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -399,6 +399,11 @@ export const ar: Dictionary = { formatting_change_by: (formats: string, users: string) => `تغيير التنسيق (${formats}) بواسطة: ${users}`, }, + exporter: { + open_file: "فتح الملف", + open_video_file: "فتح الفيديو", + open_audio_file: "فتح الصوت", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 04e42f9e8c..bf77a36a01 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -433,6 +433,11 @@ export const de: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formatierungsänderung (${formats}) von: ${users}`, }, + exporter: { + open_file: "Datei öffnen", + open_video_file: "Video öffnen", + open_audio_file: "Audio öffnen", + }, generic: { ctrl_shortcut: "Strg", }, diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index c7633e7c96..e5386f3020 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -414,6 +414,11 @@ export const en = { formatting_change_by: (formats: string, users: string) => `Formatting change (${formats}) by: ${users}`, }, + exporter: { + open_file: "Open file", + open_video_file: "Open video", + open_audio_file: "Open audio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 0d0ce9bc83..743a1be05c 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -412,6 +412,11 @@ export const es: Dictionary = { formatting_change_by: (formats: string, users: string) => `Cambio de formato (${formats}) por: ${users}`, }, + exporter: { + open_file: "Abrir archivo", + open_video_file: "Abrir vídeo", + open_audio_file: "Abrir audio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index b1af270287..6b2783ab68 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -383,6 +383,11 @@ export const fa = { formatting_change_by: (formats: string, users: string) => `تغییر قالب‌بندی (${formats}) توسط: ${users}`, }, + exporter: { + open_file: "باز کردن فایل", + open_video_file: "باز کردن ویدیو", + open_audio_file: "باز کردن صدا", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 3212988316..ad605db24a 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -460,6 +460,11 @@ export const fr: Dictionary = { formatting_change_by: (formats: string, users: string) => `Modification de mise en forme (${formats}) par : ${users}`, }, + exporter: { + open_file: "Ouvrir le fichier", + open_video_file: "Ouvrir la vidéo", + open_audio_file: "Ouvrir l'audio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index fd87ae1dee..4662a94202 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -414,6 +414,11 @@ export const he: Dictionary = { formatting_change_by: (formats: string, users: string) => `שינוי עיצוב (${formats}) על ידי: ${users}`, }, + exporter: { + open_file: "פתח קובץ", + open_video_file: "פתח וידאו", + open_audio_file: "פתח שמע", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index d3d2315d05..03eb016eed 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -428,6 +428,11 @@ export const hr: Dictionary = { formatting_change_by: (formats: string, users: string) => `Promjena oblikovanja (${formats}) od: ${users}`, }, + exporter: { + open_file: "Otvori datoteku", + open_video_file: "Otvori videozapis", + open_audio_file: "Otvori audiozapis", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index a3b0dc3065..913b2324b0 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -428,6 +428,11 @@ export const is: Dictionary = { formatting_change_by: (formats: string, users: string) => `Sniðbreyting (${formats}) af: ${users}`, }, + exporter: { + open_file: "Opna skrá", + open_video_file: "Opna myndband", + open_audio_file: "Opna hljóð", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 75ac864527..44be22c1bd 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -436,6 +436,11 @@ export const it: Dictionary = { formatting_change_by: (formats: string, users: string) => `Modifica formattazione (${formats}) da: ${users}`, }, + exporter: { + open_file: "Apri file", + open_video_file: "Apri video", + open_audio_file: "Apri audio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index d54e4a9d95..ead1f2fb30 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -454,6 +454,11 @@ export const ja: Dictionary = { formatting_change_by: (formats: string, users: string) => `書式の変更 (${formats}) 変更者: ${users}`, }, + exporter: { + open_file: "ファイルを開く", + open_video_file: "動画を開く", + open_audio_file: "音声を開く", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index 09e6217d94..2981ff1c36 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -427,6 +427,11 @@ export const ko: Dictionary = { formatting_change_by: (formats: string, users: string) => `서식 변경 (${formats}) 변경한 사람: ${users}`, }, + exporter: { + open_file: "파일 열기", + open_video_file: "동영상 열기", + open_audio_file: "오디오 열기", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index ce9354df28..da599e017c 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -415,6 +415,11 @@ export const nl: Dictionary = { formatting_change_by: (formats: string, users: string) => `Opmaakwijziging (${formats}) door: ${users}`, }, + exporter: { + open_file: "Bestand openen", + open_video_file: "Video openen", + open_audio_file: "Audio openen", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index f85a054af6..72efc096ed 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -432,6 +432,11 @@ export const no: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formateringsendring (${formats}) av: ${users}`, }, + exporter: { + open_file: "Åpne fil", + open_video_file: "Åpne video", + open_audio_file: "Åpne lyd", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index 4decff0380..d00039633c 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -405,6 +405,11 @@ export const pl: Dictionary = { formatting_change_by: (formats: string, users: string) => `Zmiana formatowania (${formats}) przez: ${users}`, }, + exporter: { + open_file: "Otwórz plik", + open_video_file: "Otwórz wideo", + open_audio_file: "Otwórz audio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index fe65e6cc8d..fe719ce023 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -407,6 +407,11 @@ export const pt: Dictionary = { formatting_change_by: (formats: string, users: string) => `Alteração de formatação (${formats}) por: ${users}`, }, + exporter: { + open_file: "Abrir arquivo", + open_video_file: "Abrir vídeo", + open_audio_file: "Abrir áudio", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index 7880c2b0f4..a4a7987dfc 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -458,6 +458,11 @@ export const ru: Dictionary = { formatting_change_by: (formats: string, users: string) => `Изменение форматирования (${formats}): ${users}`, }, + exporter: { + open_file: "Открыть файл", + open_video_file: "Открыть видео", + open_audio_file: "Открыть аудио", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index 5427978c33..4e73dc7eca 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -412,6 +412,11 @@ export const sk = { formatting_change_by: (formats: string, users: string) => `Zmena formátovania (${formats}) od: ${users}`, }, + exporter: { + open_file: "Otvoriť súbor", + open_video_file: "Otvoriť video", + open_audio_file: "Otvoriť zvuk", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index 0d722143d3..e9d379ac0b 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -438,6 +438,11 @@ export const uk: Dictionary = { formatting_change_by: (formats: string, users: string) => `Зміна форматування (${formats}) користувачем: ${users}`, }, + exporter: { + open_file: "Відкрити файл", + open_video_file: "Відкрити відео", + open_audio_file: "Відкрити аудіо", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 3c062dcaf6..13aee55a73 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -448,6 +448,11 @@ export const uz: Dictionary = { formatting_change_by: (formats: string, users: string) => `Formatlash o'zgarishi (${formats}), o'zgartirgan: ${users}`, }, + exporter: { + open_file: "Faylni ochish", + open_video_file: "Videoni ochish", + open_audio_file: "Audioni ochish", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index fb200b9350..8733fbf0ba 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -413,6 +413,11 @@ export const vi: Dictionary = { formatting_change_by: (formats: string, users: string) => `Thay đổi định dạng (${formats}) bởi: ${users}`, }, + exporter: { + open_file: "Mở tệp", + open_video_file: "Mở video", + open_audio_file: "Mở âm thanh", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index fe3d6b9340..5ac37a80c7 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -455,6 +455,11 @@ export const zhTW: Dictionary = { formatting_change_by: (formats: string, users: string) => `格式變更(${formats}),變更者:${users}`, }, + exporter: { + open_file: "開啟檔案", + open_video_file: "開啟影片", + open_audio_file: "開啟音訊", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 9cf86bcd6b..3f4c90bb56 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -455,6 +455,11 @@ export const zh: Dictionary = { formatting_change_by: (formats: string, users: string) => `格式更改(${formats}),更改者:${users}`, }, + exporter: { + open_file: "打开文件", + open_video_file: "打开视频", + open_audio_file: "打开音频", + }, generic: { ctrl_shortcut: "Ctrl", }, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8ed90667c0..8d7e203e61 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -375,6 +375,24 @@ export type PartialPlainContent = | string | (string | (StyledText<{}> & { styles: Record }))[]; +/** + * The text of a block's `"plain"` content (e.g. a code block's source code). + * Accepts the partial form too: block render/export paths can receive + * `PartialBlock`s (e.g. the HTML serializers take them directly), where + * plain content may still be the bare-string sugar. + */ +export function plainContentToString( + content: PlainContent | PartialPlainContent, +): string { + if (typeof content === "string") { + return content; + } + + return content + .map((item) => (typeof item === "string" ? item : item.text)) + .join(""); +} + // A BlockConfig has all the information to get the type of a Block (which is a specific instance of the BlockConfig. // i.e.: paragraphConfig: BlockConfig defines what a "paragraph" is / supports, and BlockFromConfigNoChildren is the shape of a specific paragraph block. // (for internal use) diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index effbc69af8..bcc670fe88 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -8,7 +8,6 @@ import { ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; - export type CustomInlineContentConfig = { type: string; content: "styled" | "none" | "plain"; diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index 3c28d50771..df0267f093 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -7,6 +7,12 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { User } from "../../user/index.js"; import { AttributionExtension } from "./AttributionExtension.js"; +// Editors created during a test, destroyed in afterEach: an undestroyed +// EditorView leaves ProseMirror DOMObserver timers behind, which fire after +// the jsdom environment is torn down ("document is not defined" as an +// unhandled error - flaky, timing-dependent, mostly on slow CI). +const editors: BlockNoteEditor[] = []; + // A `resolveUsers` spy plus an editor with the AttributionExtension registered. // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. @@ -26,6 +32,7 @@ function createEditor() { extensions: [AttributionExtension({ resolveUsers })], }); editor.mount(document.createElement("div")); + editors.push(editor); return { editor, resolveUsers }; } @@ -47,6 +54,9 @@ function addInsertMark(editor: BlockNoteEditor, userIds: string[]) { describe("AttributionExtension user loading", () => { afterEach(() => { + for (const editor of editors.splice(0)) { + editor._tiptapEditor.destroy(); + } vi.restoreAllMocks(); }); diff --git a/packages/dev-scripts/examples/template-react/tsconfig.json.template.tsx b/packages/dev-scripts/examples/template-react/tsconfig.json.template.tsx index e71e32e5a2..115cbeabfd 100644 --- a/packages/dev-scripts/examples/template-react/tsconfig.json.template.tsx +++ b/packages/dev-scripts/examples/template-react/tsconfig.json.template.tsx @@ -1,4 +1,6 @@ -const template = () => ({ +import type { Project } from "../util"; + +const template = (project: Project) => ({ __comment: "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", compilerOptions: { target: "ESNext", @@ -16,6 +18,11 @@ const template = () => ({ noEmit: true, jsx: "react-jsx", composite: true, + // The repo-wide alias for the shared test-utils package, for examples + // that depend on it (private, so it only resolves inside the monorepo). + ...(project.config.dependencies?.["@blocknote/shared"] + ? { paths: { "@shared/*": ["../../../shared/*"] } } + : {}), }, include: ["."], __ADD_FOR_LOCAL_DEV_references: [ diff --git a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx index b758ff12f6..79d4f31e75 100644 --- a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx +++ b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx @@ -25,7 +25,15 @@ export default defineConfig(((conf: { command: string }) => ({ conf.command === "build" || !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) ? {} - : ({ + : ({${ + project.config.dependencies?.["@blocknote/shared"] + ? ` + // The repo-wide alias for the shared test-utils package this + // example depends on (private, so it only resolves inside the + // monorepo). + "@shared": path.resolve(__dirname, "../../../shared/"),` + : "" + } // Comment out the lines below to load a built version of blocknote // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( diff --git a/packages/diagram-block/package.json b/packages/diagram-block/package.json index ff777e784c..0d0c08b8d9 100644 --- a/packages/diagram-block/package.json +++ b/packages/diagram-block/package.json @@ -44,6 +44,26 @@ "types": "./types/src/index.d.ts", "import": "./dist/blocknote-diagram-block.js", "require": "./dist/blocknote-diagram-block.cjs" + }, + "./docx-exporter": { + "types": "./types/src/docx-exporter/index.d.ts", + "import": "./dist/docx-exporter.js", + "require": "./dist/docx-exporter.cjs" + }, + "./odt-exporter": { + "types": "./types/src/odt-exporter/index.d.ts", + "import": "./dist/odt-exporter.js", + "require": "./dist/odt-exporter.cjs" + }, + "./pdf-exporter": { + "types": "./types/src/pdf-exporter/index.d.ts", + "import": "./dist/pdf-exporter.js", + "require": "./dist/pdf-exporter.cjs" + }, + "./email-exporter": { + "types": "./types/src/email-exporter/index.d.ts", + "import": "./dist/email-exporter.js", + "require": "./dist/email-exporter.cjs" } }, "scripts": { @@ -59,10 +79,21 @@ "devDependencies": { "@blocknote/react": "workspace:^", "react-icons": "^5.5.0", + "@blocknote/shared": "workspace:^", + "@blocknote/xl-docx-exporter": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", + "@blocknote/xl-multi-column": "workspace:^", + "@blocknote/xl-odt-exporter": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-email/components": "^1.0.12", + "@react-pdf/renderer": "^4.5.1", "@types/react": "^19.2.3", + "@zip.js/zip.js": "^2.8.8", "@types/react-dom": "^19.2.3", + "docx": "^9.6.1", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-element-to-jsx-string": "^17.0.1", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", @@ -71,7 +102,37 @@ "peerDependencies": { "@blocknote/core": "workspace:^", "@blocknote/react": "workspace:^", + "@blocknote/xl-docx-exporter": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", + "@blocknote/xl-odt-exporter": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-email/components": "^1.0.12", + "@react-pdf/renderer": "^4.5.1", + "docx": "^9.6.1", "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + }, + "peerDependenciesMeta": { + "@blocknote/xl-docx-exporter": { + "optional": true + }, + "@blocknote/xl-email-exporter": { + "optional": true + }, + "@blocknote/xl-odt-exporter": { + "optional": true + }, + "@blocknote/xl-pdf-exporter": { + "optional": true + }, + "@react-email/components": { + "optional": true + }, + "@react-pdf/renderer": { + "optional": true + }, + "docx": { + "optional": true + } } } diff --git a/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx b/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx index fd6e02f4e5..713aaa8618 100644 --- a/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx +++ b/packages/diagram-block/src/block/helpers/render/DiagramBlockPreviewWithPopup.tsx @@ -7,7 +7,7 @@ import mermaid from "mermaid"; import { useEffect, useState } from "react"; import { SiMermaid } from "react-icons/si"; -import { getDiagramPlainTextContent } from "../../../helpers/getDiagramPlainTextContent.js"; +import { plainContentToString } from "@blocknote/core"; import { initializeMermaid } from "../../../helpers/initializeMermaid.js"; import { trimDiagramSVG } from "../../../helpers/trimDiagramSVG.js"; import { getDiagramDictionary } from "../../../i18n/dictionary.js"; @@ -69,7 +69,7 @@ export const useMermaidSVG = (source: string) => { export const DiagramBlockPreviewWithPopup = ( props: ReactCustomBlockRenderProps, ) => { - const source = getDiagramPlainTextContent(props.block.content).trim(); + const source = plainContentToString(props.block.content).trim(); const { svg, error } = useMermaidSVG(source); const dict = getDiagramDictionary(props.editor).block; diff --git a/packages/diagram-block/src/docx-exporter/docxExporter.test.ts b/packages/diagram-block/src/docx-exporter/docxExporter.test.ts new file mode 100644 index 0000000000..3c293f60ba --- /dev/null +++ b/packages/diagram-block/src/docx-exporter/docxExporter.test.ts @@ -0,0 +1,216 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { en } from "@blocknote/core/locales"; +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from "@blocknote/xl-docx-exporter"; +import { BlobReader, ZipReader } from "@zip.js/zip.js"; +import { Packer } from "docx"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +import { + diagramDocument, + renderInvalidDiagram, + zipEntryContent, +} from "../exporterTestUtil.js"; +import { createDiagramBlockMapping, diagramBlockMapping } from "./index.js"; + +beforeAll(async () => { + // @ts-expect-error - Blob polyfill for Node test environment + globalThis.Blob = (await import("node:buffer")).Blob; +}); + +function createExporter( + diagram: ReturnType, + options?: ConstructorParameters[2], +) { + return new DOCXExporter( + BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }), + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + diagram, + }, + } as any, + options, + ); +} + +const documentOptions = { + sectionOptions: {}, + documentOptions: {}, + locale: "en-US", +} as any; + +describe("docx exporter mappings", () => { + it("should render an error placeholder for invalid sources", async () => { + // The renderer returns invalid sources as a typed error, and the + // mapping renders the error placeholder, identifying the diagram by the + // source's first line. + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram: renderInvalidDiagram }), + ); + + const doc = await exporter.toDocxJsDocument( + diagramDocument, + documentOptions, + ); + const documentXML = await zipEntryContent( + await Packer.toBlob(doc), + "word/document.xml", + ); + + expect(documentXML).toContain("Invalid diagram"); + expect(documentXML).toContain("graph TD"); + expect(documentXML).not.toContain("w:drawing"); + }); + + it("should throw a descriptive error without a renderer outside the browser", async () => { + // The built-in Mermaid renderer can't work here, and silently degrading + // is worse than failing loudly - the error names the `renderDiagram` + // option to pass. + const exporter = createExporter(diagramBlockMapping); + + await expect( + exporter.toDocxJsDocument(diagramDocument, documentOptions), + ).rejects.toThrow("pass a `renderDiagram` function"); + }); + + it("should render empty diagrams as an empty paragraph", async () => { + // Empty source isn't an error - there's just nothing to render (and the + // renderer is never invoked, so no browser is needed). + const exporter = createExporter(diagramBlockMapping); + + const doc = await exporter.toDocxJsDocument( + [ + { + id: "1", + type: "diagram", + props: {}, + content: [], + children: [], + }, + ] as any, + documentOptions, + ); + const documentXML = await zipEntryContent( + await Packer.toBlob(doc), + "word/document.xml", + ); + + expect(documentXML).not.toContain("Invalid diagram"); + expect(documentXML).not.toContain("w:drawing"); + }); + + it("should embed the image with the renderer's actual format", async () => { + // Renderers aren't required to produce PNGs - the embed must carry the + // format the image declares. + const exporter = createExporter( + createDiagramBlockMapping({ + renderDiagram: async () => ({ + image: { + mimeType: "image/jpeg", + data: new Uint8Array([0, 0, 0]), + width: 100, + height: 50, + }, + }), + }), + ); + + const doc = await exporter.toDocxJsDocument( + diagramDocument, + documentOptions, + ); + const entries = await new ZipReader( + new BlobReader(await Packer.toBlob(doc)), + ).getEntries(); + + expect( + entries.some((entry) => /media\/.*\.jpe?g$/.test(entry.filename)), + ).toBe(true); + }); + + it("should throw when the renderer produces a format DOCX can't embed", async () => { + // An unknown format is a renderer contract violation - mislabeling the + // bytes would corrupt the document, so it fails loudly instead. + const exporter = createExporter( + createDiagramBlockMapping({ + renderDiagram: async () => ({ + image: { + mimeType: "image/webp", + data: new Uint8Array([0, 0, 0]), + width: 100, + height: 50, + }, + }), + }), + ); + + await expect( + exporter.toDocxJsDocument(diagramDocument, documentOptions), + ).rejects.toThrow('renderer produced "image/webp"'); + }); + + it("should scale wide diagrams down to the page width", async () => { + // Word clips images wider than the body area at the right margin, so + // the display size is clamped (1200x600 -> 600x300; EMU = px * 9525). + const exporter = createExporter( + createDiagramBlockMapping({ + renderDiagram: async () => ({ + image: { + mimeType: "image/png", + data: new Uint8Array([0, 0, 0]), + width: 1200, + height: 600, + }, + }), + }), + ); + + const doc = await exporter.toDocxJsDocument( + diagramDocument, + documentOptions, + ); + const documentXML = await zipEntryContent( + await Packer.toBlob(doc), + "word/document.xml", + ); + + expect(documentXML).toContain('cx="5715000"'); + expect(documentXML).toContain('cy="2857500"'); + }); + + it("should render placeholders from the configured dictionary", async () => { + // Exporter strings are never hardcoded: the placeholder comes from the + // `diagram` section of the exporter's dictionary, exactly as it would + // from an editor dictionary (bundled English when not configured). + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram: renderInvalidDiagram }), + { + dictionary: { + ...en, + diagram: { + exporter: { + invalid_diagram: (source: string) => + `Ungültiges Diagramm „${source}"`, + }, + }, + }, + }, + ); + + const doc = await exporter.toDocxJsDocument( + diagramDocument, + documentOptions, + ); + const documentXML = await zipEntryContent( + await Packer.toBlob(doc), + "word/document.xml", + ); + + expect(documentXML).toContain("Ungültiges Diagramm"); + expect(documentXML).not.toContain("Invalid diagram"); + }); +}); diff --git a/packages/diagram-block/src/docx-exporter/index.ts b/packages/diagram-block/src/docx-exporter/index.ts new file mode 100644 index 0000000000..6775ad09a4 --- /dev/null +++ b/packages/diagram-block/src/docx-exporter/index.ts @@ -0,0 +1,139 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { plainContentToString } from "@blocknote/core"; +import { AlignmentType, ImageRun, Paragraph, TextRun } from "docx"; + +import { + RenderDiagram, + renderDiagramToImage, +} from "../helpers/renderDiagramToImage.js"; +import { getDiagramExporterDictionary } from "../i18n/dictionary.js"; + +export type { RenderDiagram } from "../helpers/renderDiagramToImage.js"; + +const MAX_WIDTH_PIXELS = 600; + +type DiagramBlock = BlockFromConfigNoChildren< + BlockConfig<"diagram", {}, "plain">, + any, + any +>; + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the diagram by the (first line of the) source. +// The parser's message is deliberately NOT rendered: it's authoring detail +// (and untranslated English) - the editor is where the author sees and +// fixes it. +function errorParagraph( + exporter: Exporter, + source: string, +) { + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: getDiagramExporterDictionary(exporter).invalid_diagram( + source.split("\n")[0], + ), + italics: true, + color: "999999", + }), + ], + }); +} + +/** + * Creates a DOCX block mapping for `@blocknote/diagram-block` that embeds + * diagrams as images. Rendering runs in the browser by default (Mermaid + * can't render outside of it); when exporting elsewhere (e.g. server-side), + * pass a `renderDiagram` function backed by e.g. `@mermaid-js/mermaid-cli` + * or a Kroki server. Invalid sources render an error placeholder (mirroring + * the editor): + * + * ```ts + * import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter"; + * + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * blockMapping: { + * ...docxDefaultSchemaMappings.blockMapping, + * diagram: createDiagramBlockMapping({ renderDiagram }), + * }, + * }); + * ``` + */ +export function createDiagramBlockMapping(options?: { + renderDiagram?: RenderDiagram; +}) { + return async ( + block: DiagramBlock, + exporter: Exporter, + ) => { + const source = plainContentToString(block.content); + if (!source.trim()) { + return new Paragraph({}); + } + + const renderDiagram = + options?.renderDiagram ?? + (typeof document !== "undefined" ? renderDiagramToImage : undefined); + if (!renderDiagram) { + throw new Error( + "Rendering diagrams to images requires a browser. When exporting elsewhere, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by @mermaid-js/mermaid-cli or a Kroki server).", + ); + } + + const result = await renderDiagram(source); + if (result.error !== undefined) { + return errorParagraph(exporter, source); + } + + // Plugged-in renderers aren't required to produce PNGs; embed with the + // raster format the image declares. An unknown format is a renderer + // contract violation - mislabeling the bytes would corrupt the document, + // so it propagates as an error instead. + const imageTypes = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/bmp": "bmp", + } as const; + const imageType = + imageTypes[result.image.mimeType as keyof typeof imageTypes]; + if (!imageType) { + throw new Error( + `DOCX embeds support png/jpeg/gif/bmp diagram images, but the renderer produced "${result.image.mimeType}".`, + ); + } + + // A DOCX body is ~624px wide with default margins; Word clips wider + // images at the right margin, so scale the display size down to fit + // (the image data keeps its full resolution). + const displayWidth = Math.min(result.image.width, MAX_WIDTH_PIXELS); + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new ImageRun({ + data: result.image.data, + type: imageType, + transformation: { + width: displayWidth, + height: Math.round( + (displayWidth / result.image.width) * result.image.height, + ), + }, + }), + ], + }); + }; +} + +/** + * DOCX block mapping for `@blocknote/diagram-block` with the default options + * - see {@link createDiagramBlockMapping}. Browser-only; when exporting + * elsewhere, use the factory to pass a `renderDiagram` function. + */ +export const diagramBlockMapping = createDiagramBlockMapping(); diff --git a/packages/diagram-block/src/email-exporter/emailExporter.test.tsx b/packages/diagram-block/src/email-exporter/emailExporter.test.tsx new file mode 100644 index 0000000000..9676f6944c --- /dev/null +++ b/packages/diagram-block/src/email-exporter/emailExporter.test.tsx @@ -0,0 +1,78 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + createCIDImageDelivery, + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { describe, expect, it } from "vite-plus/test"; + +import { + diagramDocument, + renderDiagram, + renderInvalidDiagram, +} from "../exporterTestUtil.js"; +import { createDiagramBlockMapping, diagramBlockMapping } from "./index.js"; + +function createExporter(diagram: ReturnType) { + return new ReactEmailExporter( + BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }), + { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + diagram, + }, + } as any, + ); +} + +describe("email exporter mappings", () => { + it("should embed the rendered diagram as a data URL image", async () => { + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram }), + ); + + const html = await exporter.toReactEmailDocument(diagramDocument); + + expect(html).toContain('src="data:image/png;base64,'); + // The Mermaid source stays available as the alt text. + expect(html).toContain('alt="graph TD'); + }); + + it("should throw a descriptive error without a renderer outside the browser", async () => { + const exporter = createExporter(diagramBlockMapping); + + await expect( + exporter.toReactEmailDocument(diagramDocument), + ).rejects.toThrow("pass a `renderDiagram` function"); + }); + + it("should render an error placeholder for invalid sources", async () => { + // The renderer returns invalid sources as a typed error, and the + // mapping renders the error placeholder - never the raw source. + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram: renderInvalidDiagram }), + ); + + const html = await exporter.toReactEmailDocument(diagramDocument); + + expect(html).toContain("Invalid diagram"); + expect(html).toContain("graph TD"); + expect(html).not.toContain(" { + const imageDelivery = createCIDImageDelivery(); + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram, imageDelivery }), + ); + + const html = await exporter.toReactEmailDocument(diagramDocument); + + expect(html).toContain('src="cid:diagram-1@blocknote"'); + // The Mermaid source stays available as the alt text. + expect(html).toContain('alt="graph TD'); + expect(imageDelivery.attachments).toHaveLength(1); + expect(imageDelivery.attachments[0].contentType).toBe("image/png"); + }); +}); diff --git a/packages/diagram-block/src/email-exporter/index.tsx b/packages/diagram-block/src/email-exporter/index.tsx new file mode 100644 index 0000000000..4d99388b40 --- /dev/null +++ b/packages/diagram-block/src/email-exporter/index.tsx @@ -0,0 +1,117 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { plainContentToString } from "@blocknote/core"; +import { + dataURLImageDelivery, + ReactEmailImageDelivery, +} from "@blocknote/xl-email-exporter"; +import { Img, Text } from "@react-email/components"; + +import { + RenderDiagram, + renderDiagramToImage, +} from "../helpers/renderDiagramToImage.js"; +import { getDiagramExporterDictionary } from "../i18n/dictionary.js"; + +export type { RenderDiagram } from "../helpers/renderDiagramToImage.js"; + +type DiagramBlock = BlockFromConfigNoChildren< + BlockConfig<"diagram", {}, "plain">, + any, + any +>; + +// Emails render in containers around 600px wide. +const MAX_WIDTH_PIXELS = 600; + +/** + * Creates an email block mapping for `@blocknote/diagram-block` that embeds + * diagrams as images, with the Mermaid source as the alt text. Rendering + * runs in the browser by default (Mermaid can't render outside of it); when + * exporting elsewhere (e.g. server-side email rendering), pass a + * `renderDiagram` function backed by e.g. `@mermaid-js/mermaid-cli` or a + * Kroki server. Images are embedded as data URLs by default; pass an + * `imageDelivery` (e.g. `createCIDImageDelivery` from + * `@blocknote/xl-email-exporter`) to deliver them as inline `cid:` + * attachments instead, which more email clients display. Invalid sources + * render an error placeholder (mirroring the editor): + * + * ```ts + * import { createDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter"; + * + * new ReactEmailExporter(schema, { + * ...reactEmailDefaultSchemaMappings, + * blockMapping: { + * ...reactEmailDefaultSchemaMappings.blockMapping, + * diagram: createDiagramBlockMapping({ renderDiagram, imageDelivery }), + * }, + * }); + * ``` + */ +export function createDiagramBlockMapping(options?: { + renderDiagram?: RenderDiagram; + imageDelivery?: ReactEmailImageDelivery; +}) { + return async ( + block: DiagramBlock, + exporter: Exporter, + ) => { + const source = plainContentToString(block.content); + if (!source.trim()) { + return ; + } + + const renderDiagram = + options?.renderDiagram ?? + (typeof document !== "undefined" ? renderDiagramToImage : undefined); + if (!renderDiagram) { + throw new Error( + "Rendering diagrams to images requires a browser. When exporting elsewhere, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by @mermaid-js/mermaid-cli or a Kroki server).", + ); + } + + const result = await renderDiagram(source); + if (result.error !== undefined) { + // Mirrors the editor, which shows the error state in the preview + // placeholder, identifying the diagram by the (first line of the) + // source. The parser's message is deliberately NOT rendered: it's + // authoring detail (and untranslated English) - the editor is where + // the author sees and fixes it. + return ( + + {getDiagramExporterDictionary(exporter).invalid_diagram( + source.split("\n")[0], + )} + + ); + } + + const displayWidth = Math.min(result.image.width, MAX_WIDTH_PIXELS); + const src = (options?.imageDelivery ?? dataURLImageDelivery).deliver({ + ...result.image, + name: "diagram", + }); + + return ( + {source} + ); + }; +} + +/** + * Email block mapping for `@blocknote/diagram-block` with the default + * options - see {@link createDiagramBlockMapping}. Browser-only; when + * exporting elsewhere, use the factory to pass a `renderDiagram` function. + */ +export const diagramBlockMapping = createDiagramBlockMapping(); diff --git a/packages/diagram-block/src/exporterTestUtil.ts b/packages/diagram-block/src/exporterTestUtil.ts new file mode 100644 index 0000000000..9757a4cffc --- /dev/null +++ b/packages/diagram-block/src/exporterTestUtil.ts @@ -0,0 +1,51 @@ +import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; + +import type { RenderDiagram } from "./helpers/renderDiagramToImage.js"; + +export const diagramDocument = [ + { + id: "1", + type: "diagram", + props: {}, + content: [ + { type: "text", text: "graph TD\n A[Start] --> B[End]", styles: {} }, + ], + children: [], + }, +] as any; + +// A real (1x1 transparent) PNG: some export paths probe the image bytes for +// metadata, so stub images must be actual PNGs. +export const pngBytes = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + ), + (char) => char.charCodeAt(0), +); + +// A stub renderer, standing in for e.g. mermaid-cli on a server - the real +// (browser-only) Mermaid rendering is covered by its `.browser.test`. +export const renderDiagram: RenderDiagram = async () => ({ + image: { + mimeType: "image/png", + data: pngBytes, + width: 100, + height: 50, + }, +}); + +// A renderer reporting the expected failure: invalid Mermaid source. +export const renderInvalidDiagram: RenderDiagram = async () => ({ + error: "No diagram type detected", +}); + +export async function zipEntryContent( + zip: globalThis.Blob, + filename: string, +): Promise { + const entries = await new ZipReader(new BlobReader(zip)).getEntries(); + const entry = entries.find((e) => e.filename === filename && !e.directory) as + | FileEntry + | undefined; + return entry ? await entry.getData(new TextWriter()) : ""; +} diff --git a/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts b/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts deleted file mode 100644 index d39f42cf30..0000000000 --- a/packages/diagram-block/src/helpers/getDiagramPlainTextContent.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Converts rich text content in diagram blocks to the plain text Mermaid -// source. Should be removed once we add plain text support for blocks. -export const getDiagramPlainTextContent = (content: unknown): string => { - if (!Array.isArray(content)) { - return ""; - } - - return content - .map((node) => - node && typeof node === "object" && "text" in node ? node.text : "", - ) - .join(""); -}; -// TODO: remove diff --git a/packages/diagram-block/src/helpers/index.ts b/packages/diagram-block/src/helpers/index.ts index 7358cb6ac5..b50f640471 100644 --- a/packages/diagram-block/src/helpers/index.ts +++ b/packages/diagram-block/src/helpers/index.ts @@ -1,4 +1,3 @@ -export * from "./getDiagramPlainTextContent.js"; export * from "./initializeMermaid.js"; export * from "./renderDiagramToImage.js"; export * from "./trimDiagramSVG.js"; diff --git a/packages/diagram-block/src/helpers/renderDiagramToImage.browser.test.ts b/packages/diagram-block/src/helpers/renderDiagramToImage.browser.test.ts new file mode 100644 index 0000000000..8727efb465 --- /dev/null +++ b/packages/diagram-block/src/helpers/renderDiagramToImage.browser.test.ts @@ -0,0 +1,48 @@ +import { exportImageToDataURL } from "@blocknote/core"; +import { decodeAndSample } from "@shared/util/browserImageTestUtil.js"; +import { describe, expect, test } from "vite-plus/test"; + +import { renderDiagramToImage } from "./renderDiagramToImage.js"; + +// Browser unit tests for the browser-only Mermaid renderer - the +// `RenderDiagram` implementation that the (node) unit suites replace with +// stubs. Runs in the tests package's browser suite. +describe("renderDiagramToImage", () => { + test( + "renders Mermaid source to a non-blank PNG", + { timeout: 15000 }, + async () => { + const result = await renderDiagramToImage( + "graph TD\n A[Start] --> B[End]", + ); + if (result.error !== undefined) { + throw new Error(`Expected a successful render: ${result.error}`); + } + + expect(result.image.mimeType).toBe("image/png"); + expect(result.image.width).toBeGreaterThan(0); + const { inkedPixels, inkedFractionX, inkedFractionY } = + await decodeAndSample(exportImageToDataURL(result.image)); + expect(inkedPixels).toBeGreaterThan(0); + // The diagram must fill the image, not sit letterboxed in a fraction + // of it - Mermaid crops the view box to the content bar ~8px padding, + // so a healthy render inks ~0.85+ of the canvas. (WebKit regression + // check: it lets Mermaid's inline max-width style shrink the + // rasterization to ~half if the renderer doesn't strip it.) + expect(inkedFractionX).toBeGreaterThan(0.75); + expect(inkedFractionY).toBeGreaterThan(0.75); + }, + ); + + test( + "returns invalid Mermaid source as a typed error", + { timeout: 15000 }, + async () => { + // The real Mermaid parse boundary: invalid source is expected (it's + // user input), so it comes back as a typed error rather than a throw. + const result = await renderDiagramToImage("not a valid diagram !!"); + + expect(result.error).toBeDefined(); + }, + ); +}); diff --git a/packages/diagram-block/src/helpers/renderDiagramToImage.ts b/packages/diagram-block/src/helpers/renderDiagramToImage.ts index b75aced1ef..b3965d88b3 100644 --- a/packages/diagram-block/src/helpers/renderDiagramToImage.ts +++ b/packages/diagram-block/src/helpers/renderDiagramToImage.ts @@ -1,23 +1,52 @@ +import type { ExportImage } from "@blocknote/core"; import mermaid from "mermaid"; import { initializeMermaid } from "./initializeMermaid.js"; +/** + * Renders Mermaid source to an {@link ExportImage} (with the diagram's + * natural dimensions in pixels). Invalid sources are expected (they're user + * input), so they're returned as a typed error - with a message safe to show + * to readers - rather than thrown; unexpected failures (environment, + * renderer infrastructure) throw. The default implementation + * ({@link renderDiagramToImage}) needs a browser - Mermaid can't render + * outside of it; exporters running elsewhere plug in their own (e.g. backed + * by `@mermaid-js/mermaid-cli` or a Kroki server). + */ +export type RenderDiagram = ( + source: string, +) => Promise<{ error?: undefined; image: ExportImage } | { error: string }>; + // Each render call needs its own element ID (Mermaid removes any existing // document element with the given ID when rendering). let exportElementId = 0; +// Wrap the renderer to trade sharpness for size: +// `(source) => renderDiagramToImage(source, 4)`. +const DEFAULT_RASTER_SCALE = 2; + /** - * Renders the Mermaid source to a PNG image (as a data URL, with the - * diagram's natural dimensions in pixels), e.g. to embed diagrams as images - * when exporting documents to PDF/DOCX/ODT. Throws when the source is - * invalid. Browser-only - Mermaid can't render outside of it. + * Renders the Mermaid source to a PNG {@link ExportImage} (with the + * diagram's natural dimensions in pixels), rasterized at `scale` times that + * size so it stays sharp in the exported document - e.g. to embed diagrams + * as images when exporting documents to PDF/DOCX/ODT. Invalid sources are + * returned as a typed error. Browser-only - Mermaid can't render outside of + * it. */ -export const renderDiagramToImage = async ( +export async function renderDiagramToImage( source: string, -): Promise<{ dataURL: string; width: number; height: number }> => { + scale: number = DEFAULT_RASTER_SCALE, +): ReturnType { initializeMermaid(); - await mermaid.parse(source); + try { + await mermaid.parse(source); + } catch (error) { + // The boundary that converts Mermaid's parse throw into the typed + // result. + return { error: error instanceof Error ? error.message : String(error) }; + } + const { svg } = await mermaid.render( `diagram-export-${exportElementId++}`, source, @@ -38,12 +67,18 @@ export const renderDiagramToImage = async ( const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+/).map(Number); const width = Math.ceil(viewBox?.[2] || 800); const height = Math.ceil(viewBox?.[3] || 600); - // Sized at 2x so the diagram stays sharp in the exported document. The 2x - // goes into the SVG's dimensions (rather than only the canvas), so browsers - // rasterize the vector at the full canvas resolution instead of upscaling a - // 1x raster. - svgElement.setAttribute("width", String(width * 2)); - svgElement.setAttribute("height", String(height * 2)); + // The scale goes into the SVG's dimensions (rather than only the canvas), + // so browsers rasterize the vector at the full canvas resolution instead + // of upscaling a 1x raster. + svgElement.setAttribute("width", String(width * scale)); + svgElement.setAttribute("height", String(height * scale)); + // Mermaid also caps the root with an inline `max-width` style (for + // responsive display in the editor). CSS wins over presentation attributes + // when computing the SVG's intrinsic size, and WebKit honors it during + // canvas rasterization - leaving it in renders the diagram letterboxed at + // a fraction of the canvas. The explicit dimensions above are the ones + // that must be authoritative here. + svgElement.removeAttribute("style"); const sizedSVG = new XMLSerializer().serializeToString(svgElement); const image = new Image(); @@ -51,9 +86,23 @@ export const renderDiagramToImage = async ( await image.decode(); const canvas = document.createElement("canvas"); - canvas.width = width * 2; - canvas.height = height * 2; + canvas.width = width * scale; + canvas.height = height * scale; canvas.getContext("2d")!.drawImage(image, 0, 0, canvas.width, canvas.height); - return { dataURL: canvas.toDataURL("image/png"), width, height }; -}; + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) { + throw new Error("Canvas produced no PNG data"); + } + + return { + image: { + mimeType: "image/png", + data: new Uint8Array(await blob.arrayBuffer()), + width, + height, + }, + }; +} diff --git a/packages/diagram-block/src/i18n/dictionary.ts b/packages/diagram-block/src/i18n/dictionary.ts index 4bc38a5b6f..b32e94f4f7 100644 --- a/packages/diagram-block/src/i18n/dictionary.ts +++ b/packages/diagram-block/src/i18n/dictionary.ts @@ -1,4 +1,4 @@ -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, Exporter } from "@blocknote/core"; import { en } from "./locales/en.js"; @@ -17,3 +17,22 @@ export function getDiagramDictionary( ((editor.dictionary as any).diagram as DiagramDictionary | undefined) ?? en ); } + +/** + * Returns the Diagram exporter strings. Exporters are localized independently + * of an editor: the host passes a dictionary to the exporter's options + * (see `ExporterOptions.dictionary`), and the diagram strings are read from + * its `diagram` section - the same shape merged into editor dictionaries - + * falling back to the bundled English strings. + */ +export function getDiagramExporterDictionary( + exporter: Exporter, +): DiagramDictionary["exporter"] { + return ( + ( + (exporter.options.dictionary as any)?.diagram as + | DiagramDictionary + | undefined + )?.exporter ?? en.exporter + ); +} diff --git a/packages/diagram-block/src/i18n/locales/ar.ts b/packages/diagram-block/src/i18n/locales/ar.ts index 7c13eb0860..f6620d1a00 100644 --- a/packages/diagram-block/src/i18n/locales/ar.ts +++ b/packages/diagram-block/src/i18n/locales/ar.ts @@ -18,4 +18,8 @@ export const ar: DiagramDictionary = { block_type_select: { name: "مخطط", }, + exporter: { + invalid_diagram: (source: string) => + `مخطط غير صالح "\u2068${source}\u2069"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/de.ts b/packages/diagram-block/src/i18n/locales/de.ts index dfe21c8794..45d76b9255 100644 --- a/packages/diagram-block/src/i18n/locales/de.ts +++ b/packages/diagram-block/src/i18n/locales/de.ts @@ -18,4 +18,7 @@ export const de: DiagramDictionary = { block_type_select: { name: "Diagramm", }, + exporter: { + invalid_diagram: (source: string) => `Ungültiges Diagramm "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/en.ts b/packages/diagram-block/src/i18n/locales/en.ts index 11cd7d0453..2ac6631897 100644 --- a/packages/diagram-block/src/i18n/locales/en.ts +++ b/packages/diagram-block/src/i18n/locales/en.ts @@ -16,4 +16,7 @@ export const en = { block_type_select: { name: "Diagram", }, + exporter: { + invalid_diagram: (source: string) => `Invalid diagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/es.ts b/packages/diagram-block/src/i18n/locales/es.ts index 08723ae844..0ab6270aa3 100644 --- a/packages/diagram-block/src/i18n/locales/es.ts +++ b/packages/diagram-block/src/i18n/locales/es.ts @@ -18,4 +18,7 @@ export const es: DiagramDictionary = { block_type_select: { name: "Diagrama", }, + exporter: { + invalid_diagram: (source: string) => `Diagrama no válido "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/fa.ts b/packages/diagram-block/src/i18n/locales/fa.ts index b8d580fdf9..31909ee3da 100644 --- a/packages/diagram-block/src/i18n/locales/fa.ts +++ b/packages/diagram-block/src/i18n/locales/fa.ts @@ -18,4 +18,8 @@ export const fa: DiagramDictionary = { block_type_select: { name: "نمودار", }, + exporter: { + invalid_diagram: (source: string) => + `نمودار نامعتبر "\u2068${source}\u2069"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/fr.ts b/packages/diagram-block/src/i18n/locales/fr.ts index 64e7020a2e..ff1e34eec5 100644 --- a/packages/diagram-block/src/i18n/locales/fr.ts +++ b/packages/diagram-block/src/i18n/locales/fr.ts @@ -18,4 +18,7 @@ export const fr: DiagramDictionary = { block_type_select: { name: "Diagramme", }, + exporter: { + invalid_diagram: (source: string) => `Diagramme non valide "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/he.ts b/packages/diagram-block/src/i18n/locales/he.ts index 9fe4073860..836d11277a 100644 --- a/packages/diagram-block/src/i18n/locales/he.ts +++ b/packages/diagram-block/src/i18n/locales/he.ts @@ -18,4 +18,8 @@ export const he: DiagramDictionary = { block_type_select: { name: "תרשים", }, + exporter: { + invalid_diagram: (source: string) => + `תרשים לא חוקי "\u2068${source}\u2069"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/hr.ts b/packages/diagram-block/src/i18n/locales/hr.ts index daaea7a261..18bc215220 100644 --- a/packages/diagram-block/src/i18n/locales/hr.ts +++ b/packages/diagram-block/src/i18n/locales/hr.ts @@ -18,4 +18,7 @@ export const hr: DiagramDictionary = { block_type_select: { name: "Dijagram", }, + exporter: { + invalid_diagram: (source: string) => `Neispravan dijagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/is.ts b/packages/diagram-block/src/i18n/locales/is.ts index f6c5942c5a..8f3c1f862f 100644 --- a/packages/diagram-block/src/i18n/locales/is.ts +++ b/packages/diagram-block/src/i18n/locales/is.ts @@ -18,4 +18,7 @@ export const is: DiagramDictionary = { block_type_select: { name: "Skýringarmynd", }, + exporter: { + invalid_diagram: (source: string) => `Ógild skýringarmynd "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/it.ts b/packages/diagram-block/src/i18n/locales/it.ts index d078ddd78f..19c25d464e 100644 --- a/packages/diagram-block/src/i18n/locales/it.ts +++ b/packages/diagram-block/src/i18n/locales/it.ts @@ -18,4 +18,7 @@ export const it: DiagramDictionary = { block_type_select: { name: "Diagramma", }, + exporter: { + invalid_diagram: (source: string) => `Diagramma non valido "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/ja.ts b/packages/diagram-block/src/i18n/locales/ja.ts index e3cc2eb3e7..83225960c3 100644 --- a/packages/diagram-block/src/i18n/locales/ja.ts +++ b/packages/diagram-block/src/i18n/locales/ja.ts @@ -18,4 +18,7 @@ export const ja: DiagramDictionary = { block_type_select: { name: "ダイアグラム", }, + exporter: { + invalid_diagram: (source: string) => `無効な図 "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/ko.ts b/packages/diagram-block/src/i18n/locales/ko.ts index e8d8de41e5..42116b64a5 100644 --- a/packages/diagram-block/src/i18n/locales/ko.ts +++ b/packages/diagram-block/src/i18n/locales/ko.ts @@ -18,4 +18,7 @@ export const ko: DiagramDictionary = { block_type_select: { name: "다이어그램", }, + exporter: { + invalid_diagram: (source: string) => `잘못된 다이어그램 "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/nl.ts b/packages/diagram-block/src/i18n/locales/nl.ts index 559d6c2232..e772a6a1cf 100644 --- a/packages/diagram-block/src/i18n/locales/nl.ts +++ b/packages/diagram-block/src/i18n/locales/nl.ts @@ -18,4 +18,7 @@ export const nl: DiagramDictionary = { block_type_select: { name: "Diagram", }, + exporter: { + invalid_diagram: (source: string) => `Ongeldig diagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/no.ts b/packages/diagram-block/src/i18n/locales/no.ts index 8b701b91a3..bcdd41a01a 100644 --- a/packages/diagram-block/src/i18n/locales/no.ts +++ b/packages/diagram-block/src/i18n/locales/no.ts @@ -18,4 +18,7 @@ export const no: DiagramDictionary = { block_type_select: { name: "Diagram", }, + exporter: { + invalid_diagram: (source: string) => `Ugyldig diagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/pl.ts b/packages/diagram-block/src/i18n/locales/pl.ts index 925e86a873..6af355cc6c 100644 --- a/packages/diagram-block/src/i18n/locales/pl.ts +++ b/packages/diagram-block/src/i18n/locales/pl.ts @@ -18,4 +18,7 @@ export const pl: DiagramDictionary = { block_type_select: { name: "Diagram", }, + exporter: { + invalid_diagram: (source: string) => `Nieprawidłowy diagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/pt.ts b/packages/diagram-block/src/i18n/locales/pt.ts index c0853d6b1e..3f5ea3786e 100644 --- a/packages/diagram-block/src/i18n/locales/pt.ts +++ b/packages/diagram-block/src/i18n/locales/pt.ts @@ -18,4 +18,7 @@ export const pt: DiagramDictionary = { block_type_select: { name: "Diagrama", }, + exporter: { + invalid_diagram: (source: string) => `Diagrama inválido "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/ru.ts b/packages/diagram-block/src/i18n/locales/ru.ts index c3fd2a13c9..ca396afd6f 100644 --- a/packages/diagram-block/src/i18n/locales/ru.ts +++ b/packages/diagram-block/src/i18n/locales/ru.ts @@ -18,4 +18,7 @@ export const ru: DiagramDictionary = { block_type_select: { name: "Диаграмма", }, + exporter: { + invalid_diagram: (source: string) => `Недопустимая диаграмма "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/sk.ts b/packages/diagram-block/src/i18n/locales/sk.ts index faa7ac877d..80e2b4194e 100644 --- a/packages/diagram-block/src/i18n/locales/sk.ts +++ b/packages/diagram-block/src/i18n/locales/sk.ts @@ -18,4 +18,7 @@ export const sk: DiagramDictionary = { block_type_select: { name: "Diagram", }, + exporter: { + invalid_diagram: (source: string) => `Neplatný diagram "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/uk.ts b/packages/diagram-block/src/i18n/locales/uk.ts index 94403884f7..cc781734e8 100644 --- a/packages/diagram-block/src/i18n/locales/uk.ts +++ b/packages/diagram-block/src/i18n/locales/uk.ts @@ -18,4 +18,7 @@ export const uk: DiagramDictionary = { block_type_select: { name: "Діаграма", }, + exporter: { + invalid_diagram: (source: string) => `Недійсна діаграма "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/uz.ts b/packages/diagram-block/src/i18n/locales/uz.ts index 500f8c642a..d519da8ab4 100644 --- a/packages/diagram-block/src/i18n/locales/uz.ts +++ b/packages/diagram-block/src/i18n/locales/uz.ts @@ -18,4 +18,7 @@ export const uz: DiagramDictionary = { block_type_select: { name: "Diagramma", }, + exporter: { + invalid_diagram: (source: string) => `Yaroqsiz diagramma "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/vi.ts b/packages/diagram-block/src/i18n/locales/vi.ts index 88e8ec1c26..8f2ee8d671 100644 --- a/packages/diagram-block/src/i18n/locales/vi.ts +++ b/packages/diagram-block/src/i18n/locales/vi.ts @@ -26,4 +26,7 @@ export const vi: DiagramDictionary = { block_type_select: { name: "Sơ đồ", }, + exporter: { + invalid_diagram: (source: string) => `Sơ đồ không hợp lệ "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/zh-tw.ts b/packages/diagram-block/src/i18n/locales/zh-tw.ts index c9af902b51..e903761f92 100644 --- a/packages/diagram-block/src/i18n/locales/zh-tw.ts +++ b/packages/diagram-block/src/i18n/locales/zh-tw.ts @@ -18,4 +18,7 @@ export const zhTW: DiagramDictionary = { block_type_select: { name: "圖表", }, + exporter: { + invalid_diagram: (source: string) => `無效的圖表 "${source}"`, + }, }; diff --git a/packages/diagram-block/src/i18n/locales/zh.ts b/packages/diagram-block/src/i18n/locales/zh.ts index 5c5aa1b33b..b1b5fecb38 100644 --- a/packages/diagram-block/src/i18n/locales/zh.ts +++ b/packages/diagram-block/src/i18n/locales/zh.ts @@ -18,4 +18,7 @@ export const zh: DiagramDictionary = { block_type_select: { name: "图表", }, + exporter: { + invalid_diagram: (source: string) => `无效的图表 "${source}"`, + }, }; diff --git a/packages/diagram-block/src/odt-exporter/index.ts b/packages/diagram-block/src/odt-exporter/index.ts new file mode 100644 index 0000000000..8be789895e --- /dev/null +++ b/packages/diagram-block/src/odt-exporter/index.ts @@ -0,0 +1,134 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { exportImageToDataURL, plainContentToString } from "@blocknote/core"; +import { + createODTImageParagraph, + ODTExporter, +} from "@blocknote/xl-odt-exporter"; +import { createElement } from "react"; + +import { + RenderDiagram, + renderDiagramToImage, +} from "../helpers/renderDiagramToImage.js"; +import { getDiagramExporterDictionary } from "../i18n/dictionary.js"; + +export type { RenderDiagram } from "../helpers/renderDiagramToImage.js"; + +type DiagramBlock = BlockFromConfigNoChildren< + BlockConfig<"diagram", {}, "plain">, + any, + any +>; + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the diagram by the (first line of the) source. +// The parser's message is deliberately NOT rendered: it's authoring detail +// (and untranslated English) - the editor is where the author sees and +// fixes it. Styled muted like the +// other exporters' placeholders. +function errorMessage( + exporter: ODTExporter, + source: string, +): string { + return getDiagramExporterDictionary(exporter).invalid_diagram( + source.split("\n")[0], + ); +} + +function errorParagraph(source: string, exporter: ODTExporter) { + const styleName = exporter.registerStyle((name) => + createElement( + "style:style", + { "style:family": "text", "style:name": name }, + createElement("style:text-properties", { + "fo:font-style": "italic", + "fo:color": "#999999", + }), + ), + ); + + return createElement( + "text:p", + null, + createElement( + "text:span", + { "text:style-name": styleName }, + errorMessage(exporter, source), + ), + ); +} + +/** + * Creates an ODT block mapping for `@blocknote/diagram-block` that embeds + * diagrams as images. Rendering runs in the browser by default (Mermaid + * can't render outside of it); when exporting elsewhere (e.g. server-side), + * pass a `renderDiagram` function backed by e.g. `@mermaid-js/mermaid-cli` + * or a Kroki server. Invalid sources render an error placeholder (mirroring + * the editor): + * + * ```ts + * import { createDiagramBlockMapping } from "@blocknote/diagram-block/odt-exporter"; + * + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * blockMapping: { + * ...odtDefaultSchemaMappings.blockMapping, + * diagram: createDiagramBlockMapping({ renderDiagram }), + * }, + * }); + * ``` + */ +export function createDiagramBlockMapping(options?: { + renderDiagram?: RenderDiagram; +}) { + return async ( + block: DiagramBlock, + exporter: Exporter, + ) => { + // Only the ODTExporter invokes ODT mappings, but mapping signatures are + // contravariant in the exporter parameter, so requiring the subclass here + // wouldn't satisfy the mapping type - hence the base type + cast. + const odtExporter = exporter as ODTExporter; + const source = plainContentToString(block.content); + if (!source.trim()) { + return createElement("text:p"); + } + + const renderDiagram = + options?.renderDiagram ?? + (typeof document !== "undefined" ? renderDiagramToImage : undefined); + if (!renderDiagram) { + throw new Error( + "Rendering diagrams to images requires a browser. When exporting elsewhere, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by @mermaid-js/mermaid-cli or a Kroki server).", + ); + } + + const result = await renderDiagram(source); + if (result.error !== undefined) { + return errorParagraph(source, odtExporter); + } + + // The image may be rendered above its display size for sharpness, so + // pass the diagram's display dimensions rather than the picture's own. + return await createODTImageParagraph( + odtExporter, + exportImageToDataURL(result.image), + { + width: result.image.width, + height: result.image.height, + align: "center", + }, + ); + }; +} + +/** + * ODT block mapping for `@blocknote/diagram-block` with the default options + * - see {@link createDiagramBlockMapping}. Browser-only; when exporting + * elsewhere, use the factory to pass a `renderDiagram` function. + */ +export const diagramBlockMapping = createDiagramBlockMapping(); diff --git a/packages/diagram-block/src/odt-exporter/odtExporter.test.ts b/packages/diagram-block/src/odt-exporter/odtExporter.test.ts new file mode 100644 index 0000000000..9c5ee00c50 --- /dev/null +++ b/packages/diagram-block/src/odt-exporter/odtExporter.test.ts @@ -0,0 +1,77 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + ODTExporter, + odtDefaultSchemaMappings, +} from "@blocknote/xl-odt-exporter"; +import { BlobReader, ZipReader } from "@zip.js/zip.js"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +import { + diagramDocument, + renderDiagram, + renderInvalidDiagram, + zipEntryContent, +} from "../exporterTestUtil.js"; +import { createDiagramBlockMapping, diagramBlockMapping } from "./index.js"; + +beforeAll(async () => { + // @ts-expect-error - Blob polyfill for Node test environment + globalThis.Blob = (await import("node:buffer")).Blob; +}); + +function createExporter(diagram: ReturnType) { + const mappings = { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + diagram, + }, + }; + return new ODTExporter( + BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }), + mappings as any, + ); +} + +describe("odt exporter mappings", () => { + it("should embed the rendered diagram as an image", async () => { + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram }), + ); + + const odt = await exporter.toODTDocument(diagramDocument); + const contentXML = await zipEntryContent(odt, "content.xml"); + + expect(contentXML).toContain("draw:image"); + // The picture bytes are stored as their own zip entry. + const entries = await new ZipReader(new BlobReader(odt)).getEntries(); + expect( + entries.some((entry) => entry.filename.startsWith("Pictures/")), + ).toBe(true); + }); + + it("should render an error placeholder for invalid sources", async () => { + // The renderer returns invalid sources as a typed error, and the + // mapping renders the error placeholder - never the raw source. + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram: renderInvalidDiagram }), + ); + + const contentXML = await zipEntryContent( + await exporter.toODTDocument(diagramDocument), + "content.xml", + ); + + expect(contentXML).toContain("Invalid diagram"); + expect(contentXML).toContain("graph TD"); + expect(contentXML).not.toContain("draw:image"); + }); + + it("should throw a descriptive error without a renderer outside the browser", async () => { + const exporter = createExporter(diagramBlockMapping); + + await expect(exporter.toODTDocument(diagramDocument)).rejects.toThrow( + "pass a `renderDiagram` function", + ); + }); +}); diff --git a/packages/diagram-block/src/pdf-exporter/index.tsx b/packages/diagram-block/src/pdf-exporter/index.tsx new file mode 100644 index 0000000000..6853ac7a96 --- /dev/null +++ b/packages/diagram-block/src/pdf-exporter/index.tsx @@ -0,0 +1,112 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { exportImageToDataURL, plainContentToString } from "@blocknote/core"; +import { Image, Text, View } from "@react-pdf/renderer"; + +import { + RenderDiagram, + renderDiagramToImage, +} from "../helpers/renderDiagramToImage.js"; +import { getDiagramExporterDictionary } from "../i18n/dictionary.js"; + +export type { RenderDiagram } from "../helpers/renderDiagramToImage.js"; + +type DiagramBlock = BlockFromConfigNoChildren< + BlockConfig<"diagram", {}, "plain">, + any, + any +>; + +const PIXELS_PER_POINT = 0.75; +const MAX_WIDTH_POINTS = 400; + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the diagram by the (first line of the) source. +// The parser's message is deliberately NOT rendered: it's authoring detail +// (and untranslated English) - the editor is where the author sees and +// fixes it. +function errorText( + exporter: Exporter, + source: string, +) { + return ( + + + {getDiagramExporterDictionary(exporter).invalid_diagram( + source.split("\n")[0], + )} + + + ); +} + +/** + * Creates a PDF block mapping for `@blocknote/diagram-block` that embeds + * diagrams as images. Rendering runs in the browser by default (Mermaid + * can't render outside of it); when exporting elsewhere (e.g. server-side), + * pass a `renderDiagram` function backed by e.g. `@mermaid-js/mermaid-cli` + * or a Kroki server. Invalid sources render an error placeholder (mirroring + * the editor): + * + * ```ts + * import { createDiagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; + * + * new PDFExporter(schema, { + * ...pdfDefaultSchemaMappings, + * blockMapping: { + * ...pdfDefaultSchemaMappings.blockMapping, + * diagram: createDiagramBlockMapping({ renderDiagram }), + * }, + * }); + * ``` + */ +export function createDiagramBlockMapping(options?: { + renderDiagram?: RenderDiagram; +}) { + return async ( + block: DiagramBlock, + exporter: Exporter, + ) => { + const source = plainContentToString(block.content); + if (!source.trim()) { + return ; + } + + const renderDiagram = + options?.renderDiagram ?? + (typeof document !== "undefined" ? renderDiagramToImage : undefined); + if (!renderDiagram) { + throw new Error( + "Rendering diagrams to images requires a browser. When exporting elsewhere, pass a `renderDiagram` function to `createDiagramBlockMapping` (e.g. backed by @mermaid-js/mermaid-cli or a Kroki server).", + ); + } + + const result = await renderDiagram(source); + if (result.error !== undefined) { + return errorText(exporter, source); + } + + return ( + + ); + }; +} + +/** + * PDF block mapping for `@blocknote/diagram-block` with the default options + * - see {@link createDiagramBlockMapping}. Browser-only; when exporting + * elsewhere, use the factory to pass a `renderDiagram` function. + */ +export const diagramBlockMapping = createDiagramBlockMapping(); diff --git a/packages/diagram-block/src/pdf-exporter/pdfExporter.test.tsx b/packages/diagram-block/src/pdf-exporter/pdfExporter.test.tsx new file mode 100644 index 0000000000..ee7f6d1f74 --- /dev/null +++ b/packages/diagram-block/src/pdf-exporter/pdfExporter.test.tsx @@ -0,0 +1,68 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +import reactElementToJSXString from "react-element-to-jsx-string"; +import { describe, expect, it } from "vite-plus/test"; + +import { + diagramDocument, + renderDiagram, + renderInvalidDiagram, +} from "../exporterTestUtil.js"; +import { createDiagramBlockMapping, diagramBlockMapping } from "./index.js"; + +function createExporter(diagram: ReturnType) { + const mappings = { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + diagram, + }, + }; + return new PDFExporter( + BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }), + mappings as any, + ); +} + +describe("pdf exporter mappings", () => { + it("should embed the rendered diagram as an image", async () => { + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram }), + ); + + const str = reactElementToJSXString( + await exporter.toReactPDFDocument(diagramDocument), + ); + + expect(str).toContain("data:image/png;base64,"); + // 100px wide at 0.75 points per pixel. + expect(str).toContain("width: 75"); + }); + + it("should throw a descriptive error without a renderer outside the browser", async () => { + const exporter = createExporter(diagramBlockMapping); + + await expect(exporter.toReactPDFDocument(diagramDocument)).rejects.toThrow( + "pass a `renderDiagram` function", + ); + }); + + it("should render an error placeholder for invalid sources", async () => { + // The renderer returns invalid sources as a typed error, and the + // mapping renders the error placeholder - never the raw source. + const exporter = createExporter( + createDiagramBlockMapping({ renderDiagram: renderInvalidDiagram }), + ); + + const str = reactElementToJSXString( + await exporter.toReactPDFDocument(diagramDocument), + ); + + expect(str).toContain("Invalid diagram"); + expect(str).toContain("graph TD"); + expect(str).not.toContain("data:image"); + }); +}); diff --git a/packages/diagram-block/tsconfig.json b/packages/diagram-block/tsconfig.json index c74ac34642..2d8bcd4a25 100644 --- a/packages/diagram-block/tsconfig.json +++ b/packages/diagram-block/tsconfig.json @@ -19,7 +19,15 @@ "declarationDir": "types", "composite": true, "skipLibCheck": true, - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "paths": { + "@shared/*": ["../../shared/*"] + } }, - "include": ["src"] + "include": ["src"], + "references": [ + { + "path": "../../shared" + } + ] } diff --git a/packages/diagram-block/vite.config.ts b/packages/diagram-block/vite.config.ts index 55e07044a5..698c340d58 100644 --- a/packages/diagram-block/vite.config.ts +++ b/packages/diagram-block/vite.config.ts @@ -1,6 +1,6 @@ import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // https://vitejs.dev/config/ @@ -21,6 +21,16 @@ export default defineConfig( }, test: { setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + }, + // The ODT exporter sources (loaded via the test aliases) use JSX + // namespace tags (e.g. ), which Vite's oxc rejects by default. + oxc: { + jsx: { + throwIfNamespace: false, + }, }, plugins: [webpackStats() as any], // used so that vitest resolves the core package from the sources instead of the built version @@ -29,9 +39,30 @@ export default defineConfig( conf.command === "build" ? ({} as Record) : ({ + "@shared": path.resolve(__dirname, "../../shared/"), // load live from sources with live reload working "@blocknote/core": path.resolve(__dirname, "../core/src/"), "@blocknote/react": path.resolve(__dirname, "../react/src/"), + "@blocknote/xl-docx-exporter": path.resolve( + __dirname, + "../xl-docx-exporter/src/", + ), + "@blocknote/xl-email-exporter": path.resolve( + __dirname, + "../xl-email-exporter/src/", + ), + "@blocknote/xl-multi-column": path.resolve( + __dirname, + "../xl-multi-column/src/", + ), + "@blocknote/xl-odt-exporter": path.resolve( + __dirname, + "../xl-odt-exporter/src/", + ), + "@blocknote/xl-pdf-exporter": path.resolve( + __dirname, + "../xl-pdf-exporter/src/", + ), } as Record), }, build: { @@ -39,6 +70,22 @@ export default defineConfig( lib: { entry: { "blocknote-diagram-block": path.resolve(__dirname, "src/index.ts"), + "docx-exporter": path.resolve( + __dirname, + "src/docx-exporter/index.ts", + ), + "odt-exporter": path.resolve( + __dirname, + "src/odt-exporter/index.ts", + ), + "pdf-exporter": path.resolve( + __dirname, + "src/pdf-exporter/index.tsx", + ), + "email-exporter": path.resolve( + __dirname, + "src/email-exporter/index.tsx", + ), }, name: "blocknote-diagram-block", formats: ["es", "cjs"], diff --git a/packages/math-block/package.json b/packages/math-block/package.json index ea47a9e9cc..377cad0a1c 100644 --- a/packages/math-block/package.json +++ b/packages/math-block/package.json @@ -44,6 +44,26 @@ "types": "./types/src/index.d.ts", "import": "./dist/blocknote-math-block.js", "require": "./dist/blocknote-math-block.cjs" + }, + "./docx-exporter": { + "types": "./types/src/docx-exporter/index.d.ts", + "import": "./dist/docx-exporter.js", + "require": "./dist/docx-exporter.cjs" + }, + "./odt-exporter": { + "types": "./types/src/odt-exporter/index.d.ts", + "import": "./dist/odt-exporter.js", + "require": "./dist/odt-exporter.cjs" + }, + "./pdf-exporter": { + "types": "./types/src/pdf-exporter/index.d.ts", + "import": "./dist/pdf-exporter.js", + "require": "./dist/pdf-exporter.cjs" + }, + "./email-exporter": { + "types": "./types/src/email-exporter/index.d.ts", + "import": "./dist/email-exporter.js", + "require": "./dist/email-exporter.cjs" } }, "scripts": { @@ -56,6 +76,7 @@ "dependencies": { "@handlewithcare/prosemirror-inputrules": "^0.1.4", "katex": "^0.16.11", + "mathml2omml": "^0.5.0", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-view": "^1.41.4" @@ -63,21 +84,72 @@ "devDependencies": { "@blocknote/react": "workspace:^", "react-icons": "^5.5.0", + "@blocknote/shared": "workspace:^", + "@blocknote/xl-docx-exporter": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", "@blocknote/xl-multi-column": "workspace:^", + "@blocknote/xl-odt-exporter": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-email/components": "^1.0.12", + "@react-pdf/math": "^2.0.1", + "@react-pdf/renderer": "^4.5.1", "@types/katex": "^0.16.7", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", + "@zip.js/zip.js": "^2.8.8", + "docx": "^9.6.1", + "mathjax-full": "^3.2.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-element-to-jsx-string": "^17.0.1", "rimraf": "^5.0.10", "rollup-plugin-webpack-stats": "^0.2.6", "typescript": "^5.9.3", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "xml-formatter": "^3.6.7" }, "peerDependencies": { "@blocknote/core": "workspace:^", "@blocknote/react": "workspace:^", + "@blocknote/xl-docx-exporter": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", + "@blocknote/xl-odt-exporter": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-email/components": "^1.0.12", + "@react-pdf/math": "^2.0.0", + "@react-pdf/renderer": "^4.5.1", + "docx": "^9.6.1", + "mathjax-full": "^3.2.2", "react": "^18.0 || ^19.0 || >= 19.0.0-rc", "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + }, + "peerDependenciesMeta": { + "@blocknote/xl-docx-exporter": { + "optional": true + }, + "@blocknote/xl-email-exporter": { + "optional": true + }, + "@blocknote/xl-odt-exporter": { + "optional": true + }, + "@blocknote/xl-pdf-exporter": { + "optional": true + }, + "@react-email/components": { + "optional": true + }, + "@react-pdf/math": { + "optional": true + }, + "@react-pdf/renderer": { + "optional": true + }, + "docx": { + "optional": true + }, + "mathjax-full": { + "optional": true + } } } diff --git a/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx b/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx index 60df43ba03..4b932b392b 100644 --- a/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx +++ b/packages/math-block/src/block/helpers/render/MathBlockPreviewWithPopup.tsx @@ -6,14 +6,14 @@ import { import { TbMathFunction } from "react-icons/tb"; import { MathBlockConfig } from "../../createReactMathBlockSpec.js"; -import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js"; +import { plainContentToString } from "@blocknote/core"; import { useLatexToMathMLString } from "../../../helpers/render/useLatexToMathML.js"; import { getMathDictionary } from "../../../i18n/dictionary.js"; export const MathBlockPreviewWithPopup = ( props: ReactCustomBlockRenderProps, ) => { - const source = getMathPlainTextContent(props.block.content).trim(); + const source = plainContentToString(props.block.content).trim(); const { mathMLString, error } = useLatexToMathMLString(source); const dict = getMathDictionary(props.editor).block; diff --git a/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx b/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx index 4411a0fe70..b421550441 100644 --- a/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx +++ b/packages/math-block/src/block/helpers/toExternalHTML/BlockMathMLElement.tsx @@ -1,14 +1,14 @@ import { ReactCustomBlockRenderProps } from "@blocknote/react"; import type { ComponentType } from "react"; -import { MathBlockConfig } from "../../createReactMathBlockSpec.js"; -import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js"; +import { plainContentToString } from "@blocknote/core"; import { latexToMathMLElement } from "../../../helpers/toExternalHTML/latexToMathMLElement.js"; +import { MathBlockConfig } from "../../createReactMathBlockSpec.js"; export const BlockMathMLElement = ({ block, }: ReactCustomBlockRenderProps) => { - const source = getMathPlainTextContent(block.content); + const source = plainContentToString(block.content); const { mathMLElement } = latexToMathMLElement(source); if (!mathMLElement) { return null; diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/withMathMappings/document.xml b/packages/math-block/src/docx-exporter/__snapshots__/withMathMappings/document.xml similarity index 100% rename from packages/xl-docx-exporter/src/docx/__snapshots__/withMathMappings/document.xml rename to packages/math-block/src/docx-exporter/__snapshots__/withMathMappings/document.xml diff --git a/packages/math-block/src/docx-exporter/docxExporter.test.ts b/packages/math-block/src/docx-exporter/docxExporter.test.ts new file mode 100644 index 0000000000..4994311ab6 --- /dev/null +++ b/packages/math-block/src/docx-exporter/docxExporter.test.ts @@ -0,0 +1,151 @@ +import { + BlockNoteSchema, + createPageBreakBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import { + DOCXExporter, + docxDefaultSchemaMappings, +} from "@blocknote/xl-docx-exporter"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; +import { + BlobReader, + Entry, + FileEntry, + TextWriter, + ZipReader, +} from "@zip.js/zip.js"; +import { Packer } from "docx"; +import { describe, expect, it } from "vite-plus/test"; +import xmlFormat from "xml-formatter"; + +import { inlineMathMapping, mathBlockMapping } from "./index.js"; + +const getZIPEntryContent = (entries: Entry[], fileName: string) => { + const entry = entries.find((entry) => { + return entry.filename === fileName && !entry.directory; + }) as FileEntry | undefined; + + if (!entry) { + return ""; + } + + return entry.getData!(new TextWriter()); +}; + +const prettify = (sourceXml: string) => { + // Replace random ids like r:id="rIdll8_ocxarmodcwrnsavfb" + return xmlFormat(sourceXml) + .replace(/r:id="[a-zA-Z0-9_-]*"/g, 'r:id="FAKE-ID"') + .replace(/ Id="[a-zA-Z0-9_-]*"/g, ' Id="FAKE-ID"'); +}; + +describe("docx exporter mappings", () => { + it("should export math as native equations", { timeout: 10000 }, async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just + // map the block JSON. + const mappings = { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, + }; + const exporter = new DOCXExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + // The math block & inline math paragraph from the shared test document. + const doc = await exporter.toDocxJsDocument( + testDocumentWithSourceBlocks.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, + ); + + const blob = await Packer.toBlob(doc); + const zip = new ZipReader(new BlobReader(blob)); + const entries = await zip.getEntries(); + + await expect( + prettify(await getZIPEntryContent(entries, "word/document.xml")), + ).toMatchFileSnapshot("__snapshots__/withMathMappings/document.xml"); + }); + + it("should render error placeholders for invalid LaTeX", async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just map + // the block JSON. + const mappings = { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, + }; + const exporter = new DOCXExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + const doc = await exporter.toDocxJsDocument( + [ + { + id: "1", + type: "mathBlock", + props: {}, + content: [{ type: "text", text: "\\invalidcommand{", styles: {} }], + children: [], + }, + { + id: "2", + type: "paragraph", + props: {}, + content: [ + { type: "text", text: "Broken: ", styles: {} }, + { type: "math", props: {}, content: "\\invalidcommand{" }, + ], + children: [], + }, + ] as any, + { + sectionOptions: {}, + documentOptions: {}, + locale: "en-US", + }, + ); + + const blob = await Packer.toBlob(doc); + const zip = new ZipReader(new BlobReader(blob)); + const entries = await zip.getEntries(); + const documentXML = await getZIPEntryContent(entries, "word/document.xml"); + + // Mirrors the editor's error placeholder rather than dumping the LaTeX + // source on readers - once for the block, once for the inline math. + expect(documentXML.match(/Invalid formula/g)).toHaveLength(2); + expect(documentXML).not.toContain("m:oMath"); + }); +}); diff --git a/packages/math-block/src/docx-exporter/index.ts b/packages/math-block/src/docx-exporter/index.ts new file mode 100644 index 0000000000..291ca88690 --- /dev/null +++ b/packages/math-block/src/docx-exporter/index.ts @@ -0,0 +1,129 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { plainContentToString } from "@blocknote/core"; +import { AlignmentType, ImportedXmlComponent, Paragraph, TextRun } from "docx"; +import { mml2omml } from "mathml2omml"; + +import { latexToMathML } from "../exporterHelpers/latexToMathML.js"; +import { getMathExporterDictionary } from "../i18n/dictionary.js"; + +type MathBlock = BlockFromConfigNoChildren< + BlockConfig<"mathBlock", {}, "plain">, + any, + any +>; + +type InlineMath = { type: "math"; content: string }; + +// Converts LaTeX to a native Word equation (OMML): KaTeX renders the LaTeX +// to MathML, which is then converted to OMML. Invalid LaTeX comes back as a +// typed error, for the mappings to render as a placeholder. +function latexToDocxEquation( + latex: string, + inline: boolean, +): { error?: undefined; equation: ImportedXmlComponent } | { error: string } { + const mathML = latexToMathML(latex, inline); + if (mathML.error !== undefined) { + return { error: mathML.error }; + } + + // `fromXmlString` parses the XML *document*, returning a nameless wrapper + // component around the `m:oMath` root element - unwrap it, or it would + // serialize as an (invalid) `` element. + const imported = ImportedXmlComponent.fromXmlString( + mml2omml(mathML.mathML), + ) as any; + return { equation: imported.root[0] as ImportedXmlComponent }; +} + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the formula by its source. The parser's message +// is deliberately NOT rendered: it's authoring detail (and untranslated +// English) - the editor is where the author sees and fixes it. +function errorText( + exporter: Exporter, + source: string, +) { + return new TextRun({ + text: getMathExporterDictionary(exporter).invalid_formula(source), + italics: true, + color: "999999", + }); +} + +/** + * DOCX block mapping for `@blocknote/math-block` that renders math blocks as + * native (editable) Word equations. Invalid LaTeX renders an error + * placeholder (mirroring the editor): + * + * ```ts + * import { mathBlockMapping } from "@blocknote/math-block/docx-exporter"; + * + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * blockMapping: { + * ...docxDefaultSchemaMappings.blockMapping, + * mathBlock: mathBlockMapping, + * }, + * }); + * ``` + */ +export function mathBlockMapping( + block: MathBlock, + exporter: Exporter, +) { + const source = plainContentToString(block.content); + if (!source.trim()) { + return new Paragraph({}); + } + + const result = latexToDocxEquation(source, false); + if (result.error !== undefined) { + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [errorText(exporter, source)], + }); + } + + return new Paragraph({ + alignment: AlignmentType.CENTER, + children: [result.equation as any], + }); +} + +/** + * DOCX inline content mapping for `@blocknote/math-block` that renders + * inline math as native (editable) Word equations. Invalid LaTeX renders an + * error placeholder (mirroring the editor): + * + * ```ts + * import { inlineMathMapping } from "@blocknote/math-block/docx-exporter"; + * + * new DOCXExporter(schema, { + * ...docxDefaultSchemaMappings, + * inlineContentMapping: { + * ...docxDefaultSchemaMappings.inlineContentMapping, + * math: inlineMathMapping, + * }, + * }); + * ``` + */ +export function inlineMathMapping( + inlineContent: InlineMath, + exporter: Exporter, +) { + const source = inlineContent.content; + if (!source.trim()) { + return new TextRun({ text: "" }); + } + + const result = latexToDocxEquation(source, true); + if (result.error !== undefined) { + return errorText(exporter, source); + } + + return result.equation as any; +} diff --git a/packages/math-block/src/email-exporter/__snapshots__/emailExporter.test.tsx.snap b/packages/math-block/src/email-exporter/__snapshots__/emailExporter.test.tsx.snap new file mode 100644 index 0000000000..d94f85f3be --- /dev/null +++ b/packages/math-block/src/email-exporter/__snapshots__/emailExporter.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`email exporter mappings > should export math as SVG images outside the browser > __snapshots__/emailWithMathMappings 1`] = `"
a^2 = \\sqrt{b^2 + c^2}

Inline math: e^{i\\pi} + 1 = 0

"`; diff --git a/packages/math-block/src/email-exporter/emailExporter.test.tsx b/packages/math-block/src/email-exporter/emailExporter.test.tsx new file mode 100644 index 0000000000..766e1563e7 --- /dev/null +++ b/packages/math-block/src/email-exporter/emailExporter.test.tsx @@ -0,0 +1,131 @@ +import { + BlockNoteSchema, + createPageBreakBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import { + createCIDImageDelivery, + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createInlineMathMapping, + createMathBlockMapping, + inlineMathMapping, + mathBlockMapping, +} from "./index.js"; + +const mathTestDocument = testDocumentWithSourceBlocks.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), +); + +const createExporter = (mappings: { + math: typeof mathBlockMapping; + inlineMath: typeof inlineMathMapping; +}) => + new ReactEmailExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + mathBlock: mappings.math, + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + math: mappings.inlineMath, + }, + } as any, + ); + +describe("email exporter mappings", () => { + it("should export math as SVG images outside the browser", async () => { + const exporter = createExporter({ + math: mathBlockMapping, + inlineMath: inlineMathMapping, + }); + + // Without a browser (or a plugged-in rasterizer), formulas are embedded + // as SVG data URLs - MathJax's SVG output is environment-independent. + const html = await exporter.toReactEmailDocument(mathTestDocument as any); + expect(html).toContain("data:image/svg+xml;base64,"); + expect(html).toMatchSnapshot("__snapshots__/emailWithMathMappings"); + }); + + it("should render error placeholders for invalid LaTeX", async () => { + const exporter = createExporter({ + math: mathBlockMapping, + inlineMath: inlineMathMapping, + }); + + const html = await exporter.toReactEmailDocument([ + { + id: "1", + type: "mathBlock", + props: {}, + content: [{ type: "text", text: "\\invalidcommand{", styles: {} }], + children: [], + }, + { + id: "2", + type: "paragraph", + props: {}, + content: [ + { type: "text", text: "Broken: ", styles: {} }, + { type: "math", props: {}, content: "\\invalidcommand{" }, + ], + children: [], + }, + ] as any); + + // Mirrors the editor's error placeholder rather than dumping the LaTeX + // source on readers - once for the block, once for the inline math. + expect(html.match(/Invalid formula/g)).toHaveLength(2); + expect(html).not.toContain(" { + const imageDelivery = createCIDImageDelivery(); + const exporter = createExporter({ + math: createMathBlockMapping({ + imageDelivery, + // A stub rasterizer, standing in for e.g. @resvg/resvg-js on a + // server. + rasterize: async (svg) => ({ + mimeType: "image/png", + data: new Uint8Array([0, 0, 0]), + width: svg.width, + height: svg.height, + }), + }), + inlineMath: createInlineMathMapping({ imageDelivery }), + }); + + const html = await exporter.toReactEmailDocument(mathTestDocument as any); + + // The body references the attachments by CID; the image contents are + // collected for the caller to attach at send time - the rasterized + // block math as PNG, the inline math as SVG. + expect(html).toContain('src="cid:math-1@blocknote"'); + expect(html).toContain('src="cid:math-2@blocknote"'); + expect(imageDelivery.attachments).toHaveLength(2); + expect(imageDelivery.attachments[0]).toEqual({ + cid: "math-1@blocknote", + filename: "math-1.png", + content: "AAAA", + encoding: "base64", + contentType: "image/png", + contentDisposition: "inline", + }); + expect(imageDelivery.attachments[1].contentType).toBe("image/svg+xml"); + expect(imageDelivery.attachments[1].filename).toBe("math-2.svg"); + }); +}); diff --git a/packages/math-block/src/email-exporter/index.tsx b/packages/math-block/src/email-exporter/index.tsx new file mode 100644 index 0000000000..63b8f19b5c --- /dev/null +++ b/packages/math-block/src/email-exporter/index.tsx @@ -0,0 +1,204 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { plainContentToString } from "@blocknote/core"; +import { + dataURLImageDelivery, + ReactEmailImageDelivery, +} from "@blocknote/xl-email-exporter"; +import { Img, Text } from "@react-email/components"; + +import { + latexToMathSVG, + RasterizeSVG, + rasterizeSVGInBrowser, +} from "../exporterHelpers/renderMathToImage.js"; + +type MathBlock = BlockFromConfigNoChildren< + BlockConfig<"mathBlock", {}, "plain">, + any, + any +>; + +type InlineMath = { type: "math"; content: string }; + +export { + latexToMathSVG, + rasterizeSVGInBrowser, +} from "../exporterHelpers/renderMathToImage.js"; +export type { + RasterizeSVG, + SVGExportImage, +} from "../exporterHelpers/renderMathToImage.js"; +import { getMathExporterDictionary } from "../i18n/dictionary.js"; + +type MathImageOptions = { + /** + * Rasterizes the formula SVG to a raster image. Defaults to the built-in + * canvas rasterizer in the browser; elsewhere (e.g. server-side email + * rendering), the formula is embedded as an SVG instead - pass a + * rasterizer (e.g. backed by `@resvg/resvg-js` or `sharp`) to get PNGs + * there, which more email clients display. + */ + rasterize?: RasterizeSVG; + /** + * How generated images get into the email: embedded as data URLs + * (default), or e.g. as inline `cid:` attachments via + * `createCIDImageDelivery` from `@blocknote/xl-email-exporter` - the most + * widely supported option (Gmail and Outlook block data URLs). + */ + imageDelivery?: ReactEmailImageDelivery; +}; + +// Emails render body text at 16px. +const FONT_SIZE_PIXELS = 16; + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the formula by its source. The parser's message +// is deliberately NOT rendered: it's authoring detail (and untranslated +// English) - the editor is where the author sees and fixes it. +function errorText( + exporter: Exporter, + source: string, +): string { + return getMathExporterDictionary(exporter).invalid_formula(source); +} + +/** + * Creates an email block mapping for `@blocknote/math-block` that renders + * math blocks as images, with the LaTeX source as the alt text. Invalid + * LaTeX renders an error placeholder (mirroring the editor). See + * {@link MathImageOptions} for how images are generated and delivered: + * + * ```ts + * import { createMathBlockMapping } from "@blocknote/math-block/email-exporter"; + * + * new ReactEmailExporter(schema, { + * ...reactEmailDefaultSchemaMappings, + * blockMapping: { + * ...reactEmailDefaultSchemaMappings.blockMapping, + * mathBlock: createMathBlockMapping({ imageDelivery }), + * }, + * }); + * ``` + */ +export function createMathBlockMapping(options?: MathImageOptions) { + return async ( + block: MathBlock, + exporter: Exporter, + ) => { + const source = plainContentToString(block.content); + if (!source.trim()) { + return ; + } + + // Rasterized when possible (see `MathImageOptions.rasterize`), embedded + // as SVG otherwise. Rasterization and delivery failures are unexpected + // and propagate (failing the export) rather than being rendered to + // readers. + const rasterize = + options?.rasterize ?? + (typeof document !== "undefined" ? rasterizeSVGInBrowser : undefined); + + const result = latexToMathSVG(source, { + inline: false, + fontSize: FONT_SIZE_PIXELS, + }); + if (result.error !== undefined) { + return ( + + {errorText(exporter, source)} + + ); + } + + const image = rasterize ? await rasterize(result.image) : result.image; + const src = (options?.imageDelivery ?? dataURLImageDelivery).deliver({ + ...image, + name: "math", + }); + + return ( + {source} + ); + }; +} + +/** + * Creates an email inline content mapping for `@blocknote/math-block` that + * renders inline math as images flowing with the text, with the LaTeX + * source as the alt text. Inline content renders synchronously, so the + * formula is always embedded as an SVG (never rasterized) - email clients + * that don't render SVG show the alt text. Invalid LaTeX renders an error + * placeholder (mirroring the editor): + * + * ```ts + * import { createInlineMathMapping } from "@blocknote/math-block/email-exporter"; + * + * new ReactEmailExporter(schema, { + * ...reactEmailDefaultSchemaMappings, + * inlineContentMapping: { + * ...reactEmailDefaultSchemaMappings.inlineContentMapping, + * math: createInlineMathMapping({ imageDelivery }), + * }, + * }); + * ``` + */ +export function createInlineMathMapping( + options?: Pick, +) { + return ( + inlineContent: InlineMath, + exporter: Exporter, + ) => { + const source = inlineContent.content; + if (!source.trim()) { + return ; + } + + const result = latexToMathSVG(source, { + inline: true, + fontSize: FONT_SIZE_PIXELS, + }); + if (result.error !== undefined) { + return ( + {errorText(exporter, source)} + ); + } + + const src = (options?.imageDelivery ?? dataURLImageDelivery).deliver({ + ...result.image, + name: "math", + }); + + return ( + {source} + ); + }; +} + +/** + * Email block mapping for `@blocknote/math-block` with the default options - + * see {@link createMathBlockMapping}. + */ +export const mathBlockMapping = createMathBlockMapping(); + +/** + * Email inline content mapping for `@blocknote/math-block` with the default + * options - see {@link createInlineMathMapping}. + */ +export const inlineMathMapping = createInlineMathMapping(); diff --git a/packages/math-block/src/exporterHelpers/latexToMathML.ts b/packages/math-block/src/exporterHelpers/latexToMathML.ts new file mode 100644 index 0000000000..d8ee60774f --- /dev/null +++ b/packages/math-block/src/exporterHelpers/latexToMathML.ts @@ -0,0 +1,40 @@ +import katex from "katex"; + +// Converts LaTeX to MathML (via KaTeX). The DOCX exporter mapping converts +// it further to OMML, the ODT mapping embeds it directly as a formula +// object. Invalid LaTeX is expected (the source is user input), so it's +// returned as a typed error - with a message safe to show to readers - +// rather than thrown. +export function latexToMathML( + latex: string, + inline: boolean, +): { error?: undefined; mathML: string } | { error: string } { + let katexOutput: string; + try { + katexOutput = katex.renderToString(latex, { + displayMode: !inline, + output: "mathml", + throwOnError: true, + }); + } catch (error) { + // The boundary that converts KaTeX's parse throw into the typed result. + // Only `ParseError`s are expected (invalid user LaTeX) - their messages + // are safe to show to readers. Anything else is a bug and propagates. + // `ParseError` is read off the same `katex` object whose + // `renderToString` just ran, so unlike a separate class import it can't + // diverge under bundler interop. + if (!(error instanceof katex.ParseError)) { + throw error; + } + return { error: error.message }; + } + + // KaTeX wraps the MathML in a `span`; callers only need the `math` + // element itself. + const mathML = katexOutput.match(//)?.[0]; + if (!mathML) { + throw new Error("No MathML found in KaTeX output"); + } + + return { mathML }; +} diff --git a/packages/math-block/src/exporterHelpers/renderMathToImage.browser.test.ts b/packages/math-block/src/exporterHelpers/renderMathToImage.browser.test.ts new file mode 100644 index 0000000000..a46fefb6de --- /dev/null +++ b/packages/math-block/src/exporterHelpers/renderMathToImage.browser.test.ts @@ -0,0 +1,33 @@ +import { exportImageToDataURL } from "@blocknote/core"; +import { decodeAndSample } from "@shared/util/browserImageTestUtil.js"; +import { describe, expect, test } from "vite-plus/test"; + +import { latexToMathSVG, rasterizeSVGInBrowser } from "./renderMathToImage.js"; + +// Browser unit tests for the browser-only rasterizer - the `RasterizeSVG` +// implementation that the (node) unit suites replace with stubs. Runs in the +// tests package's browser suite. +describe("rasterizeSVGInBrowser", () => { + test("rasterizes at the requested scale", async () => { + const result = latexToMathSVG("a^2 = \\sqrt{b^2 + c^2}", { + inline: false, + fontSize: 16, + }); + if (result.error !== undefined) { + throw new Error(`Expected a successful conversion: ${result.error}`); + } + + const raster = await rasterizeSVGInBrowser(result.image, 3); + + expect(raster.mimeType).toBe("image/png"); + // The raster keeps the display dimensions; the pixel data is scaled. + expect(raster.width).toBe(result.image.width); + expect(raster.height).toBe(result.image.height); + const { width, height, inkedPixels } = await decodeAndSample( + exportImageToDataURL(raster), + ); + expect(width).toBeGreaterThanOrEqual(Math.floor(raster.width * 3)); + expect(height).toBeGreaterThanOrEqual(Math.floor(raster.height * 3)); + expect(inkedPixels).toBeGreaterThan(0); + }); +}); diff --git a/packages/math-block/src/exporterHelpers/renderMathToImage.test.ts b/packages/math-block/src/exporterHelpers/renderMathToImage.test.ts new file mode 100644 index 0000000000..73661f8214 --- /dev/null +++ b/packages/math-block/src/exporterHelpers/renderMathToImage.test.ts @@ -0,0 +1,67 @@ +import { exportImageToDataURL } from "@blocknote/core"; +import { describe, expect, it } from "vite-plus/test"; + +import { latexToMathSVG } from "./renderMathToImage.js"; + +describe("latexToMathSVG", () => { + it("converts LaTeX to a sized SVG image", () => { + const result = latexToMathSVG("e^{i\\pi} + 1 = 0", { + inline: true, + fontSize: 16, + }); + + if (result.error !== undefined) { + throw new Error(`Expected a successful conversion: ${result.error}`); + } + expect(result.image.mimeType).toBe("image/svg+xml"); + expect(result.image.width).toBeGreaterThan(0); + expect(result.image.height).toBeGreaterThan(0); + expect(new TextDecoder().decode(result.image.data)).toMatch(/^ { + const result = latexToMathSVG("\\invalidcommand{", { + inline: true, + fontSize: 16, + }); + + expect(result.error).toBeDefined(); + }); + + it("sets the SVG's intrinsic dimensions to the display size", () => { + const result = latexToMathSVG("e^{i\\pi} + 1 = 0", { + inline: true, + fontSize: 16, + }); + + if (result.error !== undefined) { + throw new Error(`Expected a successful conversion: ${result.error}`); + } + const svg = new TextDecoder().decode(result.image.data); + // MathJax's `ex`-based dimensions must be replaced with explicit ones, + // or renderers fall back to a default size. + expect(svg).toContain(`width="${Math.ceil(result.image.width)}"`); + expect(svg).toContain(`height="${Math.ceil(result.image.height)}"`); + expect(svg).not.toMatch(/width="[\d.]+ex"/); + expect(svg).not.toMatch(/height="[\d.]+ex"/); + }); + + it("round-trips through exportImageToDataURL", () => { + const result = latexToMathSVG("e^{i\\pi} + 1 = 0", { + inline: true, + fontSize: 16, + }); + + if (result.error !== undefined) { + throw new Error(`Expected a successful conversion: ${result.error}`); + } + const dataURL = exportImageToDataURL(result.image); + expect(dataURL).toMatch(/^data:image\/svg\+xml;base64,/); + const bytes = Uint8Array.from(atob(dataURL.split(",")[1]), (char) => + char.charCodeAt(0), + ); + expect(new TextDecoder().decode(bytes)).toBe( + new TextDecoder().decode(result.image.data), + ); + }); +}); diff --git a/packages/math-block/src/exporterHelpers/renderMathToImage.ts b/packages/math-block/src/exporterHelpers/renderMathToImage.ts new file mode 100644 index 0000000000..bd24f76e08 --- /dev/null +++ b/packages/math-block/src/exporterHelpers/renderMathToImage.ts @@ -0,0 +1,209 @@ +import type { ExportImage } from "@blocknote/core"; +import { liteAdaptor } from "mathjax-full/js/adaptors/liteAdaptor.js"; +import { RegisterHTMLHandler } from "mathjax-full/js/handlers/html.js"; +import { TeX } from "mathjax-full/js/input/tex.js"; +import TexError from "mathjax-full/js/input/tex/TexError.js"; +import { mathjax } from "mathjax-full/js/mathjax.js"; + +// `mathjax-full` ships CommonJS, and depending on the consumer's bundler +// interop the default import above is either the class itself or a +// `{ default: class }` namespace object (observed with Vite serving the +// package from source). Resolve whichever is the constructor - using the +// namespace directly makes `instanceof` throw "Right-hand side of +// 'instanceof' is not callable" on the first invalid formula. +function isTexError(error: unknown): error is InstanceType { + const texErrorClass: unknown = (TexError as any).default ?? TexError; + return typeof texErrorClass === "function" && error instanceof texErrorClass; +} +import { SVG } from "mathjax-full/js/output/svg.js"; + +// Registers the TeX packages listed in `TEX_PACKAGES` below - a curated set +// with roughly KaTeX's coverage (what the editor itself renders), rather +// than `AllPackages`, which would pull every package (mhchem, physics, +// bussproofs, ...) into the bundle. +import "mathjax-full/js/input/tex/ams/AmsConfiguration.js"; +import "mathjax-full/js/input/tex/boldsymbol/BoldsymbolConfiguration.js"; +import "mathjax-full/js/input/tex/braket/BraketConfiguration.js"; +import "mathjax-full/js/input/tex/cancel/CancelConfiguration.js"; +import "mathjax-full/js/input/tex/color/ColorConfiguration.js"; +import "mathjax-full/js/input/tex/mathtools/MathtoolsConfiguration.js"; +import "mathjax-full/js/input/tex/newcommand/NewcommandConfiguration.js"; +import "mathjax-full/js/input/tex/noundefined/NoUndefinedConfiguration.js"; +import "mathjax-full/js/input/tex/textmacros/TextMacrosConfiguration.js"; +import "mathjax-full/js/input/tex/unicode/UnicodeConfiguration.js"; + +const TEX_PACKAGES = [ + "base", + "ams", + "boldsymbol", + "braket", + "cancel", + "color", + "mathtools", + "newcommand", + "noundefined", + "textmacros", + "unicode", +]; + +// MathJax (rather than KaTeX, which the math block itself renders with) is +// used for image-based export: its SVG output is self-contained paths, so it +// rasterizes without needing the KaTeX webfonts. `mathjax-full` is an +// optional peer dependency - when exporting math to PDF it's already +// installed transitively via `@react-pdf/math`. +let mathDocument: ReturnType | undefined; +let documentAdaptor: ReturnType | undefined; + +function getMathDocument() { + if (!mathDocument || !documentAdaptor) { + documentAdaptor = liteAdaptor(); + RegisterHTMLHandler(documentAdaptor); + mathDocument = mathjax.document("", { + InputJax: new TeX({ + packages: TEX_PACKAGES, + // MathJax renders TeX errors as error text by default - throw + // instead, so the conversion boundary in `latexToMathSVG` can return + // them as typed errors. + formatError: (_jax: unknown, err: TexError) => { + throw err; + }, + }), + OutputJax: new SVG({ fontCache: "none" }), + }); + } + return { mathDocument, documentAdaptor }; +} + +/** + * An {@link ExportImage} known to hold SVG markup - what + * {@link latexToMathSVG} produces and rasterizers consume, so passing a + * raster image to a rasterizer is a compile-time error. + */ +export type SVGExportImage = ExportImage & { mimeType: "image/svg+xml" }; + +// The ex height (x-height) of MathJax's font, as a fraction of its em size - +// MathJax sizes its SVG output in `ex` units, and this converts them to the +// target's units via the surrounding font size. +const EX_PER_EM = 0.442; + +/** + * Converts LaTeX to a self-contained {@link SVGExportImage} (via MathJax). + * Synchronous and environment-independent. Invalid LaTeX is expected (the + * source is user input), so it's returned as a typed error - with a message + * safe to show to readers - rather than thrown. + * + * The image's `width`/`height` are display dimensions, derived from + * `fontSize` - the surrounding font's size in the target's units (points, + * CSS pixels, ...) - and also set as the SVG's intrinsic dimensions, so + * renderers display it at the right size. + */ +export function latexToMathSVG( + latex: string, + options: { inline: boolean; fontSize: number }, +): { error?: undefined; image: SVGExportImage } | { error: string } { + const { mathDocument, documentAdaptor } = getMathDocument(); + + let node: unknown; + try { + node = mathDocument.convert(latex, { display: !options.inline }); + } catch (error) { + // The boundary that converts MathJax's TeX-error throw (see + // `formatError` above) into the typed result. Only `TexError`s are + // expected (invalid user LaTeX) - anything else is a bug and propagates. + if (!isTexError(error)) { + throw error; + } + return { error: error.message }; + } + + // The conversion returns an `mjx-container` wrapper; only the `svg` + // element itself is needed. + const svgNode = documentAdaptor.firstChild(node as any) as any; + const widthEx = parseFloat(documentAdaptor.getAttribute(svgNode, "width")); + const heightEx = parseFloat(documentAdaptor.getAttribute(svgNode, "height")); + if ( + documentAdaptor.kind(svgNode) !== "svg" || + isNaN(widthEx) || + isNaN(heightEx) + ) { + throw new Error("No SVG found in MathJax output"); + } + + // MathJax sizes the SVG in `ex` units; replace them with explicit pixel + // dimensions, or renderers fall back to a default size. + const width = widthEx * options.fontSize * EX_PER_EM; + const height = heightEx * options.fontSize * EX_PER_EM; + documentAdaptor.setAttribute(svgNode, "width", Math.ceil(width)); + documentAdaptor.setAttribute(svgNode, "height", Math.ceil(height)); + + return { + image: { + mimeType: "image/svg+xml", + data: new TextEncoder().encode(documentAdaptor.outerHTML(svgNode)), + width, + height, + }, + }; +} + +/** + * Rasterizes an {@link SVGExportImage} (from {@link latexToMathSVG}) to a + * raster image - rendered above its display size so it stays sharp in the + * exported document, at a scale of the implementation's choosing; the + * returned image keeps the display dimensions. The default implementation + * ({@link rasterizeSVGInBrowser}) needs a browser; exporters running + * elsewhere plug in their own (e.g. backed by `@resvg/resvg-js`'s `fitTo` + * zoom or `sharp`'s density). + */ +export type RasterizeSVG = (svg: SVGExportImage) => Promise; + +const DEFAULT_RASTER_SCALE = 2; + +/** + * Rasterizes an {@link SVGExportImage} to a PNG via a canvas, at `scale` + * times its display size. The screen's device pixel ratio is deliberately + * not consulted for the scale, since the output goes into documents, not + * onto the current screen. Browser-only. + */ +export async function rasterizeSVGInBrowser( + svg: SVGExportImage, + scale: number = DEFAULT_RASTER_SCALE, +): Promise { + const width = Math.max(1, Math.ceil(svg.width * scale)); + const height = Math.max(1, Math.ceil(svg.height * scale)); + + // The scale goes into the SVG's intrinsic dimensions (rather than only + // the canvas): browsers rasterize an SVG image at its intrinsic size and + // upscale the bitmap when drawn larger, which would blur the output. + const svgElement = new DOMParser().parseFromString( + new TextDecoder().decode(svg.data), + "image/svg+xml", + ).documentElement; + svgElement.setAttribute("width", String(width)); + svgElement.setAttribute("height", String(height)); + + const image = new Image(); + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( + new XMLSerializer().serializeToString(svgElement), + )}`; + await image.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + canvas.getContext("2d")!.drawImage(image, 0, 0, canvas.width, canvas.height); + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/png"), + ); + if (!blob) { + throw new Error("Canvas produced no PNG data"); + } + + return { + mimeType: "image/png", + data: new Uint8Array(await blob.arrayBuffer()), + width: svg.width, + height: svg.height, + }; +} diff --git a/packages/math-block/src/helpers/getMathPlainTextContent.ts b/packages/math-block/src/helpers/getMathPlainTextContent.ts deleted file mode 100644 index fe1f558fce..0000000000 --- a/packages/math-block/src/helpers/getMathPlainTextContent.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Converts rich text content in math blocks/inline content to plain text. -// Should be removed once we add plain text support for blocks/inline content -export const getMathPlainTextContent = (content: unknown): string => { - if (typeof content === "string") { - return content; - } - - if (Array.isArray(content)) { - return content - .map((node) => - node && typeof node === "object" && "text" in node ? node.text : "", - ) - .join(""); - } - return ""; -}; diff --git a/packages/math-block/src/helpers/index.ts b/packages/math-block/src/helpers/index.ts index c39f400196..782657090d 100644 --- a/packages/math-block/src/helpers/index.ts +++ b/packages/math-block/src/helpers/index.ts @@ -1,4 +1,3 @@ -export * from "./getMathPlainTextContent.js"; export * from "./latexToHTMLString.js"; export * from "./render/index.js"; export * from "./toExternalHTML/index.js"; diff --git a/packages/math-block/src/i18n/dictionary.ts b/packages/math-block/src/i18n/dictionary.ts index 6a2737f066..88ec5dace1 100644 --- a/packages/math-block/src/i18n/dictionary.ts +++ b/packages/math-block/src/i18n/dictionary.ts @@ -1,4 +1,4 @@ -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, Exporter } from "@blocknote/core"; import { en } from "./locales/en.js"; @@ -15,3 +15,19 @@ export function getMathDictionary( ): MathDictionary { return ((editor.dictionary as any).math as MathDictionary | undefined) ?? en; } + +/** + * Returns the Math exporter strings. Exporters are localized independently + * of an editor: the host passes a dictionary to the exporter's options + * (see `ExporterOptions.dictionary`), and the math strings are read from + * its `math` section - the same shape merged into editor dictionaries - + * falling back to the bundled English strings. + */ +export function getMathExporterDictionary( + exporter: Exporter, +): MathDictionary["exporter"] { + return ( + ((exporter.options.dictionary as any)?.math as MathDictionary | undefined) + ?.exporter ?? en.exporter + ); +} diff --git a/packages/math-block/src/i18n/locales/ar.ts b/packages/math-block/src/i18n/locales/ar.ts index 5e6bfd602d..3cf96a16bb 100644 --- a/packages/math-block/src/i18n/locales/ar.ts +++ b/packages/math-block/src/i18n/locales/ar.ts @@ -28,4 +28,8 @@ export const ar: MathDictionary = { block_type_select: { name: "معادلة", }, + exporter: { + invalid_formula: (source: string) => + `صيغة غير صالحة "\u2068${source}\u2069"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/de.ts b/packages/math-block/src/i18n/locales/de.ts index 7c0024dee4..319d2679ce 100644 --- a/packages/math-block/src/i18n/locales/de.ts +++ b/packages/math-block/src/i18n/locales/de.ts @@ -28,4 +28,7 @@ export const de: MathDictionary = { block_type_select: { name: "Gleichung", }, + exporter: { + invalid_formula: (source: string) => `Ungültige Formel "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/en.ts b/packages/math-block/src/i18n/locales/en.ts index 8b713b3ecb..44f42f5a00 100644 --- a/packages/math-block/src/i18n/locales/en.ts +++ b/packages/math-block/src/i18n/locales/en.ts @@ -26,4 +26,7 @@ export const en = { block_type_select: { name: "Equation", }, + exporter: { + invalid_formula: (source: string) => `Invalid formula "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/es.ts b/packages/math-block/src/i18n/locales/es.ts index 1d8d9df51b..cdaacfbbdc 100644 --- a/packages/math-block/src/i18n/locales/es.ts +++ b/packages/math-block/src/i18n/locales/es.ts @@ -28,4 +28,7 @@ export const es: MathDictionary = { block_type_select: { name: "Ecuación", }, + exporter: { + invalid_formula: (source: string) => `Fórmula no válida "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/fa.ts b/packages/math-block/src/i18n/locales/fa.ts index 9ffdaa9b16..309896aa75 100644 --- a/packages/math-block/src/i18n/locales/fa.ts +++ b/packages/math-block/src/i18n/locales/fa.ts @@ -28,4 +28,8 @@ export const fa: MathDictionary = { block_type_select: { name: "معادله", }, + exporter: { + invalid_formula: (source: string) => + `فرمول نامعتبر "\u2068${source}\u2069"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/fr.ts b/packages/math-block/src/i18n/locales/fr.ts index 65fca7a46f..005eaf3d6b 100644 --- a/packages/math-block/src/i18n/locales/fr.ts +++ b/packages/math-block/src/i18n/locales/fr.ts @@ -28,4 +28,7 @@ export const fr: MathDictionary = { block_type_select: { name: "Équation", }, + exporter: { + invalid_formula: (source: string) => `Formule non valide "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/he.ts b/packages/math-block/src/i18n/locales/he.ts index ac15cf88f8..efa5b5cb8c 100644 --- a/packages/math-block/src/i18n/locales/he.ts +++ b/packages/math-block/src/i18n/locales/he.ts @@ -28,4 +28,8 @@ export const he: MathDictionary = { block_type_select: { name: "משוואה", }, + exporter: { + invalid_formula: (source: string) => + `נוסחה לא חוקית "\u2068${source}\u2069"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/hr.ts b/packages/math-block/src/i18n/locales/hr.ts index 772e1f08e9..26234cb776 100644 --- a/packages/math-block/src/i18n/locales/hr.ts +++ b/packages/math-block/src/i18n/locales/hr.ts @@ -28,4 +28,7 @@ export const hr: MathDictionary = { block_type_select: { name: "Jednadžba", }, + exporter: { + invalid_formula: (source: string) => `Neispravna formula "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/is.ts b/packages/math-block/src/i18n/locales/is.ts index 52235dbbd0..fe4595bad3 100644 --- a/packages/math-block/src/i18n/locales/is.ts +++ b/packages/math-block/src/i18n/locales/is.ts @@ -28,4 +28,7 @@ export const is: MathDictionary = { block_type_select: { name: "Jafna", }, + exporter: { + invalid_formula: (source: string) => `Ógild formúla "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/it.ts b/packages/math-block/src/i18n/locales/it.ts index be0deed2a8..7e3e8766f4 100644 --- a/packages/math-block/src/i18n/locales/it.ts +++ b/packages/math-block/src/i18n/locales/it.ts @@ -28,4 +28,7 @@ export const it: MathDictionary = { block_type_select: { name: "Equazione", }, + exporter: { + invalid_formula: (source: string) => `Formula non valida "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/ja.ts b/packages/math-block/src/i18n/locales/ja.ts index 1cda794ed6..fb45f14689 100644 --- a/packages/math-block/src/i18n/locales/ja.ts +++ b/packages/math-block/src/i18n/locales/ja.ts @@ -28,4 +28,7 @@ export const ja: MathDictionary = { block_type_select: { name: "数式", }, + exporter: { + invalid_formula: (source: string) => `無効な数式 "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/ko.ts b/packages/math-block/src/i18n/locales/ko.ts index 057bb56464..4ea265326c 100644 --- a/packages/math-block/src/i18n/locales/ko.ts +++ b/packages/math-block/src/i18n/locales/ko.ts @@ -28,4 +28,7 @@ export const ko: MathDictionary = { block_type_select: { name: "수식", }, + exporter: { + invalid_formula: (source: string) => `잘못된 수식 "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/nl.ts b/packages/math-block/src/i18n/locales/nl.ts index 61b9883ab8..ad547a75e2 100644 --- a/packages/math-block/src/i18n/locales/nl.ts +++ b/packages/math-block/src/i18n/locales/nl.ts @@ -28,4 +28,7 @@ export const nl: MathDictionary = { block_type_select: { name: "Formule", }, + exporter: { + invalid_formula: (source: string) => `Ongeldige formule "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/no.ts b/packages/math-block/src/i18n/locales/no.ts index 1cd7bac9e8..3abc349e2d 100644 --- a/packages/math-block/src/i18n/locales/no.ts +++ b/packages/math-block/src/i18n/locales/no.ts @@ -28,4 +28,7 @@ export const no: MathDictionary = { block_type_select: { name: "Ligning", }, + exporter: { + invalid_formula: (source: string) => `Ugyldig formel "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/pl.ts b/packages/math-block/src/i18n/locales/pl.ts index 89e444e6db..1222219735 100644 --- a/packages/math-block/src/i18n/locales/pl.ts +++ b/packages/math-block/src/i18n/locales/pl.ts @@ -28,4 +28,7 @@ export const pl: MathDictionary = { block_type_select: { name: "Równanie", }, + exporter: { + invalid_formula: (source: string) => `Nieprawidłowa formuła "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/pt.ts b/packages/math-block/src/i18n/locales/pt.ts index 89ec838aa2..0c693cd7e4 100644 --- a/packages/math-block/src/i18n/locales/pt.ts +++ b/packages/math-block/src/i18n/locales/pt.ts @@ -28,4 +28,7 @@ export const pt: MathDictionary = { block_type_select: { name: "Equação", }, + exporter: { + invalid_formula: (source: string) => `Fórmula inválida "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/ru.ts b/packages/math-block/src/i18n/locales/ru.ts index 4c2c7efe35..9ae7f6bfc8 100644 --- a/packages/math-block/src/i18n/locales/ru.ts +++ b/packages/math-block/src/i18n/locales/ru.ts @@ -28,4 +28,7 @@ export const ru: MathDictionary = { block_type_select: { name: "Формула", }, + exporter: { + invalid_formula: (source: string) => `Недопустимая формула "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/sk.ts b/packages/math-block/src/i18n/locales/sk.ts index e4da15839c..3356fe2f62 100644 --- a/packages/math-block/src/i18n/locales/sk.ts +++ b/packages/math-block/src/i18n/locales/sk.ts @@ -28,4 +28,7 @@ export const sk: MathDictionary = { block_type_select: { name: "Rovnica", }, + exporter: { + invalid_formula: (source: string) => `Neplatný vzorec "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/uk.ts b/packages/math-block/src/i18n/locales/uk.ts index fddc8b5458..4ef1fe3b5e 100644 --- a/packages/math-block/src/i18n/locales/uk.ts +++ b/packages/math-block/src/i18n/locales/uk.ts @@ -28,4 +28,7 @@ export const uk: MathDictionary = { block_type_select: { name: "Формула", }, + exporter: { + invalid_formula: (source: string) => `Недійсна формула "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/uz.ts b/packages/math-block/src/i18n/locales/uz.ts index 69303b7fbd..2da22e130f 100644 --- a/packages/math-block/src/i18n/locales/uz.ts +++ b/packages/math-block/src/i18n/locales/uz.ts @@ -28,4 +28,7 @@ export const uz: MathDictionary = { block_type_select: { name: "Tenglama", }, + exporter: { + invalid_formula: (source: string) => `Yaroqsiz formula "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/vi.ts b/packages/math-block/src/i18n/locales/vi.ts index f17ce1cb95..cbce3e0fa2 100644 --- a/packages/math-block/src/i18n/locales/vi.ts +++ b/packages/math-block/src/i18n/locales/vi.ts @@ -44,4 +44,7 @@ export const vi: MathDictionary = { block_type_select: { name: "Phương trình", }, + exporter: { + invalid_formula: (source: string) => `Công thức không hợp lệ "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/zh-tw.ts b/packages/math-block/src/i18n/locales/zh-tw.ts index 47d00eabdf..de5deb445f 100644 --- a/packages/math-block/src/i18n/locales/zh-tw.ts +++ b/packages/math-block/src/i18n/locales/zh-tw.ts @@ -28,4 +28,7 @@ export const zhTW: MathDictionary = { block_type_select: { name: "方程式", }, + exporter: { + invalid_formula: (source: string) => `無效的公式 "${source}"`, + }, }; diff --git a/packages/math-block/src/i18n/locales/zh.ts b/packages/math-block/src/i18n/locales/zh.ts index 10b43f0fda..912829eded 100644 --- a/packages/math-block/src/i18n/locales/zh.ts +++ b/packages/math-block/src/i18n/locales/zh.ts @@ -28,4 +28,7 @@ export const zh: MathDictionary = { block_type_select: { name: "公式", }, + exporter: { + invalid_formula: (source: string) => `无效的公式 "${source}"`, + }, }; diff --git a/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx b/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx index e2de6c5d04..c036f5e8b6 100644 --- a/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx +++ b/packages/math-block/src/inlineContent/helpers/render/MathInlinePreviewWithPopup.tsx @@ -6,7 +6,6 @@ import { } from "@blocknote/react"; import { TbMathFunction } from "react-icons/tb"; -import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js"; import { useLatexToMathMLString } from "../../../helpers/render/useLatexToMathML.js"; import { getMathDictionary } from "../../../i18n/dictionary.js"; import { MathInlineContentConfig } from "../../createReactMathInlineContentSpec.js"; @@ -17,7 +16,7 @@ export const MathInlinePreviewWithPopup = ( StyleSchema >, ) => { - const source = getMathPlainTextContent(props.inlineContent.content).trim(); + const source = props.inlineContent.content.trim(); const { mathMLString, error } = useLatexToMathMLString(source, true); const dict = getMathDictionary(props.editor).inline; diff --git a/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx b/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx index c2f8b9921b..a313d8a1ae 100644 --- a/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx +++ b/packages/math-block/src/inlineContent/helpers/toExternalHTML/InlineMathMLElement.tsx @@ -2,9 +2,8 @@ import { StyleSchema } from "@blocknote/core"; import { ReactCustomInlineContentRenderProps } from "@blocknote/react"; import type { ComponentType } from "react"; -import { MathInlineContentConfig } from "../../createReactMathInlineContentSpec.js"; -import { getMathPlainTextContent } from "../../../helpers/getMathPlainTextContent.js"; import { latexToMathMLElement } from "../../../helpers/toExternalHTML/latexToMathMLElement.js"; +import { MathInlineContentConfig } from "../../createReactMathInlineContentSpec.js"; export const InlineMathMLElement = ({ inlineContent, @@ -12,8 +11,7 @@ export const InlineMathMLElement = ({ MathInlineContentConfig, StyleSchema >) => { - const source = getMathPlainTextContent(inlineContent.content); - const { mathMLElement } = latexToMathMLElement(source, true); + const { mathMLElement } = latexToMathMLElement(inlineContent.content, true); if (!mathMLElement) { return null; } @@ -31,7 +29,7 @@ export const InlineMathMLElement = ({ ); diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml b/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/content.xml similarity index 89% rename from packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml rename to packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/content.xml index 3ae4641846..aefe0cdde9 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/content.xml +++ b/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/content.xml @@ -22,9 +22,6 @@ - - - @@ -35,7 +32,7 @@
Inline math: - + diff --git a/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/objects.xml b/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/objects.xml new file mode 100644 index 0000000000..db9967e38b --- /dev/null +++ b/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/objects.xml @@ -0,0 +1,81 @@ + + + + + + + + a + + + 2 + + + + = + + + + + + b + + + 2 + + + + + + + + + c + + + 2 + + + + + + + a^2 = \sqrt{b^2 + c^2} + + + + + + + + + + + e + + + + i + + + π + + + + + + + + + 1 + + + = + + + 0 + + + + e^{i\pi} + 1 = 0 + + + \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/styles.xml b/packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/styles.xml similarity index 100% rename from packages/xl-odt-exporter/src/odt/__snapshots__/withMathMappings/styles.xml rename to packages/math-block/src/odt-exporter/__snapshots__/withMathMappings/styles.xml diff --git a/packages/math-block/src/odt-exporter/index.ts b/packages/math-block/src/odt-exporter/index.ts new file mode 100644 index 0000000000..07ada74686 --- /dev/null +++ b/packages/math-block/src/odt-exporter/index.ts @@ -0,0 +1,180 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { plainContentToString } from "@blocknote/core"; +import { ODTExporter } from "@blocknote/xl-odt-exporter"; +import { createElement } from "react"; + +import { latexToMathML } from "../exporterHelpers/latexToMathML.js"; +import { getMathExporterDictionary } from "../i18n/dictionary.js"; + +// The ODT elements are created with `createElement` string tags rather than +// JSX: the ODT exporter's namespaced tags (`text:p`, `draw:frame`, ...) need +// JSX runtime module augmentation plus a transform that allows namespaces, +// neither of which this package sets up for its React sources. + +type MathBlock = BlockFromConfigNoChildren< + BlockConfig<"mathBlock", {}, "plain">, + any, + any +>; + +type InlineMath = { type: "math"; content: string }; + +// A formula object, anchored as a character so it can sit inline among text. +// The MathML goes into an object sub-document (rather than inline into the +// frame) and the frame gets no explicit size, with a graphic style derived +// from the built-in "Formula" style - this exact combination makes +// LibreOffice load the formula as a real formula object and compute its +// natural size (sized frames get the formula scaled-to-fit instead, and +// inline MathML renders at zero size). +function formulaFrame(exporter: ODTExporter, mathML: string) { + const objectPath = exporter.registerObject( + '\n' + mathML, + ); + const styleName = exporter.registerStyle((name) => + createElement( + "style:style", + { + "style:family": "graphic", + "style:name": name, + "style:parent-style-name": "Formula", + }, + createElement("style:graphic-properties", { + "style:vertical-pos": "middle", + "style:vertical-rel": "text", + }), + ), + ); + + return createElement( + "draw:frame", + { "draw:style-name": styleName, "text:anchor-type": "as-char" }, + createElement("draw:object", { + "xlink:href": objectPath, + "xlink:type": "simple", + "xlink:show": "embed", + "xlink:actuate": "onLoad", + }), + ); +} + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the formula by its source. The parser's message +// is deliberately NOT rendered: it's authoring detail (and untranslated +// English) - the editor is where the author sees and fixes it. Styled muted like the other +// exporters' placeholders. +function errorText(source: string, exporter: ODTExporter) { + const styleName = exporter.registerStyle((name) => + createElement( + "style:style", + { "style:family": "text", "style:name": name }, + createElement("style:text-properties", { + "fo:font-style": "italic", + "fo:color": "#999999", + }), + ), + ); + + return createElement( + "text:span", + { "text:style-name": styleName }, + getMathExporterDictionary(exporter).invalid_formula(source), + ); +} + +/** + * ODT block mapping for `@blocknote/math-block` that renders math blocks as + * native (editable) formula objects. Invalid LaTeX renders an error + * placeholder (mirroring the editor): + * + * ```ts + * import { mathBlockMapping } from "@blocknote/math-block/odt-exporter"; + * + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * blockMapping: { + * ...odtDefaultSchemaMappings.blockMapping, + * mathBlock: mathBlockMapping, + * }, + * }); + * ``` + */ +export function mathBlockMapping( + block: MathBlock, + exporter: Exporter, +) { + // Only the ODTExporter invokes ODT mappings, but mapping signatures are + // contravariant in the exporter parameter, so requiring the subclass here + // wouldn't satisfy the mapping type - hence the base type + cast. + const odtExporter = exporter as ODTExporter; + const source = plainContentToString(block.content); + if (!source.trim()) { + return createElement("text:p"); + } + + const mathML = latexToMathML(source, false); + if (mathML.error !== undefined) { + return createElement("text:p", null, errorText(source, odtExporter)); + } + + const styleName = odtExporter.registerStyle((name) => + createElement( + "style:style", + { + "style:family": "paragraph", + "style:name": name, + "style:parent-style-name": "Standard", + }, + createElement("style:paragraph-properties", { + "fo:text-align": "center", + }), + ), + ); + + return createElement( + "text:p", + { "text:style-name": styleName }, + formulaFrame(odtExporter, mathML.mathML), + ); +} + +/** + * ODT inline content mapping for `@blocknote/math-block` that renders inline + * math as native (editable) formula objects. Invalid LaTeX renders an error + * placeholder (mirroring the editor): + * + * ```ts + * import { inlineMathMapping } from "@blocknote/math-block/odt-exporter"; + * + * new ODTExporter(schema, { + * ...odtDefaultSchemaMappings, + * inlineContentMapping: { + * ...odtDefaultSchemaMappings.inlineContentMapping, + * math: inlineMathMapping, + * }, + * }); + * ``` + */ +export function inlineMathMapping( + inlineContent: InlineMath, + exporter: Exporter, +) { + // Only the ODTExporter invokes ODT mappings, but mapping signatures are + // contravariant in the exporter parameter, so requiring the subclass here + // wouldn't satisfy the mapping type - hence the base type + cast. + const odtExporter = exporter as ODTExporter; + const source = inlineContent.content; + if (!source.trim()) { + return createElement("text:span"); + } + + const mathML = latexToMathML(source, true); + if (mathML.error !== undefined) { + return errorText(source, odtExporter); + } + + return formulaFrame(odtExporter, mathML.mathML); +} diff --git a/packages/math-block/src/odt-exporter/odtExporter.test.ts b/packages/math-block/src/odt-exporter/odtExporter.test.ts new file mode 100644 index 0000000000..8fa245c29e --- /dev/null +++ b/packages/math-block/src/odt-exporter/odtExporter.test.ts @@ -0,0 +1,124 @@ +import { + BlockNoteSchema, + createPageBreakBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import { + ODTExporter, + odtDefaultSchemaMappings, +} from "@blocknote/xl-odt-exporter"; +import { testODTDocumentAgainstSnapshot } from "@shared/util/odtTestUtil.js"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; +import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; + +import { inlineMathMapping, mathBlockMapping } from "./index.js"; + +beforeAll(async () => { + // @ts-expect-error - Blob polyfill for Node test environment + globalThis.Blob = (await import("node:buffer")).Blob; +}); + +describe("odt exporter mappings", () => { + it("should render error placeholders for invalid LaTeX", async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just map + // the block JSON. + const mappings = { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + }, + inlineContentMapping: { + ...odtDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, + }; + const exporter = new ODTExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + const odt = await exporter.toODTDocument([ + { + id: "1", + type: "mathBlock", + props: {}, + content: [{ type: "text", text: "\\invalidcommand{", styles: {} }], + children: [], + }, + { + id: "2", + type: "paragraph", + props: {}, + content: [ + { type: "text", text: "Broken: ", styles: {} }, + { type: "math", props: {}, content: "\\invalidcommand{" }, + ], + children: [], + }, + ] as any); + const zipReader = new ZipReader(new BlobReader(odt)); + const entries = await zipReader.getEntries(); + const contentXML = entries.find( + (entry) => entry.filename === "content.xml", + ) as FileEntry; + const content = await contentXML.getData(new TextWriter()); + + // Mirrors the editor's error placeholder rather than dumping the LaTeX + // source on readers - once for the block, once for the inline math. + expect(content.match(/Invalid formula/g)).toHaveLength(2); + expect(content).not.toContain("draw:object"); + }); + + it("should export math as native formulas", { timeout: 10000 }, async () => { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just + // map the block JSON. + const mappings = { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + }, + inlineContentMapping: { + ...odtDefaultSchemaMappings.inlineContentMapping, + math: inlineMathMapping, + }, + }; + const exporter = new ODTExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + // The math block & inline math paragraph from the shared test document. + const odt = await exporter.toODTDocument( + testDocumentWithSourceBlocks.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + ); + // The math block & the inline math each embed one formula object. + await testODTDocumentAgainstSnapshot(odt, { + styles: "__snapshots__/withMathMappings/styles.xml", + content: "__snapshots__/withMathMappings/content.xml", + objects: { + snapshot: "__snapshots__/withMathMappings/objects.xml", + expectedCount: 2, + }, + }); + }); +}); diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithMathMappings.jsx b/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx similarity index 89% rename from packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithMathMappings.jsx rename to packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx index 1181dbd050..cd3a3b0a49 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithMathMappings.jsx +++ b/packages/math-block/src/pdf-exporter/__snapshots__/exampleWithMathMappings.jsx @@ -46,13 +46,13 @@ Inline math:{' '} - - {`e^{i\pi} + 1 = 0`} - + />
diff --git a/packages/math-block/src/pdf-exporter/index.tsx b/packages/math-block/src/pdf-exporter/index.tsx new file mode 100644 index 0000000000..32b5ca5087 --- /dev/null +++ b/packages/math-block/src/pdf-exporter/index.tsx @@ -0,0 +1,174 @@ +import type { + BlockConfig, + BlockFromConfigNoChildren, + Exporter, +} from "@blocknote/core"; +import { exportImageToDataURL, plainContentToString } from "@blocknote/core"; +import { Math } from "@react-pdf/math"; +import { Image, Text, View } from "@react-pdf/renderer"; + +import { + latexToMathSVG, + RasterizeSVG, + rasterizeSVGInBrowser, +} from "../exporterHelpers/renderMathToImage.js"; +import { getMathExporterDictionary } from "../i18n/dictionary.js"; + +type MathBlock = BlockFromConfigNoChildren< + BlockConfig<"mathBlock", {}, "plain">, + any, + any +>; + +type InlineMath = { type: "math"; content: string }; + +export { + latexToMathSVG, + rasterizeSVGInBrowser, +} from "../exporterHelpers/renderMathToImage.js"; +export type { + RasterizeSVG, + SVGExportImage, +} from "../exporterHelpers/renderMathToImage.js"; + +// The PDF exporter's body text is 12pt (16px at 0.75 pixels per point). +const FONT_SIZE_POINTS = 16 * 0.75; + +// Mirrors the editor, which shows the error state in the preview +// placeholder, identifying the formula by its source. The parser's message +// is deliberately NOT rendered: it's authoring detail (and untranslated +// English) - the editor is where the author sees and fixes it. +function errorText( + exporter: Exporter, + source: string, + key: string, +) { + return ( + + {getMathExporterDictionary(exporter).invalid_formula(source)} + + ); +} + +/** + * PDF block mapping for `@blocknote/math-block` that renders math blocks as + * actual formulas (via `@react-pdf/math`, which converts the LaTeX to SVG + * paths with MathJax). Invalid LaTeX renders an error placeholder + * (mirroring the editor): + * + * ```ts + * import { mathBlockMapping } from "@blocknote/math-block/pdf-exporter"; + * + * new PDFExporter(schema, { + * ...pdfDefaultSchemaMappings, + * blockMapping: { + * ...pdfDefaultSchemaMappings.blockMapping, + * mathBlock: mathBlockMapping, + * }, + * }); + * ``` + */ +export function mathBlockMapping( + block: MathBlock, + exporter: Exporter, +) { + const source = plainContentToString(block.content); + if (!source.trim()) { + return ; + } + + // `Math` renders MathJax's own error output for invalid LaTeX; validate + // up front to render the editor-style error placeholder instead. + const validation = latexToMathSVG(source, { + inline: false, + fontSize: FONT_SIZE_POINTS, + }); + if (validation.error !== undefined) { + return ( + + {errorText(exporter, source, "math-error")} + + ); + } + + return ( + + {source} + + ); +} + +/** + * Creates a PDF inline content mapping for `@blocknote/math-block` that + * renders inline math as formulas, rasterized to images that flow inline + * with the text (react-pdf drops SVG elements inside `Text`, so the vector + * output used for math blocks isn't an option here). Note that the image + * sits on the text baseline, so expressions with depth (fractions, + * subscripts) render slightly raised - and it's sized for the exporter's + * 12pt body text (mappings get no font context from react-pdf), so inline + * math inside headings renders at body-text size. + * + * Rasterization runs in the browser by default; when exporting elsewhere + * (e.g. server-side), pass a `rasterize` function backed by an SVG + * rasterizer such as `@resvg/resvg-js` or `sharp`. Invalid LaTeX renders an + * error placeholder (mirroring the editor): + * + * ```ts + * import { createInlineMathMapping } from "@blocknote/math-block/pdf-exporter"; + * + * new PDFExporter(schema, { + * ...pdfDefaultSchemaMappings, + * inlineContentMapping: { + * ...pdfDefaultSchemaMappings.inlineContentMapping, + * math: createInlineMathMapping({ rasterize }), + * }, + * }); + * ``` + */ +export function createInlineMathMapping(options?: { + rasterize?: RasterizeSVG; +}) { + return ( + inlineContent: InlineMath, + exporter: Exporter, + ) => { + const source = inlineContent.content; + if (!source.trim()) { + return ; + } + + const rasterize = options?.rasterize ?? rasterizeSVGInBrowser; + if (!options?.rasterize && typeof document === "undefined") { + throw new Error( + "Rendering inline math requires rasterizing SVGs, which the built-in rasterizer can only do in the browser. When exporting elsewhere, pass a `rasterize` function to `createInlineMathMapping` (e.g. backed by @resvg/resvg-js or sharp).", + ); + } + + // The metrics are needed synchronously for react-pdf's text layout; only + // the rasterization itself is deferred, via react-pdf's support for + // async `src` functions (resolved before layout). Rasterization failures + // are unexpected and propagate - react-pdf skips the image and warns. + const result = latexToMathSVG(source, { + inline: true, + fontSize: FONT_SIZE_POINTS, + }); + if (result.error !== undefined) { + return errorText(exporter, source, "inlineMath"); + } + + return ( + rasterize(result.image).then(exportImageToDataURL)} + style={{ width: result.image.width, height: result.image.height }} + /> + ); + }; +} + +/** + * PDF inline content mapping for `@blocknote/math-block` with the default + * options - see {@link createInlineMathMapping}. Browser-only; when + * exporting elsewhere, use the factory to pass a `rasterize` function. + */ +export const inlineMathMapping = createInlineMathMapping(); diff --git a/packages/math-block/src/pdf-exporter/pdfExporter.test.tsx b/packages/math-block/src/pdf-exporter/pdfExporter.test.tsx new file mode 100644 index 0000000000..46dde732f7 --- /dev/null +++ b/packages/math-block/src/pdf-exporter/pdfExporter.test.tsx @@ -0,0 +1,140 @@ +import { + BlockNoteSchema, + createPageBreakBlockSpec, + defaultBlockSpecs, + ExportImage, +} from "@blocknote/core"; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import reactElementToJSXString from "react-element-to-jsx-string"; +import { describe, expect, it } from "vite-plus/test"; + +import { + createInlineMathMapping, + inlineMathMapping, + mathBlockMapping, +} from "./index.js"; + +// A stub rasterizer, standing in for e.g. @resvg/resvg-js on a server - the +// real (browser-only) rasterization is covered by the browser test suite. +const rasterize = async (svg: ExportImage) => ({ + mimeType: "image/png", + data: new Uint8Array([0, 0, 0]), + width: svg.width, + height: svg.height, +}); + +function createExporter( + inlineMath: ReturnType, +) { + // Assembled outside the constructor call as the schema doesn't include + // the math specs - like the default mappings, the math entries just map + // the block JSON. + const mappings = { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + mathBlock: mathBlockMapping, + }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + math: inlineMath, + }, + }; + + return new PDFExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + mappings, + ); +} + +describe("pdf exporter mappings", () => { + it("should export math as formulas and inline math as images", async () => { + const exporter = createExporter(createInlineMathMapping({ rasterize })); + + // The math block & inline math paragraph from the shared test document. + const transformed = await exporter.toReactPDFDocument( + testDocumentWithSourceBlocks.filter((block) => + ["math-block", "paragraph-with-inline-math"].includes(block.id), + ), + ); + const str = reactElementToJSXString(transformed); + + await expect(str).toMatchFileSnapshot( + "__snapshots__/exampleWithMathMappings.jsx", + ); + }); + + it("should render error placeholders for invalid LaTeX", async () => { + const exporter = createExporter(createInlineMathMapping({ rasterize })); + + const transformed = await exporter.toReactPDFDocument([ + { + id: "1", + type: "mathBlock", + props: {}, + content: [{ type: "text", text: "\\invalidcommand{", styles: {} }], + children: [], + }, + { + id: "2", + type: "paragraph", + props: {}, + content: [ + { type: "text", text: "Broken: ", styles: {} }, + { type: "math", props: {}, content: "\\invalidcommand{" }, + ], + children: [], + }, + ] as any); + const str = reactElementToJSXString(transformed); + + // Mirrors the editor's error placeholder rather than dumping the LaTeX + // source on readers - once for the block, once for the inline math. + expect(str.match(/Invalid formula/g)).toHaveLength(2); + }); + + it("should render empty math as nothing", async () => { + // Empty source isn't an error - there's just nothing to render (and no + // rasterizer is needed). + const exporter = createExporter(inlineMathMapping); + + const transformed = await exporter.toReactPDFDocument([ + { id: "1", type: "mathBlock", props: {}, content: [], children: [] }, + { + id: "2", + type: "paragraph", + props: {}, + content: [{ type: "math", props: {}, content: "" }], + children: [], + }, + ] as any); + const str = reactElementToJSXString(transformed); + + expect(str).not.toContain("Invalid formula"); + expect(str).not.toContain("Math"); + }); + + it("should throw a descriptive error without a rasterizer outside the browser", async () => { + // The default mapping's built-in rasterizer only works in the browser, + // and silently degrading is worse than failing loudly - the error names + // the `rasterize` option to pass. + const exporter = createExporter(inlineMathMapping); + + await expect( + exporter.toReactPDFDocument( + testDocumentWithSourceBlocks.filter( + (block) => block.id === "paragraph-with-inline-math", + ), + ), + ).rejects.toThrow("pass a `rasterize` function"); + }); +}); diff --git a/packages/math-block/tsconfig.json b/packages/math-block/tsconfig.json index c74ac34642..2d8bcd4a25 100644 --- a/packages/math-block/tsconfig.json +++ b/packages/math-block/tsconfig.json @@ -19,7 +19,15 @@ "declarationDir": "types", "composite": true, "skipLibCheck": true, - "emitDeclarationOnly": true + "emitDeclarationOnly": true, + "paths": { + "@shared/*": ["../../shared/*"] + } }, - "include": ["src"] + "include": ["src"], + "references": [ + { + "path": "../../shared" + } + ] } diff --git a/packages/math-block/vite.config.ts b/packages/math-block/vite.config.ts index 3d0202618d..e9d8aafbfa 100644 --- a/packages/math-block/vite.config.ts +++ b/packages/math-block/vite.config.ts @@ -1,6 +1,6 @@ import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // https://vitejs.dev/config/ @@ -21,6 +21,16 @@ export default defineConfig( }, test: { setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], + }, + // The ODT exporter sources (loaded via the test aliases) use JSX + // namespace tags (e.g. ), which Vite's oxc rejects by default. + oxc: { + jsx: { + throwIfNamespace: false, + }, }, plugins: [webpackStats() as any], // used so that vitest resolves the core package from the sources instead of the built version @@ -29,9 +39,30 @@ export default defineConfig( conf.command === "build" ? ({} as Record) : ({ + "@shared": path.resolve(__dirname, "../../shared/"), // load live from sources with live reload working "@blocknote/core": path.resolve(__dirname, "../core/src/"), "@blocknote/react": path.resolve(__dirname, "../react/src/"), + "@blocknote/xl-docx-exporter": path.resolve( + __dirname, + "../xl-docx-exporter/src/", + ), + "@blocknote/xl-email-exporter": path.resolve( + __dirname, + "../xl-email-exporter/src/", + ), + "@blocknote/xl-multi-column": path.resolve( + __dirname, + "../xl-multi-column/src/", + ), + "@blocknote/xl-odt-exporter": path.resolve( + __dirname, + "../xl-odt-exporter/src/", + ), + "@blocknote/xl-pdf-exporter": path.resolve( + __dirname, + "../xl-pdf-exporter/src/", + ), } as Record), }, build: { @@ -39,6 +70,22 @@ export default defineConfig( lib: { entry: { "blocknote-math-block": path.resolve(__dirname, "src/index.ts"), + "docx-exporter": path.resolve( + __dirname, + "src/docx-exporter/index.ts", + ), + "odt-exporter": path.resolve( + __dirname, + "src/odt-exporter/index.ts", + ), + "pdf-exporter": path.resolve( + __dirname, + "src/pdf-exporter/index.tsx", + ), + "email-exporter": path.resolve( + __dirname, + "src/email-exporter/index.tsx", + ), }, name: "blocknote-math-block", formats: ["es", "cjs"], diff --git a/packages/xl-docx-exporter/package.json b/packages/xl-docx-exporter/package.json index 1382da3db6..93f69a7611 100644 --- a/packages/xl-docx-exporter/package.json +++ b/packages/xl-docx-exporter/package.json @@ -46,16 +46,6 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" - }, - "./diagram-block": { - "types": "./types/src/diagram-block/index.d.ts", - "import": "./dist/diagram-block.js", - "require": "./dist/diagram-block.cjs" - }, - "./math-block": { - "types": "./types/src/math-block/index.d.ts", - "import": "./dist/math-block.js", - "require": "./dist/math-block.cjs" } }, "scripts": { @@ -71,15 +61,11 @@ "@blocknote/xl-multi-column": "workspace:^", "buffer": "^6.0.3", "docx": "^9.6.1", - "image-meta": "^0.2.2", - "mathml2omml": "^0.5.0" + "image-meta": "^0.2.2" }, "devDependencies": { - "@blocknote/diagram-block": "workspace:^", "@blocknote/shared": "workspace:^", - "@types/katex": "^0.16.7", "@types/react": "^19.2.3", - "katex": "^0.16.11", "@types/react-dom": "^19.2.3", "@zip.js/zip.js": "^2.8.8", "react": "^19.2.5", @@ -92,16 +78,6 @@ }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", - "@blocknote/diagram-block": "workspace:^", - "katex": "^0.16.0" - }, - "peerDependenciesMeta": { - "@blocknote/diagram-block": { - "optional": true - }, - "katex": { - "optional": true - } + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } } diff --git a/packages/xl-docx-exporter/src/diagram-block/index.ts b/packages/xl-docx-exporter/src/diagram-block/index.ts deleted file mode 100644 index 4a81bc0253..0000000000 --- a/packages/xl-docx-exporter/src/diagram-block/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { - getDiagramPlainTextContent, - renderDiagramToImage, -} from "@blocknote/diagram-block"; -import { AlignmentType, ImageRun, Paragraph } from "docx"; - -import { docxBlockMappingForDefaultSchema } from "../docx/defaultSchema/blocks.js"; - -/** - * Block mapping for `@blocknote/diagram-block` that embeds diagrams as - * images instead of their Mermaid source: - * - * ```ts - * new DOCXExporter(schema, { - * ...docxDefaultSchemaMappings, - * blockMapping: { - * ...docxDefaultSchemaMappings.blockMapping, - * diagram: diagramBlockMapping, - * }, - * }); - * ``` - * - * Rendering the diagram needs a browser; there (and for invalid sources), it - * falls back to the default source code rendering. Kept out of the default - * mappings so exporting without diagram blocks doesn't load Mermaid. - */ -export const diagramBlockMapping = async ( - ...args: Parameters -) => { - const [block] = args; - - try { - const { dataURL, width, height } = await renderDiagramToImage( - getDiagramPlainTextContent(block.content), - ); - const blob = await (await fetch(dataURL)).blob(); - - return new Paragraph({ - alignment: AlignmentType.CENTER, - children: [ - new ImageRun({ - data: await blob.arrayBuffer(), - type: "png", - transformation: { width, height }, - }), - ], - }); - } catch { - return docxBlockMappingForDefaultSchema.diagram(...args); - } -}; diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml index b77c05abb5..4a9074e6eb 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -714,37 +714,6 @@ All those moments will be lost in time, like tears in rain. - - - - - - a^2 = \sqrt{b^2 + c^2} - - - - - Inline math: - - - - - - e^{i\pi} + 1 = 0 - - - - - - - - graph TD - - - - A[Start] --> B[End] - - diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 4ffea18afe..87e5c3a5ea 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -1,5 +1,4 @@ import { - BlockConfig, BlockFromConfigNoChildren, BlockMapping, COLORS_DEFAULT, @@ -29,8 +28,6 @@ import { Table } from "../util/Table.js"; type BSchema = DefaultBlockSchema & { pageBreak: ReturnType; - mathBlock: BlockConfig<"mathBlock", {}, "inline">; - diagram: BlockConfig<"diagram", {}, "inline">; } & typeof multiColumnSchema.blockSchema; function blockPropsToStyles( @@ -79,11 +76,7 @@ function blockPropsToStyles( } const codeMapping = ( - block: BlockFromConfigNoChildren< - BSchema["codeBlock"] | BSchema["mathBlock"] | BSchema["diagram"], - any, - any - >, + block: BlockFromConfigNoChildren, ) => { // Code blocks hold plain content: at most a single unstyled text item. const [textItem, ...excessItems] = block.content as PlainContent; @@ -180,25 +173,23 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< }, audio: (block, exporter) => { return [ - file(block.props, "Open audio", exporter), + file(block.props, exporter.dictionary.open_audio_file, exporter), ...caption(block.props, exporter), ]; }, video: (block, exporter) => { return [ - file(block.props, "Open video", exporter), + file(block.props, exporter.dictionary.open_video_file, exporter), ...caption(block.props, exporter), ]; }, file: (block, exporter) => { return [ - file(block.props, "Open file", exporter), + file(block.props, exporter.dictionary.open_file, exporter), ...caption(block.props, exporter), ]; }, codeBlock: codeMapping, - mathBlock: codeMapping, - diagram: codeMapping, pageBreak: () => { return new Paragraph({ children: [new PageBreak()], diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts index f2d557044d..aa783b12d8 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/inlinecontent.ts @@ -6,13 +6,7 @@ import { import { ExternalHyperlink, ParagraphChild, TextRun } from "docx"; import type { DOCXExporter } from "../docxExporter.js"; -type ICSchema = DefaultInlineContentSchema & { - math: { - type: "math"; - propSchema: Record; - content: "plain"; - }; -}; +type ICSchema = DefaultInlineContentSchema; export const docxInlineContentMappingForDefaultSchema: InlineContentMapping< ICSchema, @@ -34,12 +28,4 @@ export const docxInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, t) => { return t.transformStyledText(ic); }, - // Renders inline math as its monospaced LaTeX source. - // TODO - math: (ic) => { - return new TextRun({ - text: ic.content, - style: "VerbatimChar", - }); - }, }; diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index cae9b74e46..53722a4988 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -3,7 +3,8 @@ import { defaultBlockSpecs, createPageBreakBlockSpec, } from "@blocknote/core"; -import { testDocumentWithSourceBlocks as testDocument } from "@shared/testDocument.js"; +import { de } from "@blocknote/core/locales"; +import { testDocument } from "@shared/testDocument.js"; import { BlobReader, Entry, @@ -14,7 +15,6 @@ import { import { Packer, Paragraph, TextRun } from "docx"; import { describe, expect, it } from "vite-plus/test"; import xmlFormat from "xml-formatter"; -import { inlineMathMapping, mathBlockMapping } from "../math-block/index.js"; import { docxDefaultSchemaMappings } from "./defaultSchema/index.js"; import { DOCXExporter } from "./docxExporter.js"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; @@ -33,53 +33,6 @@ const getZIPEntryContent = (entries: Entry[], fileName: string) => { return entry.getData!(new TextWriter()); }; describe("exporter", () => { - it( - "should export math as native equations with the math-block mappings", - { timeout: 10000 }, - async () => { - // Assembled outside the constructor call as the schema doesn't include - // the math specs - like the default mappings, the math entries just - // map the block JSON. - const mappings = { - ...docxDefaultSchemaMappings, - blockMapping: { - ...docxDefaultSchemaMappings.blockMapping, - mathBlock: mathBlockMapping, - }, - inlineContentMapping: { - ...docxDefaultSchemaMappings.inlineContentMapping, - math: inlineMathMapping, - }, - }; - const exporter = new DOCXExporter( - BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - pageBreak: createPageBreakBlockSpec(), - }, - }), - mappings, - { resolveFileUrl: testResolveFileUrl }, - ); - - // The math block & inline math paragraph from the shared test document. - const doc = await exporter.toDocxJsDocument( - testDocument.filter((block) => - ["math-block", "paragraph-with-inline-math"].includes(block.id), - ), - { sectionOptions: {}, documentOptions: {}, locale: "en-US" }, - ); - - const blob = await Packer.toBlob(doc); - const zip = new ZipReader(new BlobReader(blob)); - const entries = await zip.getEntries(); - - await expect( - prettify(await getZIPEntryContent(entries, "word/document.xml")), - ).toMatchFileSnapshot("__snapshots__/withMathMappings/document.xml"); - }, - ); - it("should export a document", { timeout: 10000 }, async () => { const exporter = new DOCXExporter( BlockNoteSchema.create({ @@ -107,8 +60,6 @@ describe("exporter", () => { await expect( prettify(await getZIPEntryContent(entries, "word/styles.xml")), ).toMatchFileSnapshot("__snapshots__/basic/styles.xml"); - - // fs.writeFileSync(__dirname + "/My Document.docx", buffer); }); it( @@ -155,8 +106,6 @@ describe("exporter", () => { const blob = await Packer.toBlob(doc); - // fs.writeFileSync(__dirname + "/My Document.docx", buffer); - const zip = new ZipReader(new BlobReader(blob)); const entries = await zip.getEntries(); @@ -293,6 +242,40 @@ describe("exporter", () => { return zip.getEntries(); } + it( + "should export file links with the configured dictionary", + { timeout: 10000 }, + async () => { + // Exporter strings are never hardcoded - the file link text comes + // from the `exporter` section of the configured dictionary (English + // when not configured). + const exporter = new DOCXExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + docxDefaultSchemaMappings, + { resolveFileUrl: testResolveFileUrl, dictionary: de }, + ); + const doc = await exporter.toDocxJsDocument(testDocument, { + sectionOptions: {}, + documentOptions: {}, + locale: "de-DE", + }); + + const zip = new ZipReader(new BlobReader(await Packer.toBlob(doc))); + const documentXML = await getZIPEntryContent( + await zip.getEntries(), + "word/document.xml", + ); + + expect(documentXML).toContain("Datei öffnen"); + expect(documentXML).not.toContain("Open file"); + }, + ); + it( "should export a document without w:lang when no locale is provided", { timeout: 10000 }, diff --git a/packages/xl-docx-exporter/src/math-block/index.ts b/packages/xl-docx-exporter/src/math-block/index.ts deleted file mode 100644 index 07116d2328..0000000000 --- a/packages/xl-docx-exporter/src/math-block/index.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { AlignmentType, ImportedXmlComponent, Paragraph } from "docx"; -import katex from "katex"; -import { mml2omml } from "mathml2omml"; - -import { docxBlockMappingForDefaultSchema } from "../docx/defaultSchema/blocks.js"; -import { docxInlineContentMappingForDefaultSchema } from "../docx/defaultSchema/inlinecontent.js"; - -// The math block's inline content as plain text (its LaTeX source). Local -// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the -// package would break headless (Node) exports, as its build carries a -// top-level CSS import. -// TODO: remove after plain text PR lands -const getPlainTextContent = (content: unknown): string => { - if (typeof content === "string") { - return content; - } - - if (Array.isArray(content)) { - return content - .map((node) => - node && typeof node === "object" && "text" in node ? node.text : "", - ) - .join(""); - } - - return ""; -}; - -// Converts LaTeX to a native Word equation (OMML): KaTeX renders the LaTeX -// to MathML, which is then converted to OMML. Throws on invalid LaTeX, so -// callers can fall back to the default source-code rendering. -const latexToEquation = (latex: string, inline: boolean) => { - const katexOutput = katex.renderToString(latex, { - displayMode: !inline, - output: "mathml", - throwOnError: true, - }); - - // KaTeX wraps the MathML in a `span`; the equation only needs the `math` - // element itself. - const mathML = katexOutput.match(//)?.[0]; - if (!mathML) { - throw new Error("No MathML found in KaTeX output"); - } - - // `fromXmlString` parses the XML *document*, returning a nameless wrapper - // component around the `m:oMath` root element - unwrap it, or it would - // serialize as an (invalid) `` element. - const imported = ImportedXmlComponent.fromXmlString(mml2omml(mathML)) as any; - return imported.root[0] as ImportedXmlComponent; -}; - -/** - * Block mapping for `@blocknote/math-block` that renders math blocks as - * native (editable) Word equations instead of their LaTeX source: - * - * ```ts - * new DOCXExporter(schema, { - * ...docxDefaultSchemaMappings, - * blockMapping: { - * ...docxDefaultSchemaMappings.blockMapping, - * mathBlock: mathBlockMapping, - * }, - * }); - * ``` - * - * Requires `katex` (a dependency of `@blocknote/math-block`, so already - * installed when using math blocks). Kept out of the default mappings so - * exporting without math blocks doesn't load it. Invalid LaTeX falls back to - * the default source code rendering. - */ -export const mathBlockMapping = ( - ...args: Parameters -) => { - const [block] = args; - - try { - const source = getPlainTextContent(block.content); - if (!source.trim()) { - throw new Error("Empty math block"); - } - - return new Paragraph({ - alignment: AlignmentType.CENTER, - children: [latexToEquation(source, false) as any], - }); - } catch { - return docxBlockMappingForDefaultSchema.mathBlock(...args); - } -}; - -/** - * Inline content mapping for `@blocknote/math-block` that renders inline math - * as native (editable) Word equations instead of its LaTeX source: - * - * ```ts - * new DOCXExporter(schema, { - * ...docxDefaultSchemaMappings, - * inlineContentMapping: { - * ...docxDefaultSchemaMappings.inlineContentMapping, - * math: inlineMathMapping, - * }, - * }); - * ``` - */ -export const inlineMathMapping = ( - ...args: Parameters -) => { - const [inlineContent] = args; - - try { - const source = getPlainTextContent(inlineContent.content); - if (!source.trim()) { - throw new Error("Empty inline math"); - } - - return latexToEquation(source, true) as any; - } catch { - return docxInlineContentMappingForDefaultSchema.math(...args); - } -}; diff --git a/packages/xl-docx-exporter/vite.config.ts b/packages/xl-docx-exporter/vite.config.ts index e4b1ffc1c1..8a88c957f6 100644 --- a/packages/xl-docx-exporter/vite.config.ts +++ b/packages/xl-docx-exporter/vite.config.ts @@ -56,8 +56,6 @@ export default defineConfig( __dirname, "src/index.ts", ), - "diagram-block": path.resolve(__dirname, "src/diagram-block/index.ts"), - "math-block": path.resolve(__dirname, "src/math-block/index.ts"), }, name: "blocknote-xl-docx-exporter", formats: ["es", "cjs"], diff --git a/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap index 83419c7f2f..8c8fdbd507 100644 --- a/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap +++ b/packages/xl-email-exporter/src/react-email/__snapshots__/reactEmailExporter.test.tsx.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`react email exporter > should export a document (HTML snapshot) > __snapshots__/reactEmailExporter 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; +exports[`react email exporter > should export a document (HTML snapshot) > __snapshots__/reactEmailExporter 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; -exports[`react email exporter > should export a document with multiple preview lines > __snapshots__/reactEmailExporterWithMultiplePreview 1`] = `"
First preview lineSecond preview line
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; +exports[`react email exporter > should export a document with multiple preview lines > __snapshots__/reactEmailExporterWithMultiplePreview 1`] = `"
First preview lineSecond preview line
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; -exports[`react email exporter > should export a document with preview > __snapshots__/reactEmailExporterWithPreview 1`] = `"
This is a preview of the email content
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; +exports[`react email exporter > should export a document with preview > __snapshots__/reactEmailExporterWithPreview 1`] = `"
This is a preview of the email content
 ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; exports[`react email exporter > should handle document with background colors > __snapshots__/reactEmailExporterBackgroundColor 1`] = `"

Text with background color

"`; @@ -14,7 +14,7 @@ exports[`react email exporter > should handle document with code blocks > __snap exports[`react email exporter > should handle document with complex nested structure > __snapshots__/reactEmailExporterComplexNested 1`] = `"

Complex Document

This is a paragraph with bold and italic text, plus a link.

  • List item with nested content

    Nested paragraph

    1. Nested numbered item

"`; -exports[`react email exporter > should handle document with custom body styles > __snapshots__/reactEmailExporterCustomBodyStyles 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video file

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio file

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

a^2 ‍​= ‍​\\sqrt{b^2 ‍​+ ‍​c^2}

Inline math: e^{i\\pi} + 1 = 0

graph ‍​TD
 ‍​ ‍​A[Start] ‍​--> ‍​B[End]
"`; +exports[`react email exporter > should handle document with custom body styles > __snapshots__/reactEmailExporterCustomBodyStyles 1`] = `"

Welcome to this demo 🙌!

Hello World nested

Hello World double nested

This paragraph has a background color

Paragraph

Heading

Heading right

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.


  • 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.

    • 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.

    • Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

    1. Numbered List Item 1

    2. Numbered List Item 2

      1. Numbered List Item Nested 1

      2. Numbered List Item Nested 2

      3. Numbered List Item Nested funky right

      4. Numbered List Item Nested funky center

  1. Numbered List Item

Check List Item

Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
Wide CellTable CellTable Cell
From https://placehold.co/332x322.jpg
Open video

From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm

Open audio

From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3

audio.mp3

Audio file caption

Inline Content:

Styled Text Link

Table Cell 1Table Cell 2Table Cell 3
Table Cell 4Table Cell Bold 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';


All those moments will be lost in time, like tears in rain.

"`; exports[`react email exporter > should handle document with headings of different levels > __snapshots__/reactEmailExporterHeadings 1`] = `"

Heading 1

Heading 2

Heading 3

"`; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx index aa22d878dc..854fec4392 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -1,5 +1,4 @@ import { - BlockConfig, BlockFromConfigNoChildren, BlockMapping, createPageBreakBlockConfig, @@ -119,16 +118,10 @@ export const defaultReactEmailTextStyles = { type BSchema = DefaultBlockSchema & { pageBreak: ReturnType; - mathBlock: BlockConfig<"mathBlock", {}, "inline">; - diagram: BlockConfig<"diagram", {}, "inline">; }; const codeMapping = ( - block: BlockFromConfigNoChildren< - BSchema["codeBlock"] | BSchema["mathBlock"] | BSchema["diagram"], - any, - any - >, + block: BlockFromConfigNoChildren, language: PrismLanguage, textStyles: ReactEmailTextStyles, ) => { @@ -298,11 +291,7 @@ export const createReactEmailBlockMappingForDefaultSchema = ( codeBlock: (block) => codeMapping(block, block.props.language as PrismLanguage, textStyles), - mathBlock: (block) => - codeMapping(block, "latex" as PrismLanguage, textStyles), - diagram: (block) => - codeMapping(block, "mermaid" as PrismLanguage, textStyles), - audio: (block) => { + audio: (block, exporter) => { // Audio icon SVG const icon = ( ); }, - video: (block) => { + video: (block, exporter) => { // Video icon SVG const icon = ( ); }, - file: (block) => { + file: (block, exporter) => { // File icon SVG const icon = ( ; - content: "plain"; - }; -}; +type ICSchema = DefaultInlineContentSchema; export const createReactEmailInlineContentMappingForDefaultSchema = ( linkStyles: ReactEmailLinkStyles = defaultReactEmailLinkStyles, @@ -43,10 +37,6 @@ export const createReactEmailInlineContentMappingForDefaultSchema = ( text: (ic, t) => { return t.transformStyledText(ic); }, - // Renders inline math as its monospaced LaTeX source. - math: (ic) => { - return {ic.content}; - }, }); // Export the original mapping for backward compatibility diff --git a/packages/xl-email-exporter/src/react-email/imageDelivery.test.ts b/packages/xl-email-exporter/src/react-email/imageDelivery.test.ts new file mode 100644 index 0000000000..86ff2938e6 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/imageDelivery.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + createCIDImageDelivery, + dataURLImageDelivery, +} from "./imageDelivery.js"; + +// Base64 "AAAA" = three zero bytes. +const pngImage = { + mimeType: "image/png", + data: new Uint8Array([0, 0, 0]), + width: 100, + height: 50, +}; + +describe("dataURLImageDelivery", () => { + it("encodes the image as a data URL src", () => { + expect(dataURLImageDelivery.deliver({ ...pngImage, name: "math" })).toBe( + "data:image/png;base64,AAAA", + ); + }); +}); + +describe("createCIDImageDelivery", () => { + it("collects base64 attachments and returns cid: srcs", () => { + const delivery = createCIDImageDelivery(); + + const src = delivery.deliver({ ...pngImage, name: "math" }); + + expect(src).toBe("cid:math-1@blocknote"); + expect(delivery.attachments).toEqual([ + { + cid: "math-1@blocknote", + filename: "math-1.png", + content: "AAAA", + encoding: "base64", + contentType: "image/png", + contentDisposition: "inline", + }, + ]); + }); + + it("numbers multiple images to keep CIDs and filenames unique", () => { + const delivery = createCIDImageDelivery(); + + delivery.deliver({ ...pngImage, name: "math" }); + const second = delivery.deliver({ ...pngImage, name: "diagram" }); + + expect(second).toBe("cid:diagram-2@blocknote"); + expect(delivery.attachments[1].filename).toBe("diagram-2.png"); + }); + + it("converts SVG images, including non-Latin-1 characters", () => { + // MathJax SVG output can contain characters outside Latin-1 (which a + // naive `btoa` rejects); the byte-based contract must round-trip them. + const svg = `e^{iπ} + 1 = 0`; + const delivery = createCIDImageDelivery(); + + const src = delivery.deliver({ + mimeType: "image/svg+xml", + data: new TextEncoder().encode(svg), + width: 100, + height: 20, + name: "math", + }); + + expect(src).toBe("cid:math-1@blocknote"); + const attachment = delivery.attachments[0]; + expect(attachment.contentType).toBe("image/svg+xml"); + expect(attachment.filename).toBe("math-1.svg"); + // The attachment round-trips back to the original SVG. + const bytes = Uint8Array.from(atob(attachment.content), (char) => + char.charCodeAt(0), + ); + expect(new TextDecoder().decode(bytes)).toBe(svg); + }); +}); diff --git a/packages/xl-email-exporter/src/react-email/imageDelivery.ts b/packages/xl-email-exporter/src/react-email/imageDelivery.ts new file mode 100644 index 0000000000..7423ecb400 --- /dev/null +++ b/packages/xl-email-exporter/src/react-email/imageDelivery.ts @@ -0,0 +1,97 @@ +import { + bytesToBase64, + ExportImage, + exportImageToDataURL, +} from "@blocknote/core"; + +/** + * How generated images (math formulas, diagrams) find their way into an + * email. Mappings that generate images take a delivery via their factory + * options, hand it the generated {@link ExportImage}, and use the returned + * string as the `` src. + * + * Custom deliveries can implement any transport: `deliver` registers the + * image and synchronously returns the reference to embed (a data URL, a + * `cid:`, a content-addressed hosted URL, ...); work that can't happen + * during rendering - uploading, attaching - happens after the email is + * rendered, from what was registered (see {@link createCIDImageDelivery} + * for this pattern). + */ +export type ReactEmailImageDelivery = { + /** + * Registers a generated image and returns the `src` to reference it with + * in the email body. Must be synchronous: some inline content renders + * synchronously. + * + * @param image - The generated image, plus a short `name` for the image + * kind (e.g. "math"), used for attachment filenames. + */ + deliver: (image: ExportImage & { name: string }) => string; +}; + +/** + * Embeds images directly in the email body as data URLs. Self-contained (no + * attachments to manage), but some email clients (notably Gmail and Outlook + * for Windows) don't display data URL images. + */ +export const dataURLImageDelivery: ReactEmailImageDelivery = { + deliver: (image) => exportImageToDataURL(image), +}; + +/** + * Delivers images as inline email attachments, referenced from the body via + * `cid:` URLs (RFC 2392) - the most widely supported way to embed generated + * images (works in Gmail and Outlook, which both block data URLs). + * + * Attaching happens at send time: after rendering the email, pass + * `attachments` to your mailer alongside the HTML. The attachment objects + * use the field names of nodemailer & compatible APIs: + * + * ```ts + * const imageDelivery = createCIDImageDelivery(); + * const exporter = new ReactEmailExporter(schema, { + * ...reactEmailDefaultSchemaMappings, + * blockMapping: { + * ...reactEmailDefaultSchemaMappings.blockMapping, + * math: createMathBlockMapping({ imageDelivery }), + * }, + * }); + * const html = await exporter.toReactEmailDocument(blocks); + * + * await transporter.sendMail({ html, attachments: imageDelivery.attachments }); + * ``` + * + * Create one delivery per rendered email - the attachment list accumulates + * across renders otherwise. + */ +export function createCIDImageDelivery(): ReactEmailImageDelivery & { + attachments: { + cid: string; + filename: string; + content: string; + encoding: "base64"; + contentType: string; + contentDisposition: "inline"; + }[]; +} { + const attachments: ReturnType["attachments"] = + []; + + return { + attachments, + deliver: (image) => { + const cid = `${image.name}-${attachments.length + 1}@blocknote`; + const extension = image.mimeType.split("/")[1]?.split("+")[0] ?? "bin"; + attachments.push({ + cid, + filename: `${image.name}-${attachments.length + 1}.${extension}`, + content: bytesToBase64(image.data), + encoding: "base64", + contentType: image.mimeType, + contentDisposition: "inline", + }); + + return `cid:${cid}`; + }, + }; +} diff --git a/packages/xl-email-exporter/src/react-email/index.ts b/packages/xl-email-exporter/src/react-email/index.ts index 8412da0065..b4da61e643 100644 --- a/packages/xl-email-exporter/src/react-email/index.ts +++ b/packages/xl-email-exporter/src/react-email/index.ts @@ -1,2 +1,3 @@ export * from "./defaultSchema/index.js"; +export * from "./imageDelivery.js"; export * from "./reactEmailExporter.jsx"; diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx index 521cfc37b5..756fd36a2a 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx @@ -11,7 +11,7 @@ import { defaultInlineContentSpecs, defaultStyleSpecs, } from "@blocknote/core"; -import { testDocumentWithSourceBlocks as testDocument } from "@shared/testDocument.js"; +import { testDocument } from "@shared/testDocument.js"; describe("react email exporter", () => { it("should export a document (HTML snapshot)", async () => { diff --git a/packages/xl-odt-exporter/package.json b/packages/xl-odt-exporter/package.json index 1a00dde44f..51cc6082f4 100644 --- a/packages/xl-odt-exporter/package.json +++ b/packages/xl-odt-exporter/package.json @@ -46,16 +46,6 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" - }, - "./diagram-block": { - "types": "./types/src/diagram-block/index.d.ts", - "import": "./dist/diagram-block.js", - "require": "./dist/diagram-block.cjs" - }, - "./math-block": { - "types": "./types/src/math-block/index.d.ts", - "import": "./dist/math-block.js", - "require": "./dist/math-block.cjs" } }, "scripts": { @@ -72,13 +62,10 @@ "image-meta": "^0.2.2" }, "devDependencies": { - "@blocknote/diagram-block": "workspace:^", "@blocknote/shared": "workspace:^", "@testing-library/react": "^16.3.0", - "@types/katex": "^0.16.7", "@types/node": "22.13.13", "@types/react": "^19.2.3", - "katex": "^0.16.11", "@types/react-dom": "^19.2.3", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -90,16 +77,6 @@ }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", - "@blocknote/diagram-block": "workspace:^", - "katex": "^0.16.0" - }, - "peerDependenciesMeta": { - "@blocknote/diagram-block": { - "optional": true - }, - "katex": { - "optional": true - } + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } } diff --git a/packages/xl-odt-exporter/src/diagram-block/index.ts b/packages/xl-odt-exporter/src/diagram-block/index.ts deleted file mode 100644 index 5c5dc5c35d..0000000000 --- a/packages/xl-odt-exporter/src/diagram-block/index.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - getDiagramPlainTextContent, - renderDiagramToImage, -} from "@blocknote/diagram-block"; - -import { odtBlockMappingForDefaultSchema } from "../odt/defaultSchema/blocks.js"; -import { ODTExporter } from "../odt/odtExporter.js"; -import { createODTImageParagraph } from "../odt/util/createODTImageParagraph.js"; - -/** - * Block mapping for `@blocknote/diagram-block` that embeds diagrams as - * images instead of their Mermaid source: - * - * ```ts - * new ODTExporter(schema, { - * ...odtDefaultSchemaMappings, - * blockMapping: { - * ...odtDefaultSchemaMappings.blockMapping, - * diagram: diagramBlockMapping, - * }, - * }); - * ``` - * - * Rendering the diagram needs a browser; there (and for invalid sources), it - * falls back to the default source code rendering. Kept out of the default - * mappings so exporting without diagram blocks doesn't load Mermaid. - */ -export const diagramBlockMapping = async ( - ...args: Parameters -) => { - const [block, exporter] = args; - - try { - const { dataURL, width, height } = await renderDiagramToImage( - getDiagramPlainTextContent(block.content), - ); - - // The image is rendered at 2x, so pass the diagram's logical dimensions - // rather than the picture's own. - return await createODTImageParagraph( - exporter as ODTExporter, - dataURL, - { width, height, align: "center" }, - ); - } catch { - return odtBlockMappingForDefaultSchema.diagram(...args); - } -}; diff --git a/packages/xl-odt-exporter/src/math-block/index.tsx b/packages/xl-odt-exporter/src/math-block/index.tsx deleted file mode 100644 index adae0ba18a..0000000000 --- a/packages/xl-odt-exporter/src/math-block/index.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import katex from "katex"; - -import { odtBlockMappingForDefaultSchema } from "../odt/defaultSchema/blocks.js"; -import { odtInlineContentMappingForDefaultSchema } from "../odt/defaultSchema/inlineContent.js"; -import { ODTExporter } from "../odt/odtExporter.js"; - -// The math block's inline content as plain text (its LaTeX source). Local -// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the -// package would break headless (Node) exports, as its build carries a -// top-level CSS import. -const getPlainTextContent = (content: unknown): string => { - if (typeof content === "string") { - return content; - } - - if (Array.isArray(content)) { - return content - .map((node) => - node && typeof node === "object" && "text" in node ? node.text : "", - ) - .join(""); - } - - return ""; -}; - -// Converts LaTeX to MathML (via KaTeX), which ODT embeds natively as formula -// objects. Throws on invalid LaTeX, so callers can fall back to the default -// source-code rendering. -const latexToMathML = (latex: string, inline: boolean): string => { - const katexOutput = katex.renderToString(latex, { - displayMode: !inline, - output: "mathml", - throwOnError: true, - }); - - // KaTeX wraps the MathML in a `span`; the formula object only needs the - // `math` element itself. - const mathML = katexOutput.match(//)?.[0]; - if (!mathML) { - throw new Error("No MathML found in KaTeX output"); - } - - return mathML; -}; - -// A formula object, anchored as a character so it can sit inline among text. -// The MathML goes into an object sub-document (rather than inline into the -// frame) and the frame gets no explicit size, with a graphic style derived -// from the built-in "Formula" style - this exact combination makes -// LibreOffice load the formula as a real formula object and compute its -// natural size (sized frames get the formula scaled-to-fit instead, and -// inline MathML renders at zero size). -const formulaFrame = (exporter: ODTExporter, mathML: string) => { - const objectPath = exporter.registerObject( - '\n' + mathML, - ); - const styleName = exporter.registerStyle((name) => ( - - - - )); - - return ( - - - - ); -}; - -/** - * Block mapping for `@blocknote/math-block` that renders math blocks as - * native (editable) formula objects instead of their LaTeX source: - * - * ```ts - * new ODTExporter(schema, { - * ...odtDefaultSchemaMappings, - * blockMapping: { - * ...odtDefaultSchemaMappings.blockMapping, - * mathBlock: mathBlockMapping, - * }, - * }); - * ``` - * - * Requires `katex` (a dependency of `@blocknote/math-block`, so already - * installed when using math blocks). Kept out of the default mappings so - * exporting without math blocks doesn't load it. Invalid LaTeX falls back to - * the default source code rendering. - */ -export const mathBlockMapping = ( - ...args: Parameters -) => { - const [block, exporter] = args; - - try { - const source = getPlainTextContent(block.content); - if (!source.trim()) { - throw new Error("Empty math block"); - } - - const odtExporter = exporter as ODTExporter; - const mathML = latexToMathML(source, false); - - const styleName = odtExporter.registerStyle((name) => ( - - - - )); - - return ( - - {formulaFrame(odtExporter, mathML)} - - ); - } catch { - return odtBlockMappingForDefaultSchema.mathBlock(...args); - } -}; - -/** - * Inline content mapping for `@blocknote/math-block` that renders inline math - * as native (editable) formula objects instead of its LaTeX source: - * - * ```ts - * new ODTExporter(schema, { - * ...odtDefaultSchemaMappings, - * inlineContentMapping: { - * ...odtDefaultSchemaMappings.inlineContentMapping, - * math: inlineMathMapping, - * }, - * }); - * ``` - */ -export const inlineMathMapping = ( - ...args: Parameters -) => { - const [inlineContent, exporter] = args; - - try { - const source = getPlainTextContent(inlineContent.content); - if (!source.trim()) { - throw new Error("Empty inline math"); - } - - return formulaFrame( - exporter as ODTExporter, - latexToMathML(source, true), - ); - } catch { - return odtInlineContentMappingForDefaultSchema.math(...args); - } -}; diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml index 4ac648c903..e108e17026 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/basic/content.xml @@ -49,59 +49,34 @@ - - + - + - - - - + - + - + - - - - + - + - - - - - - - - - - - - - - - - - - - + - + - + @@ -145,7 +120,7 @@ - + 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. @@ -266,56 +241,56 @@ Check List Item
- + + + - - - + Wide Cell - + Table Cell - + Table Cell - + Wide Cell - + Table Cell - + Table Cell - + Wide Cell - + Table Cell - + Table Cell @@ -340,7 +315,7 @@ - + @@ -379,70 +354,70 @@ Audio file caption - + Inline Content: - + Styled Text Link - - - - + + + + - + Table Cell 1 - + Table Cell 2 - + Table Cell 3 - + Table Cell 4 - + - + Table Cell Bold 5 - + Table Cell 6 - + Table Cell 7 - + Table Cell 8 - + Table Cell 9 @@ -457,31 +432,17 @@ }; - + Some inline code: - + var foo = 'bar'; - - + + All those moments will be lost in time, like tears in rain. - - a^2 = \sqrt{b^2 + c^2} - - - Inline math: - - e^{i\pi} + 1 = 0 - - - - graph TD - - A[Start] --> B[End] - \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml index 7830679429..aeb491ece1 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml @@ -49,59 +49,34 @@ - - + - + - - - - + - + - + - - - - + - + - - - - - - - - - - - - - - - - - - - + - + - + @@ -159,7 +134,7 @@ - + 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. @@ -280,56 +255,56 @@ Check List Item - + + + - - - + Wide Cell - + Table Cell - + Table Cell - + Wide Cell - + Table Cell - + Table Cell - + Wide Cell - + Table Cell - + Table Cell @@ -354,7 +329,7 @@ - + @@ -393,70 +368,70 @@ Audio file caption - + Inline Content: - + Styled Text Link - - - - + + + + - + Table Cell 1 - + Table Cell 2 - + Table Cell 3 - + Table Cell 4 - + - + Table Cell Bold 5 - + Table Cell 6 - + Table Cell 7 - + Table Cell 8 - + Table Cell 9 @@ -471,31 +446,17 @@ }; - + Some inline code: - + var foo = 'bar'; - - + + All those moments will be lost in time, like tears in rain. - - a^2 = \sqrt{b^2 + c^2} - - - Inline math: - - e^{i\pi} + 1 = 0 - - - - graph TD - - A[Start] --> B[End] - \ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/__snapshots__/withMultiColumn/content.xml b/packages/xl-odt-exporter/src/odt/__snapshots__/withMultiColumn/content.xml index fd41c45fa7..efe542c994 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/withMultiColumn/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withMultiColumn/content.xml @@ -19,66 +19,55 @@ - - - - - - - - - - + + - + - + - - - - - - - + + + + This paragraph is in a column! - + So is this heading! - + You can have multiple blocks in a column too - + Block 1 - + Block 2 - + Block 3 diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index bfd23a1e67..f86ab23d63 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -1,5 +1,4 @@ import { - BlockConfig, BlockFromConfig, BlockFromConfigNoChildren, BlockMapping, @@ -15,16 +14,10 @@ import { ODTExporter } from "../odtExporter.js"; type BSchema = DefaultBlockSchema & { pageBreak: ReturnType; - mathBlock: BlockConfig<"mathBlock", {}, "inline">; - diagram: BlockConfig<"diagram", {}, "inline">; } & typeof multiColumnSchema.blockSchema; const codeMapping = ( - block: BlockFromConfigNoChildren< - BSchema["codeBlock"] | BSchema["mathBlock"] | BSchema["diagram"], - any, - any - >, + block: BlockFromConfigNoChildren, ) => { // Code blocks hold plain content: at most a single unstyled text item. const [textItem, ...excessItems] = block.content as PlainContent; @@ -533,12 +526,9 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ); }, - // TODO codeBlock: codeMapping, - mathBlock: codeMapping, - diagram: codeMapping, - file: async (block) => { + file: async (block, exporter) => { return ( <> @@ -551,11 +541,11 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< xlink:href={block.props.url} > - Open file + {exporter.dictionary.open_file} ) : ( - "Open file" + exporter.dictionary.open_file )} {block.props.caption && ( @@ -565,7 +555,7 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ); }, - video: (block) => ( + video: (block, exporter) => ( <> - Open video + + {exporter.dictionary.open_video_file} + {block.props.caption && ( @@ -584,7 +576,7 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ), - audio: (block) => ( + audio: (block, exporter) => ( <> - Open audio + + {exporter.dictionary.open_audio_file} + {block.props.caption && ( diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx index 78a0bbaa64..544b1b2d63 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/inlineContent.tsx @@ -3,13 +3,7 @@ import { InlineContentMapping, } from "@blocknote/core"; -type ICSchema = DefaultInlineContentSchema & { - math: { - type: "math"; - propSchema: Record; - content: "plain"; - }; -}; +type ICSchema = DefaultInlineContentSchema; // `React.ReactNode` result types, matching `ODTExporter`'s `Exporter` // generics - mismatched result types make the mappings unassignable. @@ -38,9 +32,4 @@ export const odtInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, exporter) => { return exporter.transformStyledText(ic); }, - // TODO - // Renders inline math as its LaTeX source. - math: (ic) => { - return {ic.content}; - }, }; diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts index 0686ffcdeb..56685b77bd 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts +++ b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts @@ -3,11 +3,10 @@ import { createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; -import { testDocumentWithSourceBlocks as testDocument } from "@shared/testDocument.js"; -import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; +import { testODTDocumentAgainstSnapshot } from "@shared/util/odtTestUtil.js"; +import { testDocument } from "@shared/testDocument.js"; import { beforeAll, describe, expect, it } from "vite-plus/test"; -import xmlFormat from "xml-formatter"; -import { inlineMathMapping, mathBlockMapping } from "../math-block/index.js"; +import { createElement } from "react"; import { odtDefaultSchemaMappings } from "./defaultSchema/index.js"; import { ODTExporter } from "./odtExporter.js"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; @@ -20,48 +19,6 @@ beforeAll(async () => { }); describe("exporter", () => { - it( - "should export math as native formulas with the math-block mappings", - { timeout: 10000 }, - async () => { - // Assembled outside the constructor call as the schema doesn't include - // the math specs - like the default mappings, the math entries just - // map the block JSON. - const mappings = { - ...odtDefaultSchemaMappings, - blockMapping: { - ...odtDefaultSchemaMappings.blockMapping, - mathBlock: mathBlockMapping, - }, - inlineContentMapping: { - ...odtDefaultSchemaMappings.inlineContentMapping, - math: inlineMathMapping, - }, - }; - const exporter = new ODTExporter( - BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - pageBreak: createPageBreakBlockSpec(), - }, - }), - mappings, - { resolveFileUrl: testResolveFileUrl }, - ); - - // The math block & inline math paragraph from the shared test document. - const odt = await exporter.toODTDocument( - testDocument.filter((block) => - ["math-block", "paragraph-with-inline-math"].includes(block.id), - ), - ); - await testODTDocumentAgainstSnapshot(odt, { - styles: "__snapshots__/withMathMappings/styles.xml", - content: "__snapshots__/withMathMappings/content.xml", - }); - }, - ); - it("should export a document", { timeout: 10000 }, async () => { const exporter = new ODTExporter( BlockNoteSchema.create({ @@ -189,32 +146,35 @@ describe("exporter", () => { }); }, ); -}); -async function testODTDocumentAgainstSnapshot( - odt: globalThis.Blob, - snapshots: { - styles: string; - content: string; - }, -) { - const zipReader = new ZipReader(new BlobReader(odt)); - const entries = await zipReader.getEntries(); - const stylesXMLWriter = new TextWriter(); - const contentXMLWriter = new TextWriter(); - const stylesXML = entries.find( - (entry) => entry.filename === "styles.xml", - ) as FileEntry; - const contentXML = entries.find((entry) => { - return entry.filename === "content.xml"; - }) as FileEntry; + it("deduplicates identical automatic styles", () => { + const exporter = new ODTExporter( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + odtDefaultSchemaMappings, + { resolveFileUrl: testResolveFileUrl }, + ); + + const italic = (name: string) => + createElement( + "style:style", + { "style:family": "text", "style:name": name }, + createElement("style:text-properties", { "fo:font-style": "italic" }), + ); + const bold = (name: string) => + createElement( + "style:style", + { "style:family": "text", "style:name": name }, + createElement("style:text-properties", { "fo:font-weight": "bold" }), + ); - expect(stylesXML).toBeDefined(); - expect(contentXML).toBeDefined(); - await expect( - xmlFormat(await stylesXML.getData(stylesXMLWriter)), - ).toMatchFileSnapshot(snapshots.styles); - await expect( - xmlFormat(await contentXML.getData(contentXMLWriter)), - ).toMatchFileSnapshot(snapshots.content); -} + expect(exporter.registerStyle(italic)).toBe(exporter.registerStyle(italic)); + expect(exporter.registerStyle(italic)).not.toBe( + exporter.registerStyle(bold), + ); + }); +}); diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index a68cdc843c..7c17cad0ad 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -53,6 +53,7 @@ export class ODTExporter< }> = []; private styleCounter = 0; + private readonly registeredStyleNames = new Map(); public readonly options: ExporterOptions; @@ -107,15 +108,22 @@ export class ODTExporter< return styledText.text; } - const styleName = `BN_T${++this.styleCounter}`; - - // Store the complete style element - this.automaticStyles.set( - styleName, - - - , - ); + // Like `registerStyle`, identical style combinations are deduplicated - + // every styled run (each bold word, say) would otherwise create its own + // automatic style. The key is prefixed so the two key spaces can't + // collide. + const key = "T:" + JSON.stringify(styles); + let styleName = this.registeredStyleNames.get(key); + if (styleName === undefined) { + styleName = `BN_T${++this.styleCounter}`; + this.automaticStyles.set( + styleName, + + + , + ); + this.registeredStyleNames.set(key, styleName); + } return {styledText.text}; } @@ -354,8 +362,18 @@ export class ODTExporter< } public registerStyle(style: (name: string) => React.ReactNode): string { + // Identical definitions are deduplicated: mappings register their styles + // per block, and a document with many alike blocks would otherwise fill + // the automatic styles with copies. The definition is keyed by its + // rendered shape, with a placeholder where the generated name appears. + const key = "S:" + JSON.stringify(style("BN_STYLE_NAME_PLACEHOLDER")); + const existing = this.registeredStyleNames.get(key); + if (existing !== undefined) { + return existing; + } const styleName = `BN_S${++this.styleCounter}`; this.automaticStyles.set(styleName, style(styleName)); + this.registeredStyleNames.set(key, styleName); return styleName; } diff --git a/packages/xl-odt-exporter/vite.config.ts b/packages/xl-odt-exporter/vite.config.ts index 1c24710f25..3873b90769 100644 --- a/packages/xl-odt-exporter/vite.config.ts +++ b/packages/xl-odt-exporter/vite.config.ts @@ -55,11 +55,6 @@ export default defineConfig( __dirname, "src/index.ts", ), - "diagram-block": path.resolve( - __dirname, - "src/diagram-block/index.ts", - ), - "math-block": path.resolve(__dirname, "src/math-block/index.tsx"), }, name: "blocknote-xl-odt-exporter", formats: ["es", "cjs"], diff --git a/packages/xl-pdf-exporter/package.json b/packages/xl-pdf-exporter/package.json index 3b8f2f788b..4be7f059ea 100644 --- a/packages/xl-pdf-exporter/package.json +++ b/packages/xl-pdf-exporter/package.json @@ -45,16 +45,6 @@ "import": "./dist/style.css", "require": "./dist/style.css", "style": "./dist/style.css" - }, - "./diagram-block": { - "types": "./types/src/diagram-block/index.d.ts", - "import": "./dist/diagram-block.js", - "require": "./dist/diagram-block.cjs" - }, - "./math-block": { - "types": "./types/src/math-block/index.d.ts", - "import": "./dist/math-block.js", - "require": "./dist/math-block.cjs" } }, "scripts": { @@ -71,16 +61,10 @@ "@react-pdf/renderer": "^4.5.1" }, "devDependencies": { - "@blocknote/diagram-block": "workspace:^", - "@react-pdf/math": "^2.0.1", "@blocknote/shared": "workspace:^", "@testing-library/react": "^16.3.0", - "@types/jest-image-snapshot": "^6.4.0", - "@types/jsdom": "^21.1.7", "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", - "jest-image-snapshot": "^6.5.1", - "pdf-to-img": "^4.5.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-element-to-jsx-string": "^17.0.1", @@ -91,17 +75,7 @@ }, "peerDependencies": { "react": "^18.0 || ^19.0 || >= 19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc", - "@blocknote/diagram-block": "workspace:^", - "@react-pdf/math": "^2.0.0" + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" }, - "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d", - "peerDependenciesMeta": { - "@blocknote/diagram-block": { - "optional": true - }, - "@react-pdf/math": { - "optional": true - } - } + "gitHead": "37614ab348dcc7faa830a9a88437b37197a2162d" } diff --git a/packages/xl-pdf-exporter/src/diagram-block/index.tsx b/packages/xl-pdf-exporter/src/diagram-block/index.tsx deleted file mode 100644 index 03cd3ce537..0000000000 --- a/packages/xl-pdf-exporter/src/diagram-block/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { - getDiagramPlainTextContent, - renderDiagramToImage, -} from "@blocknote/diagram-block"; -import { Image } from "@react-pdf/renderer"; - -import { pdfBlockMappingForDefaultSchema } from "../pdf/defaultSchema/blocks.js"; - -const PIXELS_PER_POINT = 0.75; -const MAX_WIDTH_POINTS = 400; - -/** - * Block mapping for `@blocknote/diagram-block` that embeds diagrams as - * images instead of their Mermaid source: - * - * ```ts - * new PDFExporter(schema, { - * ...pdfDefaultSchemaMappings, - * blockMapping: { - * ...pdfDefaultSchemaMappings.blockMapping, - * diagram: diagramBlockMapping, - * }, - * }); - * ``` - * - * Rendering the diagram needs a browser; there (and for invalid sources), it - * falls back to the default source code rendering. Kept out of the default - * mappings so exporting without diagram blocks doesn't load Mermaid. - */ -export const diagramBlockMapping = async ( - ...args: Parameters -) => { - const [block] = args; - - try { - const { dataURL, width } = await renderDiagramToImage( - getDiagramPlainTextContent(block.content), - ); - - return ( - - ); - } catch { - return pdfBlockMappingForDefaultSchema.diagram(...args); - } -}; diff --git a/packages/xl-pdf-exporter/src/math-block/index.tsx b/packages/xl-pdf-exporter/src/math-block/index.tsx deleted file mode 100644 index 19a0d5d92e..0000000000 --- a/packages/xl-pdf-exporter/src/math-block/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Math } from "@react-pdf/math"; -import { View } from "@react-pdf/renderer"; - -import { pdfBlockMappingForDefaultSchema } from "../pdf/defaultSchema/blocks.js"; - -// The math block's inline content as plain text (its LaTeX source). Local -// copy of `@blocknote/math-block`'s `getMathPlainTextContent` - importing the -// package would break headless (Node) exports, as its build carries a -// top-level CSS import. -const getPlainTextContent = (content: unknown): string => { - if (typeof content === "string") { - return content; - } - - if (Array.isArray(content)) { - return content - .map((node) => - node && typeof node === "object" && "text" in node ? node.text : "", - ) - .join(""); - } - - return ""; -}; - -/** - * Block mapping for `@blocknote/math-block` that renders math blocks as - * actual formulas (via `@react-pdf/math`, which converts the LaTeX to SVG - * paths with MathJax) instead of their LaTeX source: - * - * ```ts - * new PDFExporter(schema, { - * ...pdfDefaultSchemaMappings, - * blockMapping: { - * ...pdfDefaultSchemaMappings.blockMapping, - * mathBlock: mathBlockMapping, - * }, - * }); - * ``` - * - * Kept out of the default mappings so exporting without math blocks doesn't - * load MathJax. Invalid LaTeX renders as MathJax's own error output. - * - * There's no counterpart for inline math: react-pdf silently drops SVG - * elements inside `Text`, and paragraphs are rendered as `Text`, so inline - * math keeps the default mapping (its LaTeX source in a monospaced font). - */ -export const mathBlockMapping = ( - ...args: Parameters -) => { - const [block] = args; - - const source = getPlainTextContent(block.content); - if (!source.trim()) { - return pdfBlockMappingForDefaultSchema.mathBlock(...args); - } - - return ( - - {source} - - ); -}; diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index 63a3ccc4e5..723124df6e 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -712,7 +712,7 @@ - Open video file + Open video
@@ -755,7 +755,7 @@ - Open audio file + Open audio @@ -1186,94 +1186,5 @@ - - - - - {`a^2 = \sqrt{b^2 + c^2}`} - - - - - - - - - Inline math:{' '} - - - {`e^{i\pi} + 1 = 0`} - - - - - - - - - graph TD - - - {`A[Start] --> B[End]`} - - - - \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx index cc738f980f..7281749d28 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx @@ -720,7 +720,7 @@ - Open video file + Open video @@ -763,7 +763,7 @@ - Open audio file + Open audio @@ -1194,95 +1194,6 @@ - - - - - {`a^2 = \sqrt{b^2 + c^2}`} - - - - - - - - - Inline math:{' '} - - - {`e^{i\pi} + 1 = 0`} - - - - - - - - - graph TD - - - {`A[Start] --> B[End]`} - - - - ; - mathBlock: BlockConfig<"mathBlock", {}, "inline">; - diagram: BlockConfig<"diagram", {}, "inline">; } & typeof multiColumnSchema.blockSchema; const codeMapping = ( - block: BlockFromConfigNoChildren< - BSchema["codeBlock"] | BSchema["mathBlock"] | BSchema["diagram"], - any, - any - >, + block: BlockFromConfigNoChildren, ) => { // Code blocks hold plain content: at most a single unstyled text item. const [textItem, ...excessItems] = block.content as PlainContent; @@ -164,10 +157,7 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, - // TODO codeBlock: codeMapping, - mathBlock: codeMapping, - diagram: codeMapping, pageBreak: () => { return ; }, @@ -209,7 +199,7 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< {file( block.props, - "Open audio file", + exporter.dictionary.open_audio_file, , @@ -224,7 +214,7 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< {file( block.props, - "Open video file", + exporter.dictionary.open_video_file, , @@ -239,7 +229,7 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< {file( block.props, - "Open file", + exporter.dictionary.open_file, , diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx index 951932ac89..064953a2f3 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/inlinecontent.tsx @@ -4,13 +4,7 @@ import { } from "@blocknote/core"; import { Link, Text } from "@react-pdf/renderer"; -type ICSchema = DefaultInlineContentSchema & { - math: { - type: "math"; - propSchema: Record; - content: "plain"; - }; -}; +type ICSchema = DefaultInlineContentSchema; export const pdfInlineContentMappingForDefaultSchema: InlineContentMapping< ICSchema, @@ -28,13 +22,4 @@ export const pdfInlineContentMappingForDefaultSchema: InlineContentMapping< text: (ic, exporter) => { return exporter.transformStyledText(ic); }, - // TODO - // Renders inline math as its monospaced LaTeX source. - math: (ic) => { - return ( - - {ic.content} - - ); - }, }; diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx index 6111f9a428..11d0192c17 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx @@ -10,23 +10,15 @@ import { } from "@blocknote/core"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; import { Text } from "@react-pdf/renderer"; -import { testDocumentWithSourceBlocks as testDocument } from "@shared/testDocument.js"; +import { testDocument } from "@shared/testDocument.js"; import reactElementToJSXString from "react-element-to-jsx-string"; import { describe, expect, it } from "vite-plus/test"; -import { mathBlockMapping } from "../math-block/index.js"; import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js"; import { PDFExporter } from "./pdfExporter.js"; import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js"; -// import * as ReactPDF from "@react-pdf/renderer"; -// expect.extend({ toMatchImageSnapshot }); -// import { toMatchImageSnapshot } from "jest-image-snapshot"; -// import { pdf } from "pdf-to-img"; describe("exporter", () => { it("typescript: schema with extra block", async () => { - // const exporter = createPdfExporterForDefaultSchema(); - // const ps = exporter.transform(testDocument); - const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, @@ -160,42 +152,6 @@ describe("exporter", () => { new PDFExporter(schema, pdfDefaultSchemaMappings); }); - it("should export math as formulas with the math-block mappings", async () => { - // Assembled outside the constructor call as the schema doesn't include - // the math specs - like the default mappings, the math entries just map - // the block JSON. - const mappings = { - ...pdfDefaultSchemaMappings, - blockMapping: { - ...pdfDefaultSchemaMappings.blockMapping, - mathBlock: mathBlockMapping, - }, - }; - const exporter = new PDFExporter( - BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - pageBreak: createPageBreakBlockSpec(), - column: ColumnBlock, - columnList: ColumnListBlock, - }, - }), - mappings, - ); - - // The math block & inline math paragraph from the shared test document. - const transformed = await exporter.toReactPDFDocument( - testDocument.filter((block) => - ["math-block", "paragraph-with-inline-math"].includes(block.id), - ), - ); - const str = reactElementToJSXString(transformed); - - await expect(str).toMatchFileSnapshot( - "__snapshots__/exampleWithMathMappings.jsx", - ); - }); - it("should export a document", async () => { const exporter = new PDFExporter( BlockNoteSchema.create({ @@ -214,24 +170,11 @@ describe("exporter", () => { await expect(str).toMatchFileSnapshot("__snapshots__/example.jsx"); - // would be nice to compare pdf images, but currently doesn't work on mac os (due to node canvas installation issue) - - // await ReactPDF.render(transformed, `${__dirname}/example.pdf`); - // eslint-disable-next-line - // const b = await ReactPDF(transformed); - - // await toMatchBinaryFileSnapshot(b, `__snapshots__/example.pdf`); - // expect(b.toString("utf-8")).toMatchFileSnapshot( - // `__snapshots__/example.pdf` - // ); - // const doc = await pdf(`${__dirname}/example.pdf`); - - // // expect(doc.length).toBe(2); - // // expect(doc.metadata).toEqual({ ... }); - - // for await (const page of doc) { - // expect(page).toMatchImageSnapshot(); - // } + // Visual verification of an actually produced PDF lives in the browser + // suite (tests/src/end-to-end/exporters/exporterImages.test.tsx), which + // renders the file's pages with pdf.js and screenshots them - possible + // there because a real browser needs no native canvas dependencies, + // which is what blocked doing this in Node. }); it("should export a document with header and footer", async () => { @@ -255,11 +198,6 @@ describe("exporter", () => { await expect(str).toMatchFileSnapshot( "__snapshots__/exampleWithHeaderAndFooter.jsx", ); - - // await ReactPDF.render( - // transformed, - // `${__dirname}/exampleWithHeaderAndFooter.pdf` - // ); }); it("should export a document with a multi-column block", async () => { const schema = BlockNoteSchema.create({ diff --git a/packages/xl-pdf-exporter/vite.config.ts b/packages/xl-pdf-exporter/vite.config.ts index 71939de4a0..8bb17b2174 100644 --- a/packages/xl-pdf-exporter/vite.config.ts +++ b/packages/xl-pdf-exporter/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig( }, }, test: { - environment: "jsdom", + environment: "node", setupFiles: ["./vitestSetup.ts"], testTimeout: 15000, // assetsInclude: [ @@ -64,8 +64,6 @@ export default defineConfig( __dirname, "src/index.ts", ), - "diagram-block": path.resolve(__dirname, "src/diagram-block/index.tsx"), - "math-block": path.resolve(__dirname, "src/math-block/index.tsx"), }, name: "blocknote-xl-pdf-exporter", formats: ["es", "cjs"], diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index a50f0898d2..3fd8e3b7ec 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1020,11 +1020,11 @@ export const examples = { tags: ["Basic"], dependencies: { "@blocknote/code-block": "latest", - "@shikijs/core": "^4", - "@shikijs/engine-javascript": "^4", - "@shikijs/langs-precompiled": "^4", - "@shikijs/themes": "^4", - "@shikijs/types": "^4", + "@shikijs/core": "^4.4.3", + "@shikijs/engine-javascript": "^4.4.3", + "@shikijs/langs-precompiled": "^4.4.3", + "@shikijs/themes": "^4.4.3", + "@shikijs/types": "^4.4.3", } as any, }, title: "Custom Code Block Theme & Language", @@ -1133,6 +1133,7 @@ export const examples = { "@blocknote/xl-pdf-exporter": "latest", "@react-pdf/math": "^2.0.1", "@react-pdf/renderer": "^4.5.1", + "mathjax-full": "^3.2.2", } as any, pro: true, }, @@ -1209,6 +1210,8 @@ export const examples = { author: "jmarbutt", tags: [""], dependencies: { + "@blocknote/diagram-block": "latest", + "@blocknote/math-block": "latest", "@blocknote/xl-email-exporter": "latest", "@react-email/render": "^2.0.4", } as any, diff --git a/playground/tsconfig.json b/playground/tsconfig.json index b7da9d8c3d..565f300e5f 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -16,7 +16,10 @@ "jsx": "react-jsx", "composite": true, "types": ["node"], - "rootDir": ".." + "rootDir": "..", + "paths": { + "@shared/*": ["../shared/*"] + } }, "include": ["src", "../examples", "./vite.config.ts"], "references": [ diff --git a/playground/vite.config.ts b/playground/vite.config.ts index bce33bf165..1356a0d138 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -35,6 +35,7 @@ const devAliases: Record = { __dirname, "../packages/xl-email-exporter/src", ), + "@blocknote/code-block": resolve(__dirname, "../packages/code-block/src"), "@blocknote/math-block": resolve(__dirname, "../packages/math-block/src"), "@blocknote/diagram-block": resolve( __dirname, @@ -107,7 +108,6 @@ export default defineConfig(((conf: { command: string }) => ({ alias: conf.command === "build" ? { - // TODO: review // The exporters' optional peer dependencies, used by their // subpath entries (`…/diagram-block`, `…/math-block`). They // can't be resolved from the workspace-linked exporter packages @@ -115,10 +115,17 @@ export default defineConfig(((conf: { command: string }) => ({ // Vercel's filtered install), making Vite substitute an empty // `__vite-optional-peer-dep` stub that fails the build - so // resolve them from the playground's own dependencies instead. + // Points at `src/` (like the dev aliases): the prefix replace + // bypasses the package's exports map, and only under `src/` do + // subpath imports (`…/diagram-block/docx-exporter`) land on + // real directories with index files. "@blocknote/diagram-block": resolve( __dirname, - "../packages/diagram-block", + "../packages/diagram-block/src", ), + // The shared test-utils package the suggestion-gallery example + // imports; dev mode resolves it via devAliases above. + "@shared": resolve(__dirname, "../shared"), "@react-pdf/math": resolve( __dirname, "node_modules/@react-pdf/math", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f091de2d1c..e66a5ef222 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: overrides: '@headlessui/react': ^2.2.4 + shiki: ^4.4.3 + '@shikijs/rehype': ^4.4.3 + '@shikijs/types': ^4.4.3 '@tiptap/core': ^3.29.2 '@tiptap/pm': ^3.29.2 vitest: 4.1.7 @@ -182,20 +185,20 @@ importers: specifier: ^10.34.0 version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(webpack@5.105.4(esbuild@0.27.5)) '@shikijs/core': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/engine-javascript': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/langs-precompiled': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/themes': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/types': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@tiptap/core': specifier: ^3.29.2 version: 3.29.2(@tiptap/pm@3.29.2) @@ -279,7 +282,7 @@ importers: version: 3.1.15(@fumadocs/base-ui@16.5.0(@types/react@19.2.14)(fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.2))(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) fumadocs-typescript: specifier: ^5.1.1 - version: 5.2.1(7b71d35b2307cf0dedaa2cbc5003f54e) + version: 5.2.1(baf9dc8fd7877f82edfdafde309c823a) fumadocs-ui: specifier: npm:@fumadocs/base-ui@16.5.0 version: '@fumadocs/base-ui@16.5.0(@types/react@19.2.14)(fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.2)' @@ -292,6 +295,9 @@ importers: lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.5) + mathjax-full: + specifier: ^3.2.2 + version: 3.2.2 mermaid: specifier: ^11.0.0 version: 11.16.0 @@ -332,8 +338,8 @@ importers: specifier: ^3.1.0 version: 3.1.0 shiki: - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 tailwind-merge: specifier: ^3.4.0 version: 3.5.0 @@ -2565,20 +2571,20 @@ importers: specifier: ^9.0.2 version: 9.1.1(react@19.2.5) '@shikijs/core': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/engine-javascript': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/langs-precompiled': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/themes': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/types': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 react: specifier: ^19.2.3 version: 19.2.5 @@ -2812,6 +2818,9 @@ importers: '@react-pdf/renderer': specifier: ^4.5.1 version: 4.5.1(react@19.2.5) + mathjax-full: + specifier: ^3.2.2 + version: 3.2.2 react: specifier: ^19.2.3 version: 19.2.5 @@ -2956,9 +2965,15 @@ importers: '@blocknote/core': specifier: latest version: link:../../../packages/core + '@blocknote/diagram-block': + specifier: latest + version: link:../../../packages/diagram-block '@blocknote/mantine': specifier: latest version: link:../../../packages/mantine + '@blocknote/math-block': + specifier: latest + version: link:../../../packages/math-block '@blocknote/react': specifier: latest version: link:../../../packages/react @@ -5105,17 +5120,17 @@ importers: specifier: workspace:^ version: link:../core '@shikijs/core': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/engine-javascript': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/langs-precompiled': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@shikijs/themes': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 devDependencies: rimraf: specifier: ^5.0.10 @@ -5131,8 +5146,8 @@ importers: version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) optionalDependencies: '@shikijs/types': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 packages/core: dependencies: @@ -5143,8 +5158,8 @@ importers: specifier: ^0.1.4 version: 0.1.4(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.2) '@shikijs/types': - specifier: ^4 - version: 4.0.2 + specifier: ^4.4.3 + version: 4.4.3 '@tiptap/core': specifier: ^3.29.2 version: 3.29.2(@tiptap/pm@3.29.2) @@ -5192,7 +5207,7 @@ importers: version: 1.0.0-rc.22 prosemirror-highlight: specifier: ^0.15.3 - version: 0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2) + version: 0.15.3(@shikijs/types@4.4.3)(@types/hast@3.0.5)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2) prosemirror-model: specifier: ^1.25.11 version: 1.25.11 @@ -5279,18 +5294,51 @@ importers: '@blocknote/react': specifier: workspace:^ version: link:../react + '@blocknote/shared': + specifier: workspace:^ + version: link:../../shared + '@blocknote/xl-docx-exporter': + specifier: workspace:^ + version: link:../xl-docx-exporter + '@blocknote/xl-email-exporter': + specifier: workspace:^ + version: link:../xl-email-exporter + '@blocknote/xl-multi-column': + specifier: workspace:^ + version: link:../xl-multi-column + '@blocknote/xl-odt-exporter': + specifier: workspace:^ + version: link:../xl-odt-exporter + '@blocknote/xl-pdf-exporter': + specifier: workspace:^ + version: link:../xl-pdf-exporter + '@react-email/components': + specifier: ^1.0.12 + version: 1.0.12(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-pdf/renderer': + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) '@types/react': specifier: ^19.2.3 version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@zip.js/zip.js': + specifier: ^2.8.8 + version: 2.8.26 + docx: + specifier: ^9.6.1 + version: 9.6.1 react: specifier: ^19.2.5 version: 19.2.5 react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-element-to-jsx-string: + specifier: ^17.0.1 + version: 17.0.1(react-dom@19.2.5(react@19.2.5))(react-is@19.2.4)(react@19.2.5) react-icons: specifier: ^5.5.0 version: 5.6.0(react@19.2.5) @@ -5367,6 +5415,9 @@ importers: katex: specifier: ^0.16.11 version: 0.16.47(patch_hash=cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7) + mathml2omml: + specifier: ^0.5.0 + version: 0.5.0 prosemirror-model: specifier: ^1.25.4 version: 1.25.11 @@ -5380,9 +5431,33 @@ importers: '@blocknote/react': specifier: workspace:^ version: link:../react + '@blocknote/shared': + specifier: workspace:^ + version: link:../../shared + '@blocknote/xl-docx-exporter': + specifier: workspace:^ + version: link:../xl-docx-exporter + '@blocknote/xl-email-exporter': + specifier: workspace:^ + version: link:../xl-email-exporter '@blocknote/xl-multi-column': specifier: workspace:^ version: link:../xl-multi-column + '@blocknote/xl-odt-exporter': + specifier: workspace:^ + version: link:../xl-odt-exporter + '@blocknote/xl-pdf-exporter': + specifier: workspace:^ + version: link:../xl-pdf-exporter + '@react-email/components': + specifier: ^1.0.12 + version: 1.0.12(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@react-pdf/math': + specifier: ^2.0.1 + version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) + '@react-pdf/renderer': + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) '@types/katex': specifier: ^0.16.7 version: 0.16.8 @@ -5392,12 +5467,24 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@zip.js/zip.js': + specifier: ^2.8.8 + version: 2.8.26 + docx: + specifier: ^9.6.1 + version: 9.6.1 + mathjax-full: + specifier: ^3.2.2 + version: 3.2.2 react: specifier: ^19.2.5 version: 19.2.5 react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-element-to-jsx-string: + specifier: ^17.0.1 + version: 17.0.1(react-dom@19.2.5(react@19.2.5))(react-is@19.2.4)(react@19.2.5) react-icons: specifier: ^5.5.0 version: 5.6.0(react@19.2.5) @@ -5413,6 +5500,9 @@ importers: vite-plus: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + xml-formatter: + specifier: ^3.6.7 + version: 3.7.0 packages/react: dependencies: @@ -5824,19 +5914,10 @@ importers: image-meta: specifier: ^0.2.2 version: 0.2.2 - mathml2omml: - specifier: ^0.5.0 - version: 0.5.0 devDependencies: - '@blocknote/diagram-block': - specifier: workspace:^ - version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared - '@types/katex': - specifier: ^0.16.7 - version: 0.16.8 '@types/react': specifier: ^19.2.3 version: 19.2.14 @@ -5846,9 +5927,6 @@ importers: '@zip.js/zip.js': specifier: ^2.8.8 version: 2.8.26 - katex: - specifier: ^0.16.11 - version: 0.16.47(patch_hash=cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7) react: specifier: ^19.2.5 version: 19.2.5 @@ -5990,18 +6068,12 @@ importers: specifier: ^0.2.2 version: 0.2.2 devDependencies: - '@blocknote/diagram-block': - specifier: workspace:^ - version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@types/katex': - specifier: ^0.16.7 - version: 0.16.8 '@types/node': specifier: 22.13.13 version: 22.13.13 @@ -6011,9 +6083,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) - katex: - specifier: ^0.16.11 - version: 0.16.47(patch_hash=cbfb6fe178282ddb73b753dcb27f891295e4f9ed85f63bc1466b4e4b1e4ef6e7) react: specifier: ^19.2.5 version: 19.2.5 @@ -6048,36 +6117,18 @@ importers: specifier: ^4.5.1 version: 4.5.1(react@19.2.5) devDependencies: - '@blocknote/diagram-block': - specifier: workspace:^ - version: link:../diagram-block '@blocknote/shared': specifier: workspace:^ version: link:../../shared - '@react-pdf/math': - specifier: ^2.0.1 - version: 2.0.1(@react-pdf/renderer@4.5.1(react@19.2.5))(react@19.2.5) '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@types/jest-image-snapshot': - specifier: ^6.4.0 - version: 6.4.1 - '@types/jsdom': - specifier: ^21.1.7 - version: 21.1.7 '@types/react': specifier: ^19.2.3 version: 19.2.14 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) - jest-image-snapshot: - specifier: ^6.5.1 - version: 6.5.2 - pdf-to-img: - specifier: ^4.5.0 - version: 4.5.0 react: specifier: ^19.2.5 version: 19.2.5 @@ -6302,12 +6353,18 @@ importers: '@types/node': specifier: 22.13.13 version: 22.13.13 + '@zip.js/zip.js': + specifier: ^2.8.8 + version: 2.8.26 typescript: specifier: ^5.9.3 version: 5.9.3 vite-plus: specifier: 'catalog:' version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@22.13.13)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@22.13.13)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + xml-formatter: + specifier: ^3.6.7 + version: 3.7.0 tests: dependencies: @@ -6336,12 +6393,21 @@ importers: '@blocknote/shadcn': specifier: workspace:^ version: link:../packages/shadcn + '@blocknote/xl-email-exporter': + specifier: workspace:^ + version: link:../packages/xl-email-exporter '@blocknote/xl-multi-column': specifier: workspace:^ version: link:../packages/xl-multi-column + '@blocknote/xl-pdf-exporter': + specifier: workspace:^ + version: link:../packages/xl-pdf-exporter '@playwright/test': specifier: 1.60.0 version: 1.60.0 + '@react-pdf/renderer': + specifier: ^4.5.1 + version: 4.5.1(react@19.2.5) '@tailwindcss/vite': specifier: ^4.1.14 version: 4.2.2(vite@8.0.8(@types/node@20.19.39)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) @@ -6369,6 +6435,9 @@ importers: htmlfy: specifier: ^0.6.7 version: 0.6.7 + pdfjs-dist: + specifier: ^4.10.38 + version: 4.10.38 react: specifier: ^19.2.5 version: 19.2.5 @@ -7640,30 +7709,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@jest/diff-sequences@30.3.0': - resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/expect-utils@30.3.0': - resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/get-type@30.1.0': - resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/pattern@30.0.1': - resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/schemas@30.0.5': - resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - '@jest/types@30.3.0': - resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -9923,43 +9968,40 @@ packages: resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} engines: {node: '>=20'} - '@shikijs/engine-javascript@3.23.0': - resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} - - '@shikijs/engine-javascript@4.0.2': - resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} - '@shikijs/engine-oniguruma@3.23.0': - resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} - - '@shikijs/engine-oniguruma@4.0.2': - resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} engines: {node: '>=20'} - '@shikijs/langs-precompiled@4.0.2': - resolution: {integrity: sha512-I7uqbU58tSTgChNtu7dTnJWOo0lAsZMyv1RT9DCb+qlcQu5fkp2lAeISo+2qxunYSX+l81nI83lYp75OoqYzqg==} + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} engines: {node: '>=20'} - '@shikijs/langs@3.23.0': - resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/langs-precompiled@4.4.3': + resolution: {integrity: sha512-i3+91QcqVBji2mlCinQpHQyoZfciaWsHCuv5XZwMXGEmSAMCW0oCdrWp+zMKLf0CwHd9XT+E5RBDJ0RY1USWCw==} + engines: {node: '>=20'} - '@shikijs/langs@4.0.2': - resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} engines: {node: '>=20'} '@shikijs/primitive@4.0.2': resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} engines: {node: '>=20'} - '@shikijs/rehype@3.23.0': - resolution: {integrity: sha512-GepKJxXHbXFfAkiZZZ+4V7x71Lw3s0ALYmydUxJRdvpKjSx9FOMSaunv6WRLFBXR6qjYerUq1YZQno+2gLEPwA==} + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} - '@shikijs/themes@3.23.0': - resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/rehype@4.4.3': + resolution: {integrity: sha512-vkG9jG1aRnrx05R31uAOKQHE8qpY7r1cBXE2sAZgFK2IaPnQHwaP4L1C6amQixmZ8thBsKwfbSsYxMDoo46UWQ==} + engines: {node: '>=20'} - '@shikijs/themes@4.0.2': - resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} engines: {node: '>=20'} '@shikijs/transformers@3.23.0': @@ -9971,19 +10013,13 @@ packages: peerDependencies: typescript: '>=5.5.0' - '@shikijs/types@3.23.0': - resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} - - '@shikijs/types@4.0.2': - resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} engines: {node: '>=20'} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@sinclair/typebox@0.34.49': - resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} - '@smithy/chunked-blob-reader-native@4.2.3': resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==} engines: {node: '>=18.0.0'} @@ -10570,23 +10606,8 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/istanbul-lib-coverage@2.0.6': - resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} - - '@types/istanbul-lib-report@3.0.3': - resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} - - '@types/istanbul-reports@3.0.4': - resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - - '@types/jest-image-snapshot@6.4.1': - resolution: {integrity: sha512-pj3Sdc7Cx5mMLUttPprazSDQCur2cr512Dm38e9aAHI55LDxEhqdyqzK9myC4EmEy7sPAF2nGJ8zifX4qso7sQ==} - - '@types/jest@30.0.0': - resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -10654,9 +10675,6 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} - '@types/pixelmatch@5.2.6': - resolution: {integrity: sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==} - '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -10676,9 +10694,6 @@ packages: '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} - '@types/stack-utils@2.0.3': - resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} - '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -10706,12 +10721,6 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yargs-parser@21.0.3': - resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} - - '@types/yargs@17.0.35': - resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260615.1': resolution: {integrity: sha512-EHrtoVGEEhIhsnGe+b8w0FoM9JfIw5SkoPwO8ifaU0PrYm2UbyPbj2I2hOTxtk458t0irvGz2+8cshylBRbKng==} engines: {node: '>=16.20.0'} @@ -11685,10 +11694,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} @@ -12363,10 +12368,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -12524,10 +12525,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - expect@30.3.0: - resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -12761,7 +12758,7 @@ packages: fumadocs-core: ^16.7.0 fumadocs-ui: ^16.7.0 react: '*' - shiki: '*' + shiki: ^4.4.3 typescript: '*' peerDependenciesMeta: '@types/estree': @@ -12823,10 +12820,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-stdin@5.0.1: - resolution: {integrity: sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==} - engines: {node: '>=0.12.0'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -12883,9 +12876,6 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - glur@1.1.2: - resolution: {integrity: sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -13304,39 +13294,6 @@ packages: jay-peg@1.1.1: resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} - jest-diff@30.3.0: - resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-image-snapshot@6.5.2: - resolution: {integrity: sha512-frenWThr5ddnnokcX5N4gwi41hA5TiUOdhv/JoGcJrOaktHjrk4/7XbiHKW52lgKX+vei6QkRlgM7fkYQ15nPg==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - peerDependencies: - jest: '>=20 <31' - peerDependenciesMeta: - jest: - optional: true - - jest-matcher-utils@30.3.0: - resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-message-util@30.3.0: - resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-mock@30.3.0: - resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-regex-util@30.0.1: - resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - - jest-util@30.3.0: - resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -14240,11 +14197,11 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} - oniguruma-parser@0.12.1: - resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} - oniguruma-to-es@4.3.5: - resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} @@ -14410,21 +14367,12 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - path2d@0.2.2: - resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==} - engines: {node: '>=6'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pdf-to-img@4.5.0: - resolution: {integrity: sha512-GCM2n+aYiupQmyoOmuj/0Q3Tr0hJg9iFVGiOq79y1ePF2cTGzdbPFd3lNlH1IxvqcGgQzwyo7KfNvtmefBxIiQ==} - engines: {node: '>=18'} - hasBin: true - - pdfjs-dist@4.2.67: - resolution: {integrity: sha512-rJmuBDFpD7cqC8WIkQUEClyB4UAH05K4AsyewToMTp2gSy3Rrx8c1ydAVqlJlGv3yZSOrhEERQU/4ScQQFlLHA==} - engines: {node: '>=18'} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -14473,10 +14421,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pixelmatch@5.3.0: - resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} - hasBin: true - pixelmatch@7.2.0: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true @@ -14497,14 +14441,6 @@ packages: png-js@2.0.0: resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} - pngjs@3.4.0: - resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} - engines: {node: '>=4.0.0'} - - pngjs@6.0.0: - resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} - engines: {node: '>=12.13.0'} - pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -14576,10 +14512,6 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-format@30.3.0: - resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} - engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} @@ -14618,7 +14550,7 @@ packages: peerDependencies: '@lezer/common': ^1.0.0 '@lezer/highlight': ^1.0.0 - '@shikijs/types': ^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0 + '@shikijs/types': ^4.4.3 '@types/hast': ^3.0.0 highlight.js: ^11.9.0 lowlight: ^3.1.0 @@ -14773,9 +14705,6 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-is@18.3.1: - resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.4: resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} @@ -15172,11 +15101,8 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shiki@3.23.0: - resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} - - shiki@4.0.2: - resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} engines: {node: '>=20'} side-channel-list@1.0.1: @@ -15221,10 +15147,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - slate-history@0.110.3: resolution: {integrity: sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==} peerDependencies: @@ -15289,13 +15211,6 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - ssim.js@3.5.0: - resolution: {integrity: sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -16224,6 +16139,9 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} +ignoredOptionalDependencies: + - '@napi-rs/canvas' + snapshots: '@ai-sdk/anthropic@3.0.2(zod@4.3.6)': @@ -17679,33 +17597,6 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jest/diff-sequences@30.3.0': {} - - '@jest/expect-utils@30.3.0': - dependencies: - '@jest/get-type': 30.1.0 - - '@jest/get-type@30.1.0': {} - - '@jest/pattern@30.0.1': - dependencies: - '@types/node': 22.13.13 - jest-regex-util: 30.0.1 - - '@jest/schemas@30.0.5': - dependencies: - '@sinclair/typebox': 0.34.49 - - '@jest/types@30.3.0': - dependencies: - '@jest/pattern': 30.0.1 - '@jest/schemas': 30.0.5 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.13.13 - '@types/yargs': 17.0.35 - chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -17883,7 +17774,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdx': 2.0.13 acorn: 8.16.0 collapse-white-space: 2.1.0 @@ -19967,105 +19858,93 @@ snapshots: '@shikijs/core@3.23.0': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 '@shikijs/core@4.0.2': dependencies: '@shikijs/primitive': 4.0.2 - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-html: 9.0.5 - '@shikijs/engine-javascript@3.23.0': - dependencies: - '@shikijs/types': 3.23.0 - '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.5 - - '@shikijs/engine-javascript@4.0.2': + '@shikijs/core@4.4.3': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - oniguruma-to-es: 4.3.5 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 - '@shikijs/engine-oniguruma@3.23.0': + '@shikijs/engine-javascript@4.4.3': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 - '@shikijs/engine-oniguruma@4.0.2': + '@shikijs/engine-oniguruma@4.4.3': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs-precompiled@4.0.2': + '@shikijs/langs-precompiled@4.4.3': dependencies: - '@shikijs/types': 4.0.2 - oniguruma-to-es: 4.3.5 + '@shikijs/types': 4.4.3 + oniguruma-to-es: 4.3.6 - '@shikijs/langs@3.23.0': + '@shikijs/langs@4.4.3': dependencies: - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.4.3 - '@shikijs/langs@4.0.2': + '@shikijs/primitive@4.0.2': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 - '@shikijs/primitive@4.0.2': + '@shikijs/primitive@4.4.3': dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 - '@shikijs/rehype@3.23.0': + '@shikijs/rehype@4.4.3': dependencies: - '@shikijs/types': 3.23.0 - '@types/hast': 3.0.4 + '@shikijs/types': 4.4.3 + '@types/hast': 3.0.5 hast-util-to-string: 3.0.1 - shiki: 3.23.0 + shiki: 4.4.3 unified: 11.0.5 unist-util-visit: 5.1.0 - '@shikijs/themes@3.23.0': + '@shikijs/themes@4.4.3': dependencies: - '@shikijs/types': 3.23.0 - - '@shikijs/themes@4.0.2': - dependencies: - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 '@shikijs/transformers@3.23.0': dependencies: '@shikijs/core': 3.23.0 - '@shikijs/types': 3.23.0 + '@shikijs/types': 4.4.3 '@shikijs/twoslash@4.0.2(typescript@5.9.3)': dependencies: '@shikijs/core': 4.0.2 - '@shikijs/types': 4.0.2 + '@shikijs/types': 4.4.3 twoslash: 0.3.6(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@shikijs/types@3.23.0': - dependencies: - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - '@shikijs/types@4.0.2': + '@shikijs/types@4.4.3': dependencies: '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@shikijs/vscode-textmate@10.0.2': {} - '@sinclair/typebox@0.34.49': {} - '@smithy/chunked-blob-reader-native@4.2.3': dependencies: '@smithy/util-base64': 4.3.2 @@ -20804,31 +20683,10 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@3.0.4': + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 - '@types/istanbul-lib-coverage@2.0.6': {} - - '@types/istanbul-lib-report@3.0.3': - dependencies: - '@types/istanbul-lib-coverage': 2.0.6 - - '@types/istanbul-reports@3.0.4': - dependencies: - '@types/istanbul-lib-report': 3.0.3 - - '@types/jest-image-snapshot@6.4.1': - dependencies: - '@types/jest': 30.0.0 - '@types/pixelmatch': 5.2.6 - ssim.js: 3.5.0 - - '@types/jest@30.0.0': - dependencies: - expect: 30.3.0 - pretty-format: 30.3.0 - '@types/jsdom@21.1.7': dependencies: '@types/node': 22.13.13 @@ -20907,10 +20765,6 @@ snapshots: pg-protocol: 1.13.0 pg-types: 2.2.0 - '@types/pixelmatch@5.2.6': - dependencies: - '@types/node': 22.13.13 - '@types/prop-types@15.7.15': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': @@ -20927,8 +20781,6 @@ snapshots: '@types/retry@0.12.2': {} - '@types/stack-utils@2.0.3': {} - '@types/statuses@2.0.6': {} '@types/tedious@4.0.14': @@ -20953,12 +20805,6 @@ snapshots: dependencies: '@types/node': 22.13.13 - '@types/yargs-parser@21.0.3': {} - - '@types/yargs@17.0.35': - dependencies: - '@types/yargs-parser': 21.0.3 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260615.1': optional: true @@ -22131,6 +21977,7 @@ snapshots: dependencies: node-addon-api: 7.1.1 prebuild-install: 7.1.3 + optional: true ccount@2.0.1: {} @@ -22190,8 +22037,6 @@ snapshots: chrome-trace-event@1.0.4: {} - ci-info@4.4.0: {} - citty@0.1.6: dependencies: consola: 3.4.2 @@ -22987,8 +22832,6 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@2.0.0: {} - escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} @@ -23183,15 +23026,6 @@ snapshots: expect-type@1.3.0: {} - expect@30.3.0: - dependencies: - '@jest/expect-utils': 30.3.0 - '@jest/get-type': 30.1.0 - jest-matcher-utils: 30.3.0 - jest-message-util: 30.3.0 - jest-mock: 30.3.0 - jest-util: 30.3.0 - exsolve@1.0.8: {} extend-shallow@2.0.1: @@ -23317,7 +23151,7 @@ snapshots: dependencies: '@formatjs/intl-localematcher': 0.8.2 '@orama/orama': 3.1.18 - '@shikijs/rehype': 3.23.0 + '@shikijs/rehype': 4.4.3 '@shikijs/transformers': 3.23.0 estree-util-value-to-estree: 3.5.0 github-slugger: 2.0.0 @@ -23331,7 +23165,7 @@ snapshots: remark-gfm: 4.0.1 remark-rehype: 11.1.2 scroll-into-view-if-needed: 3.1.0 - shiki: 3.23.0 + shiki: 4.4.3 tinyglobby: 0.2.16 unist-util-visit: 5.1.0 optionalDependencies: @@ -23383,7 +23217,7 @@ snapshots: mdast-util-gfm: 3.1.0 mdast-util-to-hast: 13.2.1 react: 19.2.5 - shiki: 4.0.2 + shiki: 4.4.3 tailwind-merge: 3.5.0 twoslash: 0.3.6(typescript@5.9.3) optionalDependencies: @@ -23394,7 +23228,7 @@ snapshots: - supports-color - typescript - fumadocs-typescript@5.2.1(7b71d35b2307cf0dedaa2cbc5003f54e): + fumadocs-typescript@5.2.1(baf9dc8fd7877f82edfdafde309c823a): dependencies: estree-util-value-to-estree: 3.5.0 fumadocs-core: 16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6) @@ -23409,11 +23243,11 @@ snapshots: unist-util-visit: 5.1.0 optionalDependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/react': 19.2.14 fumadocs-ui: '@fumadocs/base-ui@16.5.0(@types/react@19.2.14)(fumadocs-core@16.5.0(@types/react@19.2.14)(lucide-react@0.562.0(react@19.2.5))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(zod@4.3.6))(next@16.2.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(tailwindcss@4.2.2)' - shiki: 4.0.2 + shiki: 4.4.3 transitivePeerDependencies: - supports-color @@ -23473,8 +23307,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-stdin@5.0.1: {} - get-stream@6.0.1: {} get-symbol-description@1.1.0: @@ -23535,8 +23367,6 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 - glur@1.1.2: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -23586,7 +23416,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 comma-separated-tokens: 2.0.3 devlop: 1.1.0 estree-util-attach-comments: 3.0.0 @@ -23605,7 +23435,7 @@ snapshots: hast-util-to-html@9.0.5: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 ccount: 2.0.1 comma-separated-tokens: 2.0.3 @@ -23620,7 +23450,7 @@ snapshots: hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 devlop: 1.1.0 @@ -23639,11 +23469,11 @@ snapshots: hast-util-to-string@3.0.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-whitespace@3.0.0: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 headers-polyfill@4.0.3: {} @@ -23975,59 +23805,6 @@ snapshots: dependencies: restructure: 3.0.2 - jest-diff@30.3.0: - dependencies: - '@jest/diff-sequences': 30.3.0 - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - pretty-format: 30.3.0 - - jest-image-snapshot@6.5.2: - dependencies: - chalk: 4.1.2 - get-stdin: 5.0.1 - glur: 1.1.2 - lodash: 4.18.1 - pixelmatch: 5.3.0 - pngjs: 3.4.0 - ssim.js: 3.5.0 - - jest-matcher-utils@30.3.0: - dependencies: - '@jest/get-type': 30.1.0 - chalk: 4.1.2 - jest-diff: 30.3.0 - pretty-format: 30.3.0 - - jest-message-util@30.3.0: - dependencies: - '@babel/code-frame': 7.29.0 - '@jest/types': 30.3.0 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - pretty-format: 30.3.0 - slash: 3.0.0 - stack-utils: 2.0.6 - - jest-mock@30.3.0: - dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.13.13 - jest-util: 30.3.0 - - jest-regex-util@30.0.1: {} - - jest-util@30.3.0: - dependencies: - '@jest/types': 30.3.0 - '@types/node': 22.13.13 - chalk: 4.1.2 - ci-info: 4.4.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - jest-worker@27.5.1: dependencies: '@types/node': 22.13.13 @@ -24482,7 +24259,7 @@ snapshots: mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -24493,7 +24270,7 @@ snapshots: mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 @@ -24520,7 +24297,7 @@ snapshots: mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 devlop: 1.1.0 mdast-util-from-markdown: 2.0.3 @@ -24535,7 +24312,7 @@ snapshots: mdast-util-to-hast@13.2.1: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 @@ -25113,7 +24890,8 @@ snapshots: dependencies: semver: 7.7.4 - node-addon-api@7.1.1: {} + node-addon-api@7.1.1: + optional: true node-exports-info@1.6.0: dependencies: @@ -25238,11 +25016,11 @@ snapshots: dependencies: mimic-function: 5.0.1 - oniguruma-parser@0.12.1: {} + oniguruma-parser@0.12.2: {} - oniguruma-to-es@4.3.5: + oniguruma-to-es@4.3.6: dependencies: - oniguruma-parser: 0.12.1 + oniguruma-parser: 0.12.2 regex: 6.1.0 regex-recursion: 6.0.2 @@ -25700,26 +25478,9 @@ snapshots: path-type@4.0.0: {} - path2d@0.2.2: - optional: true - pathe@2.0.3: {} - pdf-to-img@4.5.0: - dependencies: - canvas: 3.1.0 - pdfjs-dist: 4.2.67 - transitivePeerDependencies: - - encoding - - supports-color - - pdfjs-dist@4.2.67: - optionalDependencies: - canvas: 2.11.2 - path2d: 0.2.2 - transitivePeerDependencies: - - encoding - - supports-color + pdfjs-dist@4.10.38: {} peberminta@0.9.0: {} @@ -25764,10 +25525,6 @@ snapshots: picomatch@4.0.4: {} - pixelmatch@5.3.0: - dependencies: - pngjs: 6.0.0 - pixelmatch@7.2.0: dependencies: pngjs: 7.0.0 @@ -25790,10 +25547,6 @@ snapshots: dependencies: fflate: 0.8.3 - pngjs@3.4.0: {} - - pngjs@6.0.0: {} - pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -25863,12 +25616,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 - pretty-format@30.3.0: - dependencies: - '@jest/schemas': 30.0.5 - ansi-styles: 5.2.0 - react-is: 18.3.1 - prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -25911,10 +25658,10 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-view: 1.42.2 - prosemirror-highlight@0.15.3(@shikijs/types@4.0.2)(@types/hast@3.0.4)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2): + prosemirror-highlight@0.15.3(@shikijs/types@4.4.3)(@types/hast@3.0.5)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2): optionalDependencies: - '@shikijs/types': 4.0.2 - '@types/hast': 3.0.4 + '@shikijs/types': 4.4.3 + '@types/hast': 3.0.5 prosemirror-model: 1.25.11 prosemirror-state: 1.4.4 prosemirror-transform: 1.12.0 @@ -26130,8 +25877,6 @@ snapshots: react-is@17.0.2: {} - react-is@18.3.1: {} - react-is@19.2.4: {} react-medium-image-zoom@5.4.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -26334,7 +26079,7 @@ snapshots: rehype-recma@1.0.0: dependencies: '@types/estree': 1.0.8 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 hast-util-to-estree: 3.1.3 transitivePeerDependencies: - supports-color @@ -26368,7 +26113,7 @@ snapshots: remark-rehype@11.1.2: dependencies: - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 '@types/mdast': 4.0.4 mdast-util-to-hast: 13.2.1 unified: 11.0.5 @@ -26681,27 +26426,16 @@ snapshots: shell-quote@1.8.3: {} - shiki@3.23.0: + shiki@4.4.3: dependencies: - '@shikijs/core': 3.23.0 - '@shikijs/engine-javascript': 3.23.0 - '@shikijs/engine-oniguruma': 3.23.0 - '@shikijs/langs': 3.23.0 - '@shikijs/themes': 3.23.0 - '@shikijs/types': 3.23.0 + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 - - shiki@4.0.2: - dependencies: - '@shikijs/core': 4.0.2 - '@shikijs/engine-javascript': 4.0.2 - '@shikijs/engine-oniguruma': 4.0.2 - '@shikijs/langs': 4.0.2 - '@shikijs/themes': 4.0.2 - '@shikijs/types': 4.0.2 - '@shikijs/vscode-textmate': 10.0.2 - '@types/hast': 3.0.4 + '@types/hast': 3.0.5 side-channel-list@1.0.1: dependencies: @@ -26760,8 +26494,6 @@ snapshots: sisteransi@1.0.5: {} - slash@3.0.0: {} - slate-history@0.110.3(slate@0.110.2): dependencies: is-plain-object: 5.0.0 @@ -26846,12 +26578,6 @@ snapshots: sprintf-js@1.0.3: {} - ssim.js@3.5.0: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} stacktrace-parser@0.1.11: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8d3401e543..23838b263f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,6 +15,13 @@ publicHoistPattern: - "prosemirror-*" overrides: "@headlessui/react": "^2.2.4" + # fumadocs-core still declares @shikijs/rehype ^3 while the rest of the + # workspace (code-block, docs, fumadocs-twoslash) is on shiki 4.4.3 - two + # shiki identities in one graph make the docs' rehype transformer types + # clash ("ShikiTransformer | ShikiTransformer"). Force one shiki everywhere. + "shiki": "^4.4.3" + "@shikijs/rehype": "^4.4.3" + "@shikijs/types": "^4.4.3" "@tiptap/core": "^3.29.2" "@tiptap/pm": "^3.29.2" "vitest": "4.1.7" @@ -54,3 +61,8 @@ minimumReleaseAgeExclude: - "@oxlint-tsgolint/*" - oxfmt - "@oxfmt/*" + +# pdfjs-dist pulls @napi-rs/canvas (native binaries) for Node-side +# rendering, which we never do - pdf.js only runs in the browser suite. +ignoredOptionalDependencies: + - "@napi-rs/canvas" diff --git a/shared/package.json b/shared/package.json index 66f2d870e4..e94c037ca4 100644 --- a/shared/package.json +++ b/shared/package.json @@ -16,15 +16,25 @@ }, "devDependencies": { "@types/node": "22.13.13", + "@zip.js/zip.js": "^2.8.8", "typescript": "^5.9.3", - "vite-plus": "catalog:" + "vite-plus": "catalog:", + "xml-formatter": "^3.6.7" }, "peerDependencies": { - "image-meta": "^0.2.1" + "@zip.js/zip.js": "^2.8.8", + "image-meta": "^0.2.1", + "xml-formatter": "^3.6.7" }, "peerDependenciesMeta": { + "@zip.js/zip.js": { + "optional": true + }, "image-meta": { "optional": true + }, + "xml-formatter": { + "optional": true } } } diff --git a/shared/util/browserImageTestUtil.ts b/shared/util/browserImageTestUtil.ts new file mode 100644 index 0000000000..5736016ca1 --- /dev/null +++ b/shared/util/browserImageTestUtil.ts @@ -0,0 +1,55 @@ +/** + * Decodes a data URL image and samples its pixels, so browser tests can + * assert generated images actually contain ink rather than being + * valid-but-blank - and that the ink covers the image rather than sitting + * letterboxed in a corner of it (`inkedFractionX`/`inkedFractionY` are the + * fractions of the width/height the inked bounding box spans). Browser-only + * (needs image decoding and a canvas). + */ +export async function decodeAndSample(dataURL: string): Promise<{ + width: number; + height: number; + inkedPixels: number; + inkedFractionX: number; + inkedFractionY: number; +}> { + const image = new Image(); + image.src = dataURL; + await image.decode(); + + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth || image.width; + canvas.height = image.naturalHeight || image.height; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("2D canvas context unavailable for decoding images"); + } + context.drawImage(image, 0, 0); + + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + let inkedPixels = 0; + let minX = canvas.width; + let maxX = -1; + let minY = canvas.height; + let maxY = -1; + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] > 0) { + inkedPixels++; + const pixelIndex = (i - 3) / 4; + const x = pixelIndex % canvas.width; + const y = Math.floor(pixelIndex / canvas.width); + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + } + + return { + width: canvas.width, + height: canvas.height, + inkedPixels, + inkedFractionX: maxX < minX ? 0 : (maxX - minX + 1) / canvas.width, + inkedFractionY: maxY < minY ? 0 : (maxY - minY + 1) / canvas.height, + }; +} diff --git a/shared/util/odtTestUtil.ts b/shared/util/odtTestUtil.ts new file mode 100644 index 0000000000..8b794a18d4 --- /dev/null +++ b/shared/util/odtTestUtil.ts @@ -0,0 +1,55 @@ +import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; +import { expect } from "vite-plus/test"; +import xmlFormat from "xml-formatter"; + +/** + * Verifies an exported ODT document against file snapshots: `styles.xml`, + * `content.xml`, and the embedded objects (the sub-documents that e.g. + * formulas are stored in, as separate `Object N/content.xml` zip entries). + * Tests that don't declare `objects` assert the document embeds none, so + * object payloads can't go unverified. + */ +export async function testODTDocumentAgainstSnapshot( + odt: Blob, + snapshots: { + styles: string; + content: string; + objects?: { snapshot: string; expectedCount: number }; + }, +) { + const zipReader = new ZipReader(new BlobReader(odt)); + const entries = await zipReader.getEntries(); + const stylesXML = entries.find( + (entry) => entry.filename === "styles.xml", + ) as FileEntry; + const contentXML = entries.find( + (entry) => entry.filename === "content.xml", + ) as FileEntry; + + expect(stylesXML).toBeDefined(); + expect(contentXML).toBeDefined(); + await expect( + xmlFormat(await stylesXML.getData(new TextWriter())), + ).toMatchFileSnapshot(snapshots.styles); + await expect( + xmlFormat(await contentXML.getData(new TextWriter())), + ).toMatchFileSnapshot(snapshots.content); + + const objectEntries = entries + .filter((entry) => /^Object \d+\/content\.xml$/.test(entry.filename)) + .sort((a, b) => a.filename.localeCompare(b.filename)) as FileEntry[]; + expect(objectEntries).toHaveLength(snapshots.objects?.expectedCount ?? 0); + + if (snapshots.objects) { + const objectContents = await Promise.all( + objectEntries.map( + async (entry) => + `\n` + + xmlFormat(await entry.getData(new TextWriter())), + ), + ); + await expect(objectContents.join("\n")).toMatchFileSnapshot( + snapshots.objects.snapshot, + ); + } +} diff --git a/shared/vite.config.ts b/shared/vite.config.ts index 817e8db181..f4fcdd8017 100644 --- a/shared/vite.config.ts +++ b/shared/vite.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ { auto: true }, { pattern: "!**/*.tsbuildinfo", base: "workspace" }, ], + // Without declared outputs the cache can't restore `dist/` on a + // cache hit, leaving consumers type-checking against missing or + // stale declarations. + output: ["dist/**", "!dist/**/*.tsbuildinfo"], }, }, }, diff --git a/tests/docker-run.sh b/tests/docker-run.sh index e9fd9eb1c8..41618fa38d 100755 --- a/tests/docker-run.sh +++ b/tests/docker-run.sh @@ -77,6 +77,9 @@ mounts+=( mounts+=( -v "$PWD/shared/testDocument.ts:/work/shared/testDocument.ts" -v "$PWD/shared/formatConversionTestUtil.ts:/work/shared/formatConversionTestUtil.ts" + -v "$PWD/shared/api:/work/shared/api" + -v "$PWD/shared/util:/work/shared/util" + -v "$PWD/shared/assets:/work/shared/assets" ) # Mount the report dir so the html reporter's output lands on the host instead # of being thrown away with the container. Created on the host first so docker diff --git a/tests/package.json b/tests/package.json index 8f0c301109..94d596fb6f 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,6 +13,10 @@ "@blocknote/core": "workspace:^", "@blocknote/mantine": "workspace:^", "@blocknote/diagram-block": "workspace:^", + "@blocknote/xl-email-exporter": "workspace:^", + "@blocknote/xl-pdf-exporter": "workspace:^", + "@react-pdf/renderer": "^4.5.1", + "pdfjs-dist": "^4.10.38", "@blocknote/math-block": "workspace:^", "@blocknote/react": "workspace:^", "@blocknote/shadcn": "workspace:^", diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-chromium-linux.png new file mode 100644 index 0000000000..fc78cbb3e6 Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-firefox-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-firefox-linux.png new file mode 100644 index 0000000000..82eacec13e Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-firefox-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-webkit-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-webkit-linux.png new file mode 100644 index 0000000000..04c04c01af Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-webkit-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-1-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-1-chromium-linux.png new file mode 100644 index 0000000000..20ddc8d64b Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-1-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-2-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-2-chromium-linux.png new file mode 100644 index 0000000000..c4f983a18e Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-2-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-3-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-3-chromium-linux.png new file mode 100644 index 0000000000..bc81b617ba Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-3-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-4-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-4-chromium-linux.png new file mode 100644 index 0000000000..803c5ed6e5 Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-4-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/exporterImages.test.tsx b/tests/src/end-to-end/exporters/exporterImages.test.tsx new file mode 100644 index 0000000000..7cf24e1e64 --- /dev/null +++ b/tests/src/end-to-end/exporters/exporterImages.test.tsx @@ -0,0 +1,236 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { diagramBlockMapping as emailDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter"; +import { diagramBlockMapping as pdfDiagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; +import { + inlineMathMapping as emailInlineMathMapping, + mathBlockMapping as emailMathBlockMapping, +} from "@blocknote/math-block/email-exporter"; +import { + inlineMathMapping as pdfInlineMathMapping, + mathBlockMapping as pdfMathBlockMapping, +} from "@blocknote/math-block/pdf-exporter"; +import { + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +import { pdf } from "@react-pdf/renderer"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { decodeAndSample } from "@shared/util/browserImageTestUtil.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { browserName } from "../../utils/context.js"; +import { screenshotFull } from "../../utils/screenshotFull.js"; + +// Complete exports of the full shared test document with the default +// mappings in a real browser, where the mappings' `typeof document` checks +// select the built-in image implementations. These are the only tests of +// that composition - the packages' (node) unit suites always take the +// headless side of those checks (or plug in stubs), and the colocated +// `.browser.test` files call the implementations directly, bypassing the +// mappings. A broken default wiring or inverted environment check passes +// every one of those tests and only fails here - while breaking the primary +// real-world path, exporting from the browser. Lives in this package because +// it spans math-block, diagram-block and the exporters, and this is the +// repo's only browser-mode runner. + +// An invalid diagram and an invalid formula, whose typed errors must render +// as placeholders without failing the export. Unlike the packages' node +// suites, this exercises the real error classes through vite's +// bundling/interop of mermaid and mathjax-full - e.g. a mis-resolved +// `TexError` import only breaks here (and in real apps), not in node. +const invalidDiagramBlock = { + id: "invalid-diagram", + type: "diagram", + props: {}, + content: [{ type: "text", text: "not a valid diagram !!", styles: {} }], + children: [], +} as any; +const invalidMathBlock = { + id: "invalid-math", + type: "mathBlock", + props: {}, + // A structural error: MathJax's `noundefined` package renders unknown + // commands as text rather than erroring, so an unknown command wouldn't + // reach the error path. + content: [{ type: "text", text: "\\frac{1}{", styles: {} }], + children: [], +} as any; + +const schema = () => BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }); + +afterEach(() => { + document.getElementById("export-under-test")?.remove(); +}); + +// Creates the container the export under test is rendered into (removed +// again by the afterEach above). +function createExportFrame(width: string) { + const frame = document.createElement("div"); + frame.id = "export-under-test"; + frame.style.width = width; + frame.style.background = "white"; + document.body.append(frame); + return frame; +} + +describe("email export through a complete exporter in the browser", () => { + test("renders math and diagrams to images", { timeout: 30000 }, async () => { + // The full shared test document, minus the media blocks: the email + // mappings embed media by their (remote) URLs directly, which the + // screenshot below would then try to load over the network. + const emailDocument = [ + ...testDocumentWithSourceBlocks.filter( + (block) => !["image", "video", "audio", "file"].includes(block.type), + ), + invalidDiagramBlock, + invalidMathBlock, + ]; + + const exporter = new ReactEmailExporter(schema(), { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + math: emailMathBlockMapping, + diagram: emailDiagramBlockMapping, + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + inlineMath: emailInlineMathMapping, + }, + } as any); + + const html = await exporter.toReactEmailDocument(emailDocument as any); + + // Three generated images: block math (rasterized to PNG in the + // browser), inline math (always SVG), and the valid diagram (PNG). The + // invalid diagram renders the error placeholder instead - and doesn't + // fail the export. + // Decodes the HTML-escaped attribute value; `&` must be decoded + // last - decoding it first would double-unescape sequences like + // `&#x27;` (an escaped literal `'`) into `'`. + const srcs = [...html.matchAll(/]*src="(data:[^"]+)"/g)].map( + (match) => match[1].replaceAll("'", "'").replaceAll("&", "&"), + ); + expect(srcs).toHaveLength(3); + expect(srcs[0]).toMatch(/^data:image\/png/); + expect(srcs[1]).toMatch(/^data:image\/svg\+xml/); + expect(srcs[2]).toMatch(/^data:image\/png/); + for (const src of srcs) { + expect((await decodeAndSample(src)).inkedPixels).toBeGreaterThan(0); + } + expect(html).toContain("Invalid diagram"); + expect(html).toContain("Invalid formula"); + + // Visual regression of the exported email as a client would show it, + // rendered at 600px (typical email client width). + const frame = createExportFrame("600px"); + frame.innerHTML = html; + // Wait until every image is ready to paint - a screenshot taken while a + // data: URL is still decoding captures a gap (and unloaded images throw + // off the height measurement in screenshotFull). + await Promise.all( + [...frame.querySelectorAll("img")].map((img) => img.decode()), + ); + await screenshotFull(frame, "email-export"); + }); +}); + +describe("pdf export through a complete exporter in the browser", () => { + // Chromium only: the produced PDF is the same file everywhere (react-pdf + // lays it out from bundled fonts, not browser rendering), so per-browser + // runs would only re-test pdf.js's rasterizer at 3x the suite cost. + test.skipIf(browserName !== "chromium")( + "renders math and diagrams to images in the produced PDF", + { timeout: 60000 }, + async () => { + const mappings = { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + math: pdfMathBlockMapping, + diagram: pdfDiagramBlockMapping, + }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + inlineMath: pdfInlineMathMapping, + }, + }; + // The full shared test document: unlike the email mappings, the PDF + // exporter fetches media through `resolveFileUrl`, so the test + // resolver keeps it deterministic and offline. + const exporter = new PDFExporter(schema(), mappings as any, { + resolveFileUrl: testResolveFileUrl, + }); + + const transformed = await exporter.toReactPDFDocument([ + ...testDocumentWithSourceBlocks, + invalidDiagramBlock, + invalidMathBlock, + ] as any); + + // The element tree carries the inline math as an image with + // react-pdf's async (rasterizing) src function, and the diagram as a + // rendered PNG. + const images: any[] = []; + const collectImages = (node: any) => { + if (!node || typeof node !== "object") { + return; + } + if (Array.isArray(node)) { + node.forEach(collectImages); + return; + } + if (node.type === "IMAGE") { + images.push(node); + } + collectImages(node.props?.children); + }; + collectImages(transformed); + expect(images.some((i) => typeof i.props.src === "function")).toBe(true); + expect( + images.some((i) => String(i.props.src).startsWith("data:image/png")), + ).toBe(true); + + // Produce the actual file - this runs react-pdf's asset resolution, + // which invokes the inline math's rasterizing src function. + const blob = await pdf(transformed as any).toBlob(); + const bytes = new Uint8Array(await blob.arrayBuffer()); + expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-"); + + // Render the produced PDF's pages with pdf.js (pure JS - the reason + // the old Node-side attempt at this failed was native canvas + // dependencies, which a real browser doesn't need) and screenshot + // them, stacked, as a visual regression of the actual export. + const pdfjs = await import("pdfjs-dist"); + const workerUrl = ( + await import("pdfjs-dist/build/pdf.worker.min.mjs?url" as string) + ).default; + pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; + + const parsed = await pdfjs.getDocument({ data: bytes }).promise; + // The test document contains a page break. + expect(parsed.numPages).toBeGreaterThanOrEqual(2); + + // Screenshot each page as its own full-resolution baseline. + const frame = createExportFrame("fit-content"); + for (let n = 1; n <= parsed.numPages; n++) { + const pdfPage = await parsed.getPage(n); + const viewport = pdfPage.getViewport({ scale: 1 }); + const canvas = document.createElement("canvas"); + canvas.width = viewport.width; + canvas.height = viewport.height; + canvas.style.display = "block"; + await pdfPage.render({ + canvasContext: canvas.getContext("2d")!, + viewport, + } as any).promise; + frame.replaceChildren(canvas); + await screenshotFull(frame, `pdf-export-page-${n}`); + } + }, + ); +}); diff --git a/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-chromium-linux.png b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-chromium-linux.png new file mode 100644 index 0000000000..c349554d14 Binary files /dev/null and b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-chromium-linux.png differ diff --git a/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-firefox-linux.png b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-firefox-linux.png new file mode 100644 index 0000000000..4b2491fa80 Binary files /dev/null and b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-firefox-linux.png differ diff --git a/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-webkit-linux.png b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-webkit-linux.png new file mode 100644 index 0000000000..e964772e39 Binary files /dev/null and b/tests/src/end-to-end/screenshots/__screenshots__/screenshotFull.test.tsx/screenshot-full-tall-element-webkit-linux.png differ diff --git a/tests/src/end-to-end/screenshots/screenshotFull.test.tsx b/tests/src/end-to-end/screenshots/screenshotFull.test.tsx new file mode 100644 index 0000000000..b2eb8b759f --- /dev/null +++ b/tests/src/end-to-end/screenshots/screenshotFull.test.tsx @@ -0,0 +1,36 @@ +import { describe, test } from "vite-plus/test"; + +import { screenshotFull } from "../../utils/screenshotFull.js"; + +// Guards the capture utility itself, on synthetic content: screenshotFull +// depends on harness DOM internals (see its doc comment), and a harness +// update that breaks them would otherwise only surface as confusing +// failures in the feature tests using it. Numbered stripes make the failure +// modes obvious in the diff: a plain capture blanks out below ~712px, a +// viewport-only capture comes out downscaled to ~0.14x - the baseline +// proves the full 600x2000 render. +describe("screenshotFull", () => { + test( + "captures a tall element completely at full resolution", + { timeout: 30000 }, + async () => { + const frame = document.createElement("div"); + frame.style.width = "600px"; + frame.style.background = "white"; + for (let i = 0; i < 40; i++) { + const stripe = document.createElement("div"); + stripe.textContent = `stripe ${i} - starts at y = ${i * 50}px`; + stripe.style.height = "50px"; + stripe.style.font = "20px sans-serif"; + stripe.style.background = i % 2 ? "#e3f2fd" : "#fff3e0"; + frame.append(stripe); + } + document.body.append(frame); + try { + await screenshotFull(frame, "screenshot-full-tall-element"); + } finally { + frame.remove(); + } + }, + ); +}); diff --git a/tests/src/end-to-end/static/static.test.tsx b/tests/src/end-to-end/static/static.test.tsx index a9d20e5db6..544b0c2fa4 100644 --- a/tests/src/end-to-end/static/static.test.tsx +++ b/tests/src/end-to-end/static/static.test.tsx @@ -47,6 +47,10 @@ describe("Check static rendering", () => { "static-rendering-equality", { comparatorOptions: { allowedMismatchedPixels: 200 }, + // scale: "css" is load-bearing: with the harness's fit-to-window + // transform on the tester iframe, Playwright's css/device capture + // paths rasterize slightly differently, and dropping it pushes + // the diff past the pixel budget (empirically, chromium). screenshotOptions: { scale: "css", mask: masks() }, }, ); diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html index c39d38e0f3..dc83c065d9 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/diagram/basic.html @@ -8,34 +8,10 @@ style="white-space: normal;" >
-
-
- -

Add a Mermaid diagram

-
-
+
-