diff --git a/AGENTS.md b/AGENTS.md index a57ca58812..d55aadd9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,3 +40,4 @@ When writing a new feature, bug fix, or other modification, it may not be immedi # Additional Notes - Do not create git commits, unless asked for directly, and do not add Co-Authored-By lines to commits. +- **The exporters mirror the editor's look, and that parity is guarded by review, not types.** The exporter packages (`xl-typst-exporter`/`xl-pdf-exporter`, `xl-docx-exporter`, `xl-odt-exporter`, `xl-email-exporter`) hardcode editor-derived styling constants (heading scale, spacing, list markers, code-block chrome, ...), each annotated with the `packages/core/src/editor/Block.css` rule it mirrors — keep those comments when touching either side. When changing visual rules in `Block.css` (or adding a block type), regenerate the exporter visual baselines and review them against the editor ground truth: the static-equality baseline (`tests/src/end-to-end/static`) renders the _same shared test document_ as the typst PDF baselines (`tests/src/end-to-end/exporters`), so fidelity drift shows up as a side-by-side diff in the same PR. diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx index 1f545fc38a..4a5e0ddaa3 100644 --- a/docs/content/docs/features/export/typst.mdx +++ b/docs/content/docs/features/export/typst.mdx @@ -47,10 +47,11 @@ const typst = await exporter.toTypst(editor.document, { ### Images & assets -The markup references the document's images by virtual paths like -`/assets/asset-0`. After `toTypst`, the exporter's `assetFiles` map holds +The markup references files by virtual paths: the document's images (like +`/assets/asset-0`) and a bundled code-highlighting theme the preamble +always references. After `toTypst`, the exporter's `assetFiles` map holds the bytes for those paths. Map them into your Typst compiler's filesystem -before compiling: +before compiling; every document needs this, not only those with images: ```typescript const assets = exporter.assetFiles; // Map diff --git a/packages/diagram-block/src/typst-exporter/__snapshots__/withDiagramMappings/diagramDocument.typ b/packages/diagram-block/src/typst-exporter/__snapshots__/withDiagramMappings/diagramDocument.typ index 49a19cac1e..bf1cf06411 100644 --- a/packages/diagram-block/src/typst-exporter/__snapshots__/withDiagramMappings/diagramDocument.typ +++ b/packages/diagram-block/src/typst-exporter/__snapshots__/withDiagramMappings/diagramDocument.typ @@ -4,6 +4,7 @@ #set par(leading: 0.78em, spacing: 0pt, justify: false) #set block(spacing: 0pt) #set list(spacing: 0pt) +#set list(marker: ([•], [◦], [▪])) #set enum(spacing: 0pt) #set heading(numbering: none) #show heading: set text(weight: 700) @@ -13,17 +14,22 @@ #show heading.where(level: 4): set text(size: 12pt) #show heading.where(level: 5): set text(size: 10.8pt) #show heading.where(level: 6): set text(size: 9.6pt) -#show raw: set text(font: "Geist Mono") -#show raw.where(block: true): it => block(width: 100%, inset: 12pt, radius: 3pt, fill: luma(248), stroke: 0.5pt + luma(210), it) +#set raw(theme: "/assets/code-theme.tmTheme") +#show raw: set text(font: "Geist Mono", ligatures: false) +#show raw.where(block: false): set text(fill: black) +#show raw.where(block: true): it => block(width: 100%, inset: 18pt, radius: 6pt, fill: rgb("#161616"), text(fill: rgb("#e1e4e8"), it)) #show quote.where(block: true): it => block(inset: (left: 14pt, y: 4pt), stroke: (left: 2pt + rgb("#7D797A")), text(fill: rgb("#7D797A"), it.body)) #set figure(numbering: none) #show figure: set block(breakable: false) #show figure.caption: set text(size: 9.6pt, fill: luma(110)) #show link: set text(fill: rgb("#0b6e99")) -#let _cb-unchecked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + luma(148), fill: white) -#let _cb-checked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + rgb("#3183c8"), fill: rgb("#3183c8"), place(center + horizon, { - box(move(dx: -0.11em, dy: 0.05em, rotate(45deg, line(length: 0.2em, stroke: white + 0.11em)))) - box(move(dx: 0.02em, dy: -0.04em, rotate(-45deg, line(length: 0.38em, stroke: white + 0.11em)))) -})) +#let _cb-box(fill, stroke, tick) = box(baseline: 0.13em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + stroke, fill: fill, tick) +#let _cb-unchecked = _cb-box(white, luma(148), none) +#let _cb-checked = _cb-box(rgb("#3183c8"), rgb("#3183c8"), place(top + left, curve( + stroke: (paint: white, thickness: 0.11em, cap: "round", join: "round"), + curve.move((0.20em, 0.47em)), + curve.line((0.37em, 0.63em)), + curve.line((0.70em, 0.27em)), +))) #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#[#show figure: set align(center); #figure(image("/assets/asset-0", width: 75.0pt), alt: "graph TD\n A[Start] --> B[End]")]] diff --git a/packages/diagram-block/src/typst-exporter/typstExporter.test.ts b/packages/diagram-block/src/typst-exporter/typstExporter.test.ts index ae135d1a7f..a7bf2c6d89 100644 --- a/packages/diagram-block/src/typst-exporter/typstExporter.test.ts +++ b/packages/diagram-block/src/typst-exporter/typstExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; import { + TYPST_CODE_THEME_PATH, TypstExporter, typstDefaultSchemaMappings, } from "@blocknote/xl-typst-exporter"; @@ -50,7 +51,10 @@ describe("typst exporter mappings", () => { // The rendered image is registered as a compiler asset. const assets = exporter.assetFiles; - expect([...assets.keys()]).toEqual(["/assets/asset-0"]); + expect([...assets.keys()]).toEqual([ + TYPST_CODE_THEME_PATH, + "/assets/asset-0", + ]); // Compile under Typst's own PDF/UA-1 validation - it *errors* on // figures without alt text, so this proves the mapping's figures stay @@ -76,7 +80,10 @@ describe("typst exporter mappings", () => { }); const assets = exporter.assetFiles; - expect([...assets.keys()]).toEqual(["/assets/asset-0"]); + expect([...assets.keys()]).toEqual([ + TYPST_CODE_THEME_PATH, + "/assets/asset-0", + ]); expect(typst).toContain('image("/assets/asset-0"'); const pdf = await compileTypstForTesting(typst, { @@ -96,7 +103,13 @@ describe("typst exporter mappings", () => { // first line), not the renderer's message - and no figure. expect(typst).toContain('Invalid diagram \\"graph TD'); expect(typst).not.toContain("#figure"); - expect(exporter.assetFiles.size).toBe(0); + // No *image* assets registered (assetFiles always carries the code + // highlighting theme). + expect( + [...exporter.assetFiles.keys()].filter((key) => + key.startsWith("/assets/asset-"), + ), + ).toHaveLength(0); }); it("should require a renderer outside the browser", async () => { diff --git a/packages/math-block/src/typst-exporter/__snapshots__/withMathMappings/mathDocument.typ b/packages/math-block/src/typst-exporter/__snapshots__/withMathMappings/mathDocument.typ index 942f236cf8..27f26624a1 100644 --- a/packages/math-block/src/typst-exporter/__snapshots__/withMathMappings/mathDocument.typ +++ b/packages/math-block/src/typst-exporter/__snapshots__/withMathMappings/mathDocument.typ @@ -4,6 +4,7 @@ #set par(leading: 0.78em, spacing: 0pt, justify: false) #set block(spacing: 0pt) #set list(spacing: 0pt) +#set list(marker: ([•], [◦], [▪])) #set enum(spacing: 0pt) #set heading(numbering: none) #show heading: set text(weight: 700) @@ -13,18 +14,23 @@ #show heading.where(level: 4): set text(size: 12pt) #show heading.where(level: 5): set text(size: 10.8pt) #show heading.where(level: 6): set text(size: 9.6pt) -#show raw: set text(font: "Geist Mono") -#show raw.where(block: true): it => block(width: 100%, inset: 12pt, radius: 3pt, fill: luma(248), stroke: 0.5pt + luma(210), it) +#set raw(theme: "/assets/code-theme.tmTheme") +#show raw: set text(font: "Geist Mono", ligatures: false) +#show raw.where(block: false): set text(fill: black) +#show raw.where(block: true): it => block(width: 100%, inset: 18pt, radius: 6pt, fill: rgb("#161616"), text(fill: rgb("#e1e4e8"), it)) #show quote.where(block: true): it => block(inset: (left: 14pt, y: 4pt), stroke: (left: 2pt + rgb("#7D797A")), text(fill: rgb("#7D797A"), it.body)) #set figure(numbering: none) #show figure: set block(breakable: false) #show figure.caption: set text(size: 9.6pt, fill: luma(110)) #show link: set text(fill: rgb("#0b6e99")) -#let _cb-unchecked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + luma(148), fill: white) -#let _cb-checked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + rgb("#3183c8"), fill: rgb("#3183c8"), place(center + horizon, { - box(move(dx: -0.11em, dy: 0.05em, rotate(45deg, line(length: 0.2em, stroke: white + 0.11em)))) - box(move(dx: 0.02em, dy: -0.04em, rotate(-45deg, line(length: 0.38em, stroke: white + 0.11em)))) -})) +#let _cb-box(fill, stroke, tick) = box(baseline: 0.13em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + stroke, fill: fill, tick) +#let _cb-unchecked = _cb-box(white, luma(148), none) +#let _cb-checked = _cb-box(rgb("#3183c8"), rgb("#3183c8"), place(top + left, curve( + stroke: (paint: white, thickness: 0.11em, cap: "round", join: "round"), + curve.move((0.20em, 0.47em)), + curve.line((0.37em, 0.63em)), + curve.line((0.70em, 0.27em)), +))) #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#math.equation(block: true, alt: "a^2 = \\sqrt{b^2 + c^2}", $ a^2 = sqrt(b^2 + c^2) $)] diff --git a/packages/math-block/src/typst-exporter/typstExporter.test.ts b/packages/math-block/src/typst-exporter/typstExporter.test.ts index 3a01b35b1a..9110aa8534 100644 --- a/packages/math-block/src/typst-exporter/typstExporter.test.ts +++ b/packages/math-block/src/typst-exporter/typstExporter.test.ts @@ -55,6 +55,8 @@ describe("typst exporter mappings", () => { // equations without alt text, so this proves the mappings' equations // stay UA-conformant end-to-end. const pdf = await compileTypstForTesting(typst, { + // The preamble references the exporter's bundled code-theme asset. + assets: exporter.assetFiles, pdfStandard: "ua-1", }); expect(pdf.length).toBeGreaterThan(0); 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 0ddd38bcc1..f649e3f6dc 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -500,16 +500,6 @@ - - - - - - - Open file - - - diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/withCustomOptions/document.xml.rels b/packages/xl-docx-exporter/src/docx/__snapshots__/withCustomOptions/document.xml.rels index 99a8e4defb..452c5c2697 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/withCustomOptions/document.xml.rels +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/withCustomOptions/document.xml.rels @@ -8,7 +8,6 @@ - diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index f7833d36a3..7429770e22 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -177,18 +177,33 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< }); }, audio: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return []; + } return [ file(block.props, exporter.dictionary.open_audio_file, exporter), ...caption(block.props, exporter), ]; }, video: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return []; + } return [ file(block.props, exporter.dictionary.open_video_file, exporter), ...caption(block.props, exporter), ]; }, file: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return []; + } return [ file(block.props, exporter.dictionary.open_file, exporter), ...caption(block.props, exporter), @@ -262,6 +277,14 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< }); }, image: async (block, exporter) => { + if (!block.props.url) { + // An image without a URL is the editor's un-uploaded placeholder ("Add + // image"), not document content - it exports as nothing. (The typst + // exporter renders a labelled placeholder figure when a name is present; + // this format has no placeholder rendering, so any url-less image is + // omitted.) + return []; + } const blob = await exporter.resolveFile(block.props.url); const { width, height } = await getImageDimensions(blob); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 8bb4dc9b7e..11367c90f2 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -382,8 +382,12 @@ describe("exporter", () => { "word/document.xml", ); - expect(documentXML).toContain("Datei öffnen"); - expect(documentXML).not.toContain("Open file"); + // The document's empty file block exports as nothing (placeholder, + // not content), so the dictionary wiring shows through the video / + // audio links instead. + expect(documentXML).toContain("Video öffnen"); + expect(documentXML).toContain("Audio öffnen"); + expect(documentXML).not.toContain("Open video file"); }, ); 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 37ef72320e..d9cc16d87f 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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

"`; +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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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

"`; +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

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Emojis: 😀 🎉 🚀 👍 👍🏽 🌍 🚶‍♀️

Centered paragraph

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

Checked List Item

  1. Numbered List Item starting at 5

  2. Numbered List Item 6

  • Toggle List Item

    Content nested inside the toggle list item.

    • A nested bullet inside the toggle

Toggle Heading

Content nested inside the toggle heading.

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 underlined strikethrough Link

Table Header 1Table Header 2Table Header 3
Table Cell 4Table Cell Bold Colored 5Table Cell 6
Table Cell 7Table Cell 8Table Cell 9
Merged HeaderHeader C
Header AHeader BHeader C2
Merged RowsCell B1Cell C1
Cell B2Cell C2

A hard line break
within a single paragraph

const ‍​helloWorld ‍​= ‍​(message) ‍​=> ‍​{
 ‍​ ‍​console.log("Hello World", ‍​message);
};

Some inline code: var foo = 'bar';

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


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 ad5ce3220b..a3ba8b653e 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -293,6 +293,11 @@ export const createReactEmailBlockMappingForDefaultSchema = ( codeBlock: (block) => codeMapping(block, block.props.language as PrismLanguage, textStyles), audio: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return <>; + } // Audio icon SVG const icon = ( { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return <>; + } // Video icon SVG const icon = ( { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return <>; + } // File icon SVG const icon = ( { + if (!block.props.url) { + // An image without a URL is the editor's un-uploaded placeholder ("Add + // image"), not document content - it exports as nothing. (The typst + // exporter renders a labelled placeholder figure when a name is present; + // this format has no placeholder rendering, so any url-less image is + // omitted.) + return <>; + } return ( - - Open 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 e64e78b78f..3312a64d51 100644 --- a/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml +++ b/packages/xl-odt-exporter/src/odt/__snapshots__/withCustomOptions/content.xml @@ -403,9 +403,6 @@ - - Open file - diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index ce992e0d0f..13c5c4a269 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -418,6 +418,14 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< }, image: async (block, exporter) => { + if (!block.props.url) { + // An image without a URL is the editor's un-uploaded placeholder ("Add + // image"), not document content - it exports as nothing. (The typst + // exporter renders a labelled placeholder figure when a name is present; + // this format has no placeholder rendering, so any url-less image is + // omitted.) + return <>; + } const odtExporter = exporter as ODTExporter; const { path, mimeType, ...originalDimensions } = @@ -579,6 +587,11 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< codeBlock: codeMapping, file: async (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder ("Add file") is an editing + // affordance, not document content - it exports as nothing. + return <>; + } return ( <> @@ -605,45 +618,59 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< ); }, - video: (block, exporter) => ( - <> - - - - {exporter.dictionary.open_video_file} - - - - {block.props.caption && ( - {block.props.caption} - )} - - ), + video: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder is an editing affordance, not + // document content - it exports as nothing. + return <>; + } + return ( + <> + + + + {exporter.dictionary.open_video_file} + + + + {block.props.caption && ( + {block.props.caption} + )} + + ); + }, - audio: (block, exporter) => ( - <> - - - - {exporter.dictionary.open_audio_file} - - - - {block.props.caption && ( - {block.props.caption} - )} - - ), + audio: (block, exporter) => { + if (!block.props.url && !block.props.name) { + // An un-uploaded media placeholder is an editing affordance, not + // document content - it exports as nothing. + return <>; + } + return ( + <> + + + + {exporter.dictionary.open_audio_file} + + + + {block.props.caption && ( + {block.props.caption} + )} + + ); + }, }; diff --git a/packages/xl-pdf-exporter/src/index.ts b/packages/xl-pdf-exporter/src/index.ts index 987e657e20..651fe08c1b 100644 --- a/packages/xl-pdf-exporter/src/index.ts +++ b/packages/xl-pdf-exporter/src/index.ts @@ -16,6 +16,7 @@ export { type TypstCompileOptions, } from "./pdfua/compileTypst.js"; export { + DEFAULT_EMOJI_FONT_FAMILY, loadDefaultBodyFonts, loadDefaultEmojiFont, } from "./pdfua/defaultFonts.js"; diff --git a/packages/xl-typst-compiler/src/typstCompiler.test.ts b/packages/xl-typst-compiler/src/typstCompiler.test.ts index c01fe5a5b9..533cc3c1a6 100644 --- a/packages/xl-typst-compiler/src/typstCompiler.test.ts +++ b/packages/xl-typst-compiler/src/typstCompiler.test.ts @@ -239,20 +239,25 @@ describe("the real exporter document", () => { font("noto/Noto-COLRv1.ttf"), ], }); + const exporterSrc = join(pkgDir, "..", "xl-typst-exporter", "src"); const source = readFileSync( - join( - pkgDir, - "..", - "xl-typst-exporter", - "src", - "__snapshots__", - "testDocument.typ", - ), + join(exporterSrc, "__snapshots__", "testDocument.typ"), "utf8", ); + // The exporter's markup references its assets: the document's image and + // the code-highlighting theme (which the exporter's `assetFiles` always + // carries - a consumer maps them all in, and so does this test). The + // theme is read from the exporter's source like the snapshot itself; if + // the exporter renames the path, this compile fails loudly. + const codeTheme = new Uint8Array( + readFileSync(join(exporterSrc, "codeTheme.tmTheme")), + ); const { pdf } = expectPdf( full.compilePdf(source, { - assets: new Map([["/assets/asset-0", png]]), + assets: new Map([ + ["/assets/asset-0", png], + ["/assets/code-theme.tmTheme", codeTheme], + ]), pdfStandard: "ua-1", }), ); diff --git a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ index 4c57f27290..7beb8b3135 100644 --- a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ +++ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ @@ -4,6 +4,7 @@ #set par(leading: 0.78em, spacing: 0pt, justify: false) #set block(spacing: 0pt) #set list(spacing: 0pt) +#set list(marker: ([•], [◦], [▪])) #set enum(spacing: 0pt) #set heading(numbering: none) #show heading: set text(weight: 700) @@ -13,26 +14,31 @@ #show heading.where(level: 4): set text(size: 12pt) #show heading.where(level: 5): set text(size: 10.8pt) #show heading.where(level: 6): set text(size: 9.6pt) -#show raw: set text(font: "Geist Mono") -#show raw.where(block: true): it => block(width: 100%, inset: 12pt, radius: 3pt, fill: luma(248), stroke: 0.5pt + luma(210), it) +#set raw(theme: "/assets/code-theme.tmTheme") +#show raw: set text(font: "Geist Mono", ligatures: false) +#show raw.where(block: false): set text(fill: black) +#show raw.where(block: true): it => block(width: 100%, inset: 18pt, radius: 6pt, fill: rgb("#161616"), text(fill: rgb("#e1e4e8"), it)) #show quote.where(block: true): it => block(inset: (left: 14pt, y: 4pt), stroke: (left: 2pt + rgb("#7D797A")), text(fill: rgb("#7D797A"), it.body)) #set figure(numbering: none) #show figure: set block(breakable: false) #show figure.caption: set text(size: 9.6pt, fill: luma(110)) #show link: set text(fill: rgb("#0b6e99")) -#let _cb-unchecked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + luma(148), fill: white) -#let _cb-checked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + rgb("#3183c8"), fill: rgb("#3183c8"), place(center + horizon, { - box(move(dx: -0.11em, dy: 0.05em, rotate(45deg, line(length: 0.2em, stroke: white + 0.11em)))) - box(move(dx: 0.02em, dy: -0.04em, rotate(-45deg, line(length: 0.38em, stroke: white + 0.11em)))) -})) - -#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#emph("Welcome to this ")#strong(emph("demo 🙌!")) +#let _cb-box(fill, stroke, tick) = box(baseline: 0.13em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + stroke, fill: fill, tick) +#let _cb-unchecked = _cb-box(white, luma(148), none) +#let _cb-checked = _cb-box(rgb("#3183c8"), rgb("#3183c8"), place(top + left, curve( + stroke: (paint: white, thickness: 0.11em, cap: "round", join: "round"), + curve.move((0.20em, 0.47em)), + curve.line((0.37em, 0.63em)), + curve.line((0.70em, 0.27em)), +))) + +#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#emph("Welcome to this ")#strong(emph("demo 🙌!"))] #pad(left: 1.5em)[ -#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Hello World nested" +#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Hello World nested"] #pad(left: 1.5em)[ #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Hello World double nested"] -]] -]] +] +] #block(width: 100%, fill: rgb("#fbe4e4"), inset: (x: 6pt, top: 6.9pt, bottom: 6.9pt))[#strong("This paragraph has a background color")] @@ -63,7 +69,7 @@ #pagebreak(weak: true) #list( - [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"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." + [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"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."] #list( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"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."]], @@ -72,24 +78,24 @@ #enum( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item 1"]], - [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item 2" + [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item 2"] #enum( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item Nested 1"]], [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item Nested 2"]], [#align(right)[#block(width: 100%, fill: rgb("#fbe4e4"), inset: (x: 6pt, top: 6.9pt, bottom: 6.9pt))[#text(fill: rgb("#0b6e99"))[#"Numbered List Item Nested funky right"]]]], [#align(center)[#block(width: 100%, fill: rgb("#fbe4e4"), inset: (x: 6pt, top: 6.9pt, bottom: 6.9pt))[#text(fill: rgb("#0b6e99"))[#"Numbered List Item Nested funky center"]]]] -)]] -)]] +)] +)] ) #enum( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item"]] ) -#list(marker: none, +#list(marker: none, body-indent: 0pt, [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#list(marker: _cb-unchecked, [#"Check List Item"])]], - [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#list(marker: _cb-checked, [#"Checked List Item"])]] + [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#list(marker: _cb-checked, [#strike[#"Checked List Item"]])]] ) #enum( @@ -98,19 +104,19 @@ [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Numbered List Item 6"]] ) -#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#box(baseline: 0.02em, move(dy: 0.04em, polygon(fill: luma(115), (0pt, 0pt), (0.32em, 0.2em), (0pt, 0.4em))))#h(0.35em)#"Toggle List Item" +#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#box(baseline: 0.02em, move(dy: 0.04em, polygon(fill: luma(115), (0pt, 0pt), (0.32em, 0.2em), (0pt, 0.4em))))#h(0.35em)#"Toggle List Item"] #pad(left: 1.5em)[ #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Content nested inside the toggle list item."] #list( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"A nested bullet inside the toggle"]] ) -]] +] -#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 2, outlined: true)[#"Toggle Heading"] +#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 2, outlined: true)[#"Toggle Heading"]] #pad(left: 1.5em)[ #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Content nested inside the toggle heading."] -]] +] #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#table( columns: (150.0pt, auto, auto), @@ -121,7 +127,7 @@ [#"Wide Cell"], [#"Table Cell"], [#"Table Cell"], )] -#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Open file"] + #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#[#show figure: set align(left); #figure(image("/assets/asset-0", width: 80%), caption: [#"From https://placehold.co/332x322.jpg"], alt: "From https://placehold.co/332x322.jpg")]] @@ -177,6 +183,6 @@ )] ) -#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#line(length: 100%, stroke: 0.5pt + luma(200))] +#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#line(length: 100%, stroke: 1pt + rgb("#7D797A"))] #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#quote(block: true)[#"All those moments will be lost in time, like tears in rain."]] diff --git a/packages/xl-typst-exporter/src/codeTheme.tmTheme b/packages/xl-typst-exporter/src/codeTheme.tmTheme new file mode 100644 index 0000000000..1d9c421a90 --- /dev/null +++ b/packages/xl-typst-exporter/src/codeTheme.tmTheme @@ -0,0 +1,44 @@ + + + + + nameBlockNote GitHub Dark + settings + + + settings + background#161616 + foreground#e1e4e8 + + + + scopecomment, punctuation.definition.comment + settingsforeground#6a737d + + + scopestring, punctuation.definition.string + settingsforeground#9ecbff + + + scopeconstant, constant.numeric, support.constant, constant.language + settingsforeground#79b8ff + + + scopekeyword, storage, storage.type, keyword.operator + settingsforeground#f97583 + + + scopeentity.name.function, support.function, meta.function-call + settingsforeground#b392f0 + + + scopevariable.parameter, variable.other.member, meta.definition.variable + settingsforeground#ffab70 + + + scopeentity.name.tag, entity.name.type, support.type, support.class + settingsforeground#85e89d + + + + diff --git a/packages/xl-typst-exporter/src/codeTheme.ts b/packages/xl-typst-exporter/src/codeTheme.ts new file mode 100644 index 0000000000..c14c8715bf --- /dev/null +++ b/packages/xl-typst-exporter/src/codeTheme.ts @@ -0,0 +1,18 @@ +// eslint-disable-next-line import/no-unresolved +import codeThemeXml from "./codeTheme.tmTheme?raw"; + +/** + * The syntax-highlighting theme for exported code blocks, mirroring the + * editor: the code-block package highlights with shiki's `github-dark` on + * the editor's `#161616` block background (Block.css `codeBlock`). Typst + * highlights with syntect, which takes TextMate themes - so the editor's + * look is mirrored by `codeTheme.tmTheme`, a minimal theme carrying + * github-dark's token palette. It ships as a virtual compiler file at this + * path; `TypstExporter.assetFiles` always includes it, and the preamble's + * `#set raw(theme: ...)` references it. + */ +export const TYPST_CODE_THEME_PATH = "/assets/code-theme.tmTheme"; + +export const TYPST_CODE_THEME_BYTES: Uint8Array = new TextEncoder().encode( + codeThemeXml, +); diff --git a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts index fb8ff39816..daeb3733bf 100644 --- a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts +++ b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts @@ -49,6 +49,12 @@ function imagePlaceholderFigure(props: MediaProps): string { */ function mediaLink(props: MediaProps, fallback: string): string { const { url, caption, name } = props; + // A block with neither a URL nor a name is the editor's un-uploaded + // placeholder ("Add file"), an editing affordance rather than document + // content - it exports as nothing. + if (!url && !name) { + return ""; + } const label = name || fallback; const main = url ? `#link(${strLit(url)})[#${strLit(label)}]` @@ -72,7 +78,13 @@ async function imageFigure( props: MediaProps, exporter: TypstExporter, ): Promise { - const { url, caption } = props; + const { url, caption, name } = props; + // Same rule as `mediaLink`: a block with neither a URL nor a name is the + // editor's un-uploaded placeholder ("Add image"), an editing affordance + // rather than document content - it exports as nothing. + if (!url && !name) { + return ""; + } if (!url) { return imagePlaceholderFigure(props); } @@ -142,7 +154,9 @@ export const typstBlockMappingForDefaultSchema: BlockMapping< }, // --- structural ------------------------------------------------------------- - divider: () => `#line(length: 100%, stroke: 0.5pt + luma(200))`, + // Same weight and grey as the editor's divider (Block.css: 1px solid + // rgb(125,121,122)). + divider: () => `#line(length: 100%, stroke: 1pt + rgb("#7D797A"))`, pageBreak: () => `#pagebreak(weak: true)`, // Multi-column layout is assembled by TypstExporter.transformBlocks (columns diff --git a/packages/xl-typst-exporter/src/index.ts b/packages/xl-typst-exporter/src/index.ts index 559695fff5..3419c1df79 100644 --- a/packages/xl-typst-exporter/src/index.ts +++ b/packages/xl-typst-exporter/src/index.ts @@ -3,6 +3,10 @@ export { type TypstDocumentOptions, type TypstExporterOptions, } from "./typstExporter.js"; +// The code-highlighting theme's virtual path - one of the entries +// `assetFiles` always carries, exported so consumers and tests can refer to +// it without hardcoding. +export { TYPST_CODE_THEME_PATH } from "./codeTheme.js"; export * from "./defaultSchema/index.js"; // Helpers for authors of custom Typst mappings (e.g. the math-block / // diagram-block `typst-exporter` entry points): string literals and the diff --git a/packages/xl-typst-exporter/src/typstExporter.test.ts b/packages/xl-typst-exporter/src/typstExporter.test.ts index 3073d18bf2..890545d4d2 100644 --- a/packages/xl-typst-exporter/src/typstExporter.test.ts +++ b/packages/xl-typst-exporter/src/typstExporter.test.ts @@ -83,7 +83,9 @@ describe("TypstExporter", () => { expect(typ).not.toContain("#set document("); expect(typ).not.toContain("lang:"); expect(typ).toContain('#set text(font: "Inter 18pt", size: 12pt)'); - expect(typ).toContain('#show raw: set text(font: "Geist Mono")'); + expect(typ).toContain( + '#show raw: set text(font: "Geist Mono", ligatures: false)', + ); }); it("propagates font (constructor) and document (export) options into the preamble", async () => { @@ -113,7 +115,9 @@ describe("TypstExporter", () => { expect(typ).toContain( '#set text(font: "Times New Roman", size: 14pt, lang: "fr")', ); - expect(typ).toContain('#show raw: set text(font: "Courier New")'); + expect(typ).toContain( + '#show raw: set text(font: "Courier New", ligatures: false)', + ); expect(typ).toContain('#set page(paper: "us-letter", margin: 2cm)'); }); @@ -217,15 +221,15 @@ describe("TypstExporter", () => { ]), ); - // The parent's own content is aligned... + // The parent's own padded block is aligned, and the align scope closes + // before the children's `#pad` begins... expect(typ).toContain( - '#align(right)[#heading(level: 1, outlined: true)[#"Heading right"]]', + '#align(right)[#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 1, outlined: true)[#"Heading right"]]]\n#pad(left: 1.5em)[', ); - // ...while the indented child sits outside that scope, unaligned. + // ...so the indented child sits outside that scope, unaligned. const child = '#heading(level: 2, outlined: true)[#"Heading 2"]'; expect(typ).toContain(child); expect(typ.indexOf("#pad(left: 1.5em)")).toBeLessThan(typ.indexOf(child)); - expect(typ).not.toContain(`#align(right)[#block`); }); it("fails the export when an image can't be resolved", async () => { @@ -342,7 +346,12 @@ describe("TypstExporter", () => { // ...and both header rows live in ONE table.header - Typst rejects a // table with more than one. expect(typ.match(/table\.header\(/g)).toHaveLength(1); - const pdf = await compileTypstForTesting(typ); + // The preamble references the bundled code-theme asset, so compiling + // the markup requires mapping `assetFiles` - the exporter's documented + // contract for every consumer. + const pdf = await compileTypstForTesting(typ, { + assets: exporter.assetFiles, + }); expect(pdf.length).toBeGreaterThan(0); }, ); @@ -393,19 +402,36 @@ describe("TypstExporter", () => { expect(typ).not.toContain("table.header("); }); - it("renders a placeholder figure for an image without a URL", async () => { + it("renders a placeholder figure only for a *named* image without a URL", async () => { const exporter = new TypstExporter(schema, typstDefaultSchemaMappings); - const typ = await exporter.toTypst( + // Neither URL nor name: the editor's un-uploaded "Add image" + // placeholder - an editing affordance, exported as nothing (the same + // rule as the other media blocks). + const empty = await exporter.toTypst( partialBlocksToBlocksForTesting(schema, [ { type: "image", props: { caption: "Later" } }, ]), ); + expect(empty).not.toContain("#figure("); - // Nothing to embed yet - a placeholder rectangle in a Figure that still - // carries alt text (PDF/UA requires one on every figure). + const typ = await exporter.toTypst( + partialBlocksToBlocksForTesting(schema, [ + { type: "image", props: { name: "chart.png" } }, + ]), + ); + + // A named image with nothing to embed yet - a placeholder rectangle in + // a Figure that still carries alt text (PDF/UA requires one on every + // figure). expect(typ).toContain("#figure(rect("); - expect(typ).toContain('alt: "Later"'); - expect(exporter.assetFiles.size).toBe(0); + expect(typ).toContain('alt: "chart.png"'); + // No *image* assets registered (assetFiles always carries the code + // highlighting theme). + expect( + [...exporter.assetFiles.keys()].filter((key) => + key.startsWith("/assets/asset-"), + ), + ).toHaveLength(0); }); }); diff --git a/packages/xl-typst-exporter/src/typstExporter.ts b/packages/xl-typst-exporter/src/typstExporter.ts index f289055ef5..1c8f6af33c 100644 --- a/packages/xl-typst-exporter/src/typstExporter.ts +++ b/packages/xl-typst-exporter/src/typstExporter.ts @@ -10,6 +10,7 @@ import { StyledText, } from "@blocknote/core"; import { corsProxyResolveFileUrl } from "@shared/api/corsProxy.js"; +import { TYPST_CODE_THEME_BYTES, TYPST_CODE_THEME_PATH } from "./codeTheme.js"; import { CHECKBOX_MARKER_DEFS, checkboxMarker, @@ -288,29 +289,38 @@ export class TypstExporter< block.children, nestingLevel + 1, ); - let content = body; - if (children.length) { - // As in `wrapBlock`: the item's alignment wraps its own body only, so - // nested children keep their own alignment (matching the editor). - content = this.applyOwnAlignment(block.props, content); - // Separate the item body from its children with a blank line so a child - // *paragraph* becomes its own block (a single "\n" is only a soft break in - // Typst markup); nested lists carry their own indentation either way. - content += "\n\n" + children.join("\n\n"); + const checked = + block.type === "checkListItem" + ? ((block.props as { checked?: boolean }).checked ?? false) + : undefined; + // The editor strikes a checked item's own text (not its children). + const own = checked ? `#strike[${body}]` : body; + // Use the checkbox as the item's list marker (so wrapped lines hang under + // the text) via a single-item inner list; wrapList groups these under one + // outer marker-less list, keeping a proper L > LI structure. + if (!children.length) { + return this.applyBlockProps( + block.props, + checked === undefined + ? own + : `#list(marker: ${checkboxMarker(checked)}, [${own}])`, + ); } - if (block.type === "checkListItem") { - const checked = (block.props as { checked?: boolean }).checked; - // Use the checkbox as the item's list marker (so wrapped lines hang under - // the text) via a single-item inner list; wrapList groups these under one - // outer marker-less list, keeping a proper L > LI structure. The body and - // any nested children are wrapped together so children nest under the item. - content = `#list(marker: ${checkboxMarker(checked ?? false)}, [${content}])`; - } - return this.applyBlockProps( - children.length - ? { ...block.props, textAlignment: undefined } - : block.props, - content, + // As in `wrapBlock`: the spacing insets and alignment wrap the item's + // *own* body only - each child carries its own insets and alignment - so + // closing a nested run doesn't stack bottom insets, and a background + // covers the children too (both matching the editor). The children are + // separated by a blank line so a child *paragraph* becomes its own block + // (a single "\n" is only a soft break in Typst markup); nested lists + // carry their own indentation either way. + const padded = this.applyBlockProps(block.props, own, { + skipBackground: true, + }); + return this.wrapBackground( + block.props, + checked === undefined + ? padded + "\n\n" + children.join("\n\n") + : `#list(marker: ${checkboxMarker(checked)}, [${padded}\n\n${children.join("\n\n")}])`, ); } @@ -353,7 +363,11 @@ export class TypstExporter< return `#enum(\n ${startArg}${body}\n)`; } if (kind === "check") { - return `#list(marker: none,\n ${body}\n)`; + // The grouping list is invisible, so it must not indent: its default + // body-indent (0.5em) would shift check items right of their bullet / + // numbered siblings, while the editor keeps every list type in the + // same marker column. + return `#list(marker: none, body-indent: 0pt,\n ${body}\n)`; } return `#list(\n ${body}\n)`; } @@ -368,42 +382,43 @@ export class TypstExporter< if (block.type === "pageBreak") { return self; } - let inner = self; - if (children.length) { - // Alignment scopes to the block's *own* content: Typst's `align`/`par` - // style everything in their scope, but in the editor nested children - // keep their own alignment. So with children present the alignment - // wraps `self` here, and is withheld from the outer wrapper below. - inner = this.applyOwnAlignment(block.props, inner); - inner += "\n#pad(left: 1.5em)[\n" + children.join("\n\n") + "\n]"; - } // Headings get extra top padding (the editor's ~18px heading top spacing). const extraTop = block.type === "heading" ? "8pt" : undefined; - return this.applyBlockProps( - children.length - ? { ...block.props, textAlignment: undefined } - : block.props, - inner, - { extraTop }, + if (!children.length) { + return this.applyBlockProps(block.props, self, { extraTop }); + } + // The spacing insets and alignment wrap the block's *own* body only - + // every child block carries its own - so closing a nested run doesn't + // stack bottom insets into an oversized gap, and nested children keep + // their own alignment (in the editor the rhythm is uniform: each block + // contributes exactly its own padding). A background, by contrast, must + // cover the children as well, as the editor's does. + const own = this.applyBlockProps(block.props, self, { + extraTop, + skipBackground: true, + }); + return this.wrapBackground( + block.props, + own + "\n#pad(left: 1.5em)[\n" + children.join("\n\n") + "\n]", ); } /** - * The alignment part of `applyBlockProps`, for wrapping a parent's own - * content when its children must stay outside the alignment scope. + * Wraps a parent's spacing-wrapped own body plus its rendered children in + * the block's background fill, if any: the editor's background covers + * nested children too. Vertical rhythm stays with the own body and the + * children themselves (each carries its own insets), so the fill + * contributes no spacing of its own - only the same horizontal inset + * `applyBlockProps` gives a filled leaf block. */ - private applyOwnAlignment( - props: { textAlignment?: string }, + private wrapBackground( + props: { backgroundColor?: string }, s: string, ): string { - if (props?.textAlignment === "justify") { - return `#par(justify: true)[${s}]`; - } - const align = props?.textAlignment; - if (align === "right" || align === "center") { - return `#align(${align})[${s}]`; - } - return s; + const bc = colorHex(this, props?.backgroundColor, "background"); + return bc + ? `#block(width: 100%, fill: rgb("${bc}"), inset: (x: 6pt))[\n${s}\n]` + : s; } /** @@ -420,7 +435,7 @@ export class TypstExporter< textAlignment?: string; }, s: string, - opts?: { extraTop?: string }, + opts?: { extraTop?: string; skipBackground?: boolean }, ): string { // A mapping that rendered nothing stays invisible (an empty math or // diagram block must not become a stray padded box) - the paragraph @@ -429,8 +444,12 @@ export class TypstExporter< return s; } // Same color resolution the style mapping uses for inline text/highlight. + // `skipBackground` is for parents with children, whose fill must cover + // the children too and so lives in `wrapBackground` around both. const tc = colorHex(this, props?.textColor, "text"); - const bc = colorHex(this, props?.backgroundColor, "background"); + const bc = opts?.skipBackground + ? undefined + : colorHex(this, props?.backgroundColor, "background"); if (tc) { s = `#text(fill: rgb("${tc}"))[${s}]`; } @@ -500,6 +519,9 @@ export class TypstExporter< `#set par(leading: 0.78em, spacing: 0pt, justify: false)`, `#set block(spacing: 0pt)`, `#set list(spacing: 0pt)`, + // Bullet glyphs cycle like the editor's (Block.css: "•", "◦", "▪") - + // typst's own cycle ("•", "‣", "–") diverges from level two on. + `#set list(marker: ([•], [◦], [▪]))`, `#set enum(spacing: 0pt)`, `#set heading(numbering: none)`, // Heading sizes match the editor (3 / 2 / 1.3 / 1 / 0.9 / 0.8 × 12pt), bold. @@ -511,9 +533,19 @@ export class TypstExporter< `#show heading.where(level: 4): set text(size: 12pt)`, `#show heading.where(level: 5): set text(size: 10.8pt)`, `#show heading.where(level: 6): set text(size: 9.6pt)`, - // Code: monospace, in a light boxed block. - `#show raw: set text(font: ${strLit(monoFontFamily)})`, - `#show raw.where(block: true): it => block(width: 100%, inset: 12pt, radius: 3pt, fill: luma(248), stroke: 0.5pt + luma(210), it)`, + // Code: monospace; code *blocks* use the editor's dark scheme - the + // #161616 / 8px-radius / 24px-padding chrome from Block.css and + // github-dark token colors (the code-block package's default theme) + // via the bundled tmTheme, which assetFiles always carries. Inline + // code has no language, so the theme colors nothing there; its text is + // pinned back to the body color because the theme's light foreground + // is meant for the dark block background only. + `#set raw(theme: ${strLit(TYPST_CODE_THEME_PATH)})`, + // Ligatures off: Geist Mono renders "=>" (and friends) as arrow + // glyphs, which the editor's plain monospace does not. + `#show raw: set text(font: ${strLit(monoFontFamily)}, ligatures: false)`, + `#show raw.where(block: false): set text(fill: black)`, + `#show raw.where(block: true): it => block(width: 100%, inset: 18pt, radius: 6pt, fill: rgb("#161616"), text(fill: rgb("#e1e4e8"), it))`, // Quote: grey text with a left rule. Vertical inset makes the rule span // the full line height (like the editor), not just the glyph ink. `#show quote.where(block: true): it => block(inset: (left: 14pt, y: 4pt), stroke: (left: 2pt + rgb("#7D797A")), text(fill: rgb("#7D797A"), it.body))`, @@ -580,9 +612,14 @@ export class TypstExporter< * `$typst.mapShadow`, node: `compiler.mapShadow`) before compiling. */ public get assetFiles(): Map { - return new Map( - [...this.assets.values()].map(({ path, bytes }) => [path, bytes]), - ); + return new Map([ + // The code-block highlighting theme the preamble references - always + // present (not per-image, so it must not shift the asset-N numbering). + [TYPST_CODE_THEME_PATH, TYPST_CODE_THEME_BYTES], + ...[...this.assets.values()].map( + ({ path, bytes }) => [path, bytes] as const, + ), + ]); } /** Convert a BlockNote document to a full Typst source string. */ diff --git a/packages/xl-typst-exporter/src/util.ts b/packages/xl-typst-exporter/src/util.ts index dd6e1706eb..5e2cda6ef3 100644 --- a/packages/xl-typst-exporter/src/util.ts +++ b/packages/xl-typst-exporter/src/util.ts @@ -29,15 +29,20 @@ export function escStr(s: string): string { * MIT-licensed `cheq` package.) */ export const CHECKBOX_MARKER_DEFS = [ - `#let _cb-unchecked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + luma(148), fill: white)`, - // The check mark is `place`d (out of flow) so the box keeps the same metrics - // as the unchecked box — otherwise typst's baseline-aligned list markers - // (PR #7895) drop the checked box below its text. The baseline shift centres - // the box on the text's x-height. - `#let _cb-checked = box(baseline: 0.24em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + rgb("#3183c8"), fill: rgb("#3183c8"), place(center + horizon, {`, - ` box(move(dx: -0.11em, dy: 0.05em, rotate(45deg, line(length: 0.2em, stroke: white + 0.11em))))`, - ` box(move(dx: 0.02em, dy: -0.04em, rotate(-45deg, line(length: 0.38em, stroke: white + 0.11em))))`, - `}))`, + // The tick is `place`d (out of flow) so both boxes keep identical metrics - + // otherwise typst's baseline-aligned list markers (PR #7895) drop the + // checked box below its text. The 0.13em baseline shift centres the box on + // the text's cap height (verified against a 600dpi raster). The tick is a + // single round-capped polyline: two separate rotated `line`s (the previous + // shape) leave a visible gap at the corner, butt caps making it worse. + `#let _cb-box(fill, stroke, tick) = box(baseline: 0.13em, width: 0.9em, height: 0.9em, radius: 2pt, stroke: 0.08em + stroke, fill: fill, tick)`, + `#let _cb-unchecked = _cb-box(white, luma(148), none)`, + `#let _cb-checked = _cb-box(rgb("#3183c8"), rgb("#3183c8"), place(top + left, curve(`, + ` stroke: (paint: white, thickness: 0.11em, cap: "round", join: "round"),`, + ` curve.move((0.20em, 0.47em)),`, + ` curve.line((0.37em, 0.63em)),`, + ` curve.line((0.70em, 0.27em)),`, + `)))`, ].join("\n"); /** The check-list marker symbol name (defined by {@link CHECKBOX_MARKER_DEFS}). */ 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__/emailImages.test.tsx/email-export-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/emailImages.test.tsx/email-export-chromium-linux.png 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__/emailImages.test.tsx/email-export-firefox-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-firefox-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/emailImages.test.tsx/email-export-firefox-linux.png 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__/emailImages.test.tsx/email-export-webkit-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/email-export-webkit-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/emailImages.test.tsx/email-export-webkit-linux.png diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-1-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-1-chromium-linux.png deleted file mode 100644 index c52ab39088..0000000000 Binary files a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-1-chromium-linux.png and /dev/null differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-2-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-2-chromium-linux.png deleted file mode 100644 index e46e4ab9d0..0000000000 Binary files a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-2-chromium-linux.png and /dev/null differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-4-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-4-chromium-linux.png deleted file mode 100644 index 10c511a3d3..0000000000 Binary files a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-4-chromium-linux.png and /dev/null 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__/reactPdfImages.test.tsx/pdf-export-page-1-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-1-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-1-chromium-linux.png 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__/reactPdfImages.test.tsx/pdf-export-page-2-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-2-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-2-chromium-linux.png 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__/reactPdfImages.test.tsx/pdf-export-page-3-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-3-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-3-chromium-linux.png 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__/reactPdfImages.test.tsx/pdf-export-page-4-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-4-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-4-chromium-linux.png diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-5-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-5-chromium-linux.png similarity index 100% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/pdf-export-page-5-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/reactPdfImages.test.tsx/pdf-export-page-5-chromium-linux.png diff --git a/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-1-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-1-chromium-linux.png new file mode 100644 index 0000000000..52a6c0b448 Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-1-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-2-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-2-chromium-linux.png new file mode 100644 index 0000000000..b5cac3a59b Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-2-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-3-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-3-chromium-linux.png similarity index 90% rename from tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-3-chromium-linux.png rename to tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-3-chromium-linux.png index cb737d35b7..b36d149eaf 100644 Binary files a/tests/src/end-to-end/exporters/__screenshots__/exporterImages.test.tsx/typst-pdf-page-3-chromium-linux.png and b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-3-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-4-chromium-linux.png b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-4-chromium-linux.png new file mode 100644 index 0000000000..47deb26fe5 Binary files /dev/null and b/tests/src/end-to-end/exporters/__screenshots__/typstPdfImages.test.tsx/typst-pdf-page-4-chromium-linux.png differ diff --git a/tests/src/end-to-end/exporters/emailImages.test.tsx b/tests/src/end-to-end/exporters/emailImages.test.tsx new file mode 100644 index 0000000000..090e160a39 --- /dev/null +++ b/tests/src/end-to-end/exporters/emailImages.test.tsx @@ -0,0 +1,87 @@ +import { diagramBlockMapping as emailDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter"; +import { + inlineMathMapping as emailInlineMathMapping, + mathBlockMapping as emailMathBlockMapping, +} from "@blocknote/math-block/email-exporter"; +import { + ReactEmailExporter, + reactEmailDefaultSchemaMappings, +} from "@blocknote/xl-email-exporter"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { decodeAndSample } from "@shared/util/browserImageTestUtil.js"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { screenshotFull } from "../../utils/screenshotFull.js"; +import { + createExportFrame, + invalidDiagramBlock, + invalidMathBlock, + removeExportFrame, + schema, +} from "./exporterTestUtil.js"; + +// See exporterTestUtil.tsx for why the complete-export browser tests live +// here rather than in the exporter packages. + +afterEach(removeExportFrame); + +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, + mathBlock: emailMathBlockMapping, + diagram: emailDiagramBlockMapping, + }, + inlineContentMapping: { + ...reactEmailDefaultSchemaMappings.inlineContentMapping, + math: 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"); + }); +}); diff --git a/tests/src/end-to-end/exporters/exporterImages.test.tsx b/tests/src/end-to-end/exporters/exporterImages.test.tsx deleted file mode 100644 index 4bf711b2f3..0000000000 --- a/tests/src/end-to-end/exporters/exporterImages.test.tsx +++ /dev/null @@ -1,395 +0,0 @@ -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 { diagramBlockMapping as typstDiagramBlockMapping } from "@blocknote/diagram-block/typst-exporter"; -import { - inlineMathMapping as typstInlineMathMapping, - mathBlockMapping as typstMathBlockMapping, -} from "@blocknote/math-block/typst-exporter"; -import { - ReactEmailExporter, - reactEmailDefaultSchemaMappings, -} from "@blocknote/xl-email-exporter"; -import { - compileTypstToPdf, - TypstExporter, - typstDefaultSchemaMappings, -} from "@blocknote/xl-pdf-exporter"; -// Bundled wasm + fonts so the Typst compile below runs fully offline. -// eslint-disable-next-line import/no-unresolved -import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url"; -// eslint-disable-next-line import/no-unresolved -import interRegularUrl from "@shared/assets/fonts/inter/Inter_18pt-Regular.ttf?url"; -// Typst needs a math-capable font for equations and an emoji font for the -// document's emoji (the compiler ships no fonts; missing glyphs would fail -// PDF/UA-1 validation as `.notdef`). -import newCMMathBookUrl from "@shared/assets/fonts/newcm/NewCMMath-Book.otf?url"; -import newCMMathRegularUrl from "@shared/assets/fonts/newcm/NewCMMath-Regular.otf?url"; -import notoEmojiUrl from "@shared/assets/fonts/noto/Noto-COLRv1.ttf?url"; -import { - PDFExporter, - pdfDefaultSchemaMappings, -} from "@blocknote/xl-pdf-exporter/react-pdf"; -import { pdf } from "@react-pdf/renderer"; -import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; -import { decodeAndSample } from "@shared/util/browserImageTestUtil.js"; -import { isPdf } from "@shared/util/testBytesUtil.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, - mathBlock: emailMathBlockMapping, - diagram: emailDiagramBlockMapping, - }, - inlineContentMapping: { - ...reactEmailDefaultSchemaMappings.inlineContentMapping, - math: 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"); - }); -}); - -// Builds the exporter, converts the shared document (plus invalid math / -// diagram placeholders), and compiles it natively as PDF/UA-1 through the -// real wasm pipeline. Shared by the assertion test and the visual test. -async function compileTypstUaPdf() { - const typstDocument = [ - ...testDocumentWithSourceBlocks, - invalidDiagramBlock, - invalidMathBlock, - ]; - - const exporter = new TypstExporter( - schema(), - { - ...typstDefaultSchemaMappings, - blockMapping: { - ...typstDefaultSchemaMappings.blockMapping, - mathBlock: typstMathBlockMapping, - diagram: typstDiagramBlockMapping, - }, - inlineContentMapping: { - ...typstDefaultSchemaMappings.inlineContentMapping, - math: typstInlineMathMapping, - }, - } as any, - { resolveFileUrl: testResolveFileUrl }, - ); - - const typ = await exporter.toTypst( - typstDocument as any, - { - title: "BlockNote Export", - lang: "en", - // Fixed timestamp: the visual baselines rely on deterministic bytes. - creationTimestamp: 1_700_000_000, - } as any, - ); - - const fonts = await Promise.all( - [interRegularUrl, newCMMathRegularUrl, newCMMathBookUrl, notoEmojiUrl].map( - async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()), - ), - ); - const result = await compileTypstToPdf(typ, { - wasm: new URL(compilerWasmUrl, document.baseURI), - fonts, - assets: exporter.assetFiles, - pdfStandard: "ua-1", - creationTimestamp: 1_700_000_000, - }); - if (result.error) { - throw new Error(result.compileErrors.map((d) => d.message).join(" | ")); - } - return { typ, exporter, pdf: result.pdf }; -} - -describe("pdf/ua export through the complete typst pipeline in the browser", () => { - test( - "exports the shared document with math and diagrams", - { timeout: 60000 }, - async () => { - // Compiling through the real wasm pipeline is what only the browser - // suite covers: bundler-served wasm loading (the node unit suites - // drive the same wasm from bytes). - const { typ, exporter, pdf } = await compileTypstUaPdf(); - - // Native equations with the LaTeX source as alt text, the diagram as a - // figure with the Mermaid source as alt text - and the invalid sources - // as placeholders, without failing the export. - expect(typ).toContain("math.equation(block: true"); - expect(typ).toContain('alt: "a^2 = \\\\sqrt{b^2 + c^2}"'); - expect(typ).toContain("Invalid diagram"); - expect(typ).toContain("Invalid formula"); - - // The default browser renderer produced a real Mermaid render as - // vector SVG, and the document's images resolved to registered assets. - // (Asset paths are extension-less; identify the formats by bytes.) - const assetBytes = [...exporter.assetFiles.values()]; - expect( - assetBytes.some((b) => - new TextDecoder().decode(b.slice(0, 5)).startsWith(" b[0] === 0xff && b[1] === 0xd8)).toBe(true); - - expect(isPdf(pdf)).toBe(true); - expect(pdf.byteLength).toBeGreaterThan(10_000); - // Natively declared and validated by the compile itself. - expect(new TextDecoder("latin1").decode(pdf).includes("pdfuaid")).toBe( - true, - ); - }, - ); - - // Chromium only, like the react-pdf visual test below: the PDF is the - // same bytes everywhere (Typst lays it out from the supplied 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")( - "matches the per-page visual snapshot", - // Cold wasm load + compile + rasterizing every page needs more headroom - // than the other tests on shared CI runners. - { timeout: 120000 }, - async () => { - const { pdf } = await compileTypstUaPdf(); - - // Render the produced PDF's pages with pdf.js and screenshot each - // page as a visual regression of the actual export (the same - // approach as the react-pdf test below). - 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: pdf.slice() }).promise; - // The test document contains a page break. - expect(parsed.numPages).toBeGreaterThanOrEqual(2); - - 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, `typst-pdf-page-${n}`); - } - }, - ); -}); - -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, - mathBlock: pdfMathBlockMapping, - diagram: pdfDiagramBlockMapping, - }, - inlineContentMapping: { - ...pdfDefaultSchemaMappings.inlineContentMapping, - math: 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(isPdf(bytes)).toBe(true); - - // 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/exporters/exporterTestUtil.tsx b/tests/src/end-to-end/exporters/exporterTestUtil.tsx new file mode 100644 index 0000000000..8961d707a2 --- /dev/null +++ b/tests/src/end-to-end/exporters/exporterTestUtil.tsx @@ -0,0 +1,97 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { expect } from "vite-plus/test"; +import { screenshotFull } from "../../utils/screenshotFull.js"; + +// The exporter e2e files (emailImages / typstPdfImages / reactPdfImages) +// run 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. They live in this package +// because they span 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. +export const invalidDiagramBlock = { + id: "invalid-diagram", + type: "diagram", + props: {}, + content: [{ type: "text", text: "not a valid diagram !!", styles: {} }], + children: [], +} as any; +export 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; + +export function schema() { + return BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }); +} + +/** + * Creates the container an export under test is rendered into. Each test + * file removes it again via `afterEach(removeExportFrame)`. + */ +export 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; +} + +export function removeExportFrame() { + document.getElementById("export-under-test")?.remove(); +} + +/** + * Renders each page of a produced PDF with pdf.js (pure JS - a real browser + * needs no native canvas dependencies) and screenshots it as its own + * full-resolution baseline named `${name}-${pageNumber}`. + */ +export async function screenshotPdfPages( + pdfBytes: Uint8Array, + name: string, + options?: Parameters[2], +) { + 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: pdfBytes.slice() }).promise; + // The shared test document contains a page break. + expect(parsed.numPages).toBeGreaterThanOrEqual(2); + + 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, `${name}-${n}`, options); + } +} diff --git a/tests/src/end-to-end/exporters/reactPdfImages.test.tsx b/tests/src/end-to-end/exporters/reactPdfImages.test.tsx new file mode 100644 index 0000000000..2b8d7e52d6 --- /dev/null +++ b/tests/src/end-to-end/exporters/reactPdfImages.test.tsx @@ -0,0 +1,94 @@ +import { diagramBlockMapping as pdfDiagramBlockMapping } from "@blocknote/diagram-block/pdf-exporter"; +import { + inlineMathMapping as pdfInlineMathMapping, + mathBlockMapping as pdfMathBlockMapping, +} from "@blocknote/math-block/pdf-exporter"; +import { + PDFExporter, + pdfDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter/react-pdf"; +import { pdf } from "@react-pdf/renderer"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { isPdf } from "@shared/util/testBytesUtil.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { browserName } from "../../utils/context.js"; +import { + invalidDiagramBlock, + invalidMathBlock, + removeExportFrame, + schema, + screenshotPdfPages, +} from "./exporterTestUtil.js"; + +// The deprecated react-pdf exporter's complete-export browser test. See +// exporterTestUtil.tsx for why it lives here rather than in the package. + +afterEach(removeExportFrame); + +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, + mathBlock: pdfMathBlockMapping, + diagram: pdfDiagramBlockMapping, + }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + math: 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(isPdf(bytes)).toBe(true); + + await screenshotPdfPages(bytes, "pdf-export-page"); + }, + ); +}); diff --git a/tests/src/end-to-end/exporters/typstPdfImages.test.tsx b/tests/src/end-to-end/exporters/typstPdfImages.test.tsx new file mode 100644 index 0000000000..06836911bc --- /dev/null +++ b/tests/src/end-to-end/exporters/typstPdfImages.test.tsx @@ -0,0 +1,156 @@ +import { diagramBlockMapping as typstDiagramBlockMapping } from "@blocknote/diagram-block/typst-exporter"; +import { + inlineMathMapping as typstInlineMathMapping, + mathBlockMapping as typstMathBlockMapping, +} from "@blocknote/math-block/typst-exporter"; +import { + compileTypstToPdf, + DEFAULT_EMOJI_FONT_FAMILY, + loadDefaultBodyFonts, + loadDefaultEmojiFont, + TypstExporter, + typstDefaultSchemaMappings, +} from "@blocknote/xl-pdf-exporter"; +// Bundled wasm so the Typst compile below runs fully offline. The fonts come +// from the package's own default loaders (not a hand-copied list, which can +// drift): a missing *face* (bold/italic) or the mono family falls back +// silently, which would render styled text and code unstyled and leave that +// fidelity untested - here the baselines pin exactly what a zero-config +// export ships. +// eslint-disable-next-line import/no-unresolved +import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url"; +import { testDocumentWithSourceBlocks } from "@shared/testDocument.js"; +import { isPdf } from "@shared/util/testBytesUtil.js"; +import { testResolveFileUrl } from "@shared/util/testFileResolver.js"; +import { afterEach, describe, expect, test } from "vite-plus/test"; +import { browserName } from "../../utils/context.js"; +import { + invalidDiagramBlock, + invalidMathBlock, + removeExportFrame, + schema, + screenshotPdfPages, +} from "./exporterTestUtil.js"; + +// See exporterTestUtil.tsx for why the complete-export browser tests live +// here rather than in the exporter packages. + +afterEach(removeExportFrame); + +// Builds the exporter, converts the shared document (plus invalid math / +// diagram placeholders), and compiles it natively as PDF/UA-1 through the +// real wasm pipeline. Shared by the assertion test and the visual test. +async function compileTypstUaPdf() { + const typstDocument = [ + ...testDocumentWithSourceBlocks, + invalidDiagramBlock, + invalidMathBlock, + ]; + + const exporter = new TypstExporter( + schema(), + { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + mathBlock: typstMathBlockMapping, + diagram: typstDiagramBlockMapping, + }, + inlineContentMapping: { + ...typstDefaultSchemaMappings.inlineContentMapping, + math: typstInlineMathMapping, + }, + } as any, + { + resolveFileUrl: testResolveFileUrl, + // As PDFExporter declares by default, so multi-codepoint emoji (skin + // tones, ZWJ sequences) shape through the emoji font instead of + // relying on Typst's per-glyph fallback. + emojiFontFamily: DEFAULT_EMOJI_FONT_FAMILY, + }, + ); + + const typ = await exporter.toTypst( + typstDocument as any, + { + title: "BlockNote Export", + lang: "en", + // Fixed timestamp: the visual baselines rely on deterministic bytes. + creationTimestamp: 1_700_000_000, + } as any, + ); + + const result = await compileTypstToPdf(typ, { + wasm: new URL(compilerWasmUrl, document.baseURI), + fonts: await loadDefaultBodyFonts(), + emojiFont: await loadDefaultEmojiFont(), + assets: exporter.assetFiles, + pdfStandard: "ua-1", + creationTimestamp: 1_700_000_000, + }); + if (result.error) { + throw new Error(result.compileErrors.map((d) => d.message).join(" | ")); + } + return { typ, exporter, pdf: result.pdf }; +} + +describe("pdf/ua export through the complete typst pipeline in the browser", () => { + test( + "exports the shared document with math and diagrams", + { timeout: 60000 }, + async () => { + // Compiling through the real wasm pipeline is what only the browser + // suite covers: bundler-served wasm loading (the node unit suites + // drive the same wasm from bytes). + const { typ, exporter, pdf } = await compileTypstUaPdf(); + + // Native equations with the LaTeX source as alt text, the diagram as a + // figure with the Mermaid source as alt text - and the invalid sources + // as placeholders, without failing the export. + expect(typ).toContain("math.equation(block: true"); + expect(typ).toContain('alt: "a^2 = \\\\sqrt{b^2 + c^2}"'); + expect(typ).toContain("Invalid diagram"); + expect(typ).toContain("Invalid formula"); + + // The default browser renderer produced a real Mermaid render as + // vector SVG, and the document's images resolved to registered assets. + // (Asset paths are extension-less; identify the formats by bytes.) + const assetBytes = [...exporter.assetFiles.values()]; + expect( + assetBytes.some((b) => + new TextDecoder().decode(b.slice(0, 5)).startsWith(" b[0] === 0xff && b[1] === 0xd8)).toBe(true); + + expect(isPdf(pdf)).toBe(true); + expect(pdf.byteLength).toBeGreaterThan(10_000); + // Natively declared and validated by the compile itself. + expect(new TextDecoder("latin1").decode(pdf).includes("pdfuaid")).toBe( + true, + ); + }, + ); + + // Chromium only, like the react-pdf visual test: the PDF is the same + // bytes everywhere (Typst lays it out from the supplied 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")( + "matches the per-page visual snapshot", + // Cold wasm load + compile + rasterizing every page needs more headroom + // than the other tests on shared CI runners. + { timeout: 120000 }, + async () => { + const { pdf } = await compileTypstUaPdf(); + + // Deterministic input (fixed creationTimestamp) + a pinned renderer + // make these pages byte-stable, so the comparison can be much tighter + // than the suite default - which, at 2% of an A4 page, once absorbed + // a full bold/italic styling regression. + await screenshotPdfPages(pdf, "typst-pdf-page", { + comparatorOptions: { allowedMismatchedPixelRatio: 0.002 }, + }); + }, + ); +}); diff --git a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-chromium-linux.png b/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-chromium-linux.png index 544a0c0d37..6a32d51a51 100644 Binary files a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-chromium-linux.png and b/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-chromium-linux.png differ diff --git a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-firefox-linux.png b/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-firefox-linux.png deleted file mode 100644 index db5354ac6c..0000000000 Binary files a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-firefox-linux.png and /dev/null differ diff --git a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-webkit-linux.png b/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-webkit-linux.png deleted file mode 100644 index 34220d8dd8..0000000000 Binary files a/tests/src/end-to-end/static/__screenshots__/static.test.tsx/static-rendering-equality-webkit-linux.png and /dev/null differ diff --git a/tests/src/end-to-end/static/static.test.tsx b/tests/src/end-to-end/static/static.test.tsx index 544b0c2fa4..62f703211e 100644 --- a/tests/src/end-to-end/static/static.test.tsx +++ b/tests/src/end-to-end/static/static.test.tsx @@ -1,11 +1,80 @@ -import BasicBlocksApp from "@examples/01-basic/04-default-blocks/src/App"; -import BasicBlocksStaticApp from "@examples/05-interoperability/10-static-html-render/src/App"; +import { + BlockNoteSchema, + createPageBreakBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { withMultiColumn } from "@blocknote/xl-multi-column"; import StaticApp from "@examples/02-backend/04-rendering-static-documents/src/App"; +import { testDocument } from "@shared/testDocument.js"; import { describe, test } from "vite-plus/test"; import { render } from "vitest-browser-react"; -import { page } from "../../utils/context.js"; +import { browserName, page } from "../../utils/context.js"; import { EDITOR_SELECTOR } from "../../utils/const.js"; import { expectElement, sleep, waitForSelector } from "../../utils/editor.js"; +import { screenshotFull } from "../../utils/screenshotFull.js"; + +// The equality test below renders the *shared exporter test document*, so its +// baseline doubles as the editor ground truth the exporter visual baselines +// (e.g. the typst PDF pages) can be reviewed against - same document, live +// editor rendering. The schema is the shared document's: defaults plus page +// break and multi-column. `testDocument` deliberately carries no math / +// diagram blocks (those live only in `testDocumentWithSourceBlocks`), exactly +// so editor schemas without their specs can seed it. +function sharedDocSchema() { + return withMultiColumn( + BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + }, + }), + ); +} + +// `testDocument`'s blocks carry empty ids, which the editor replaces with +// random ones on load - but the toggle-state seeding in the test needs +// stable ids known before the render, so they are assigned here (top-level +// is enough: the toggles are top-level blocks). +const doc = testDocument.map((block, index) => ({ + ...block, + id: block.id || `shared-doc-${index}`, +})); + +function LiveEditorApp() { + const editor = useCreateBlockNote({ + schema: sharedDocSchema(), + initialContent: doc as never, + }); + return ; +} + +// Mirrors examples/05-interoperability/10-static-html-render: the full-HTML +// export injected under the BlockNote container classes, so the stylesheet +// applies to the static markup the way it does to the live editor. +function StaticRenderApp() { + const editor = useCreateBlockNote({ + schema: sharedDocSchema(), + initialContent: doc as never, + }); + return ( +
+
+
+ ); +} describe("Check static rendering", () => { test("Check static rendering", async () => { @@ -17,86 +86,81 @@ describe("Check static rendering", () => { // Renders two editors back-to-back and screenshots each against the same // baseline, asserting the static HTML export looks like the live editor. - // Heavy (two full editors + two full-page screenshots), and Firefox is slow, - // so under 3-browser-in-one-container contention even 60s is tight — 90s. - test( + // Chromium-only: the property under test (static markup + stylesheet == + // live editor) is browser-independent, and the full-resolution capture + // relies on exact dimensions, which drift on Firefox. Heavy (two full + // editors + two full-page screenshots) - 90s. + test.skipIf(browserName !== "chromium")( "Check static rendering visually matches live editor", { timeout: 90000 }, async () => { - // Screenshots the whole page against the shared baseline. Mirrors the - // options the original Playwright test used: - // - Mask the regions that legitimately differ between the live editor and - // the static export, or that aren't deterministic across runs.