Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 4 additions & 3 deletions docs/content/docs/features/export/typst.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Uint8Array>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]")]]
19 changes: 16 additions & 3 deletions packages/diagram-block/src/typst-exporter/typstExporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
import {
TYPST_CODE_THEME_PATH,
TypstExporter,
typstDefaultSchemaMappings,
} from "@blocknote/xl-typst-exporter";
Expand Down Expand Up @@ -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
Expand All @@ -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, {
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) $)]

Expand Down
2 changes: 2 additions & 0 deletions packages/math-block/src/typst-exporter/typstExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,16 +500,6 @@
</w:tc>
</w:tr>
</w:tbl>
<w:p>
<w:hyperlink w:history="1" r:id="FAKE-ID">
<w:r>
<w:rPr>
<w:rStyle w:val="Hyperlink"/>
</w:rPr>
<w:t xml:space="preserve">Open file</w:t>
</w:r>
</w:hyperlink>
</w:p>
<w:p>
<w:r>
<w:drawing>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="comments.xml"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="" TargetMode="External"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm" TargetMode="External"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3" TargetMode="External"/>
<Relationship Id="FAKE-ID" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="" TargetMode="External"/>
Expand Down
23 changes: 23 additions & 0 deletions packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 6 additions & 2 deletions packages/xl-docx-exporter/src/docx/docxExporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
},
);

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<svg
Expand Down Expand Up @@ -326,6 +331,11 @@ export const createReactEmailBlockMappingForDefaultSchema = (
);
},
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 <></>;
}
// Video icon SVG
const icon = (
<svg
Expand Down Expand Up @@ -359,6 +369,11 @@ export const createReactEmailBlockMappingForDefaultSchema = (
);
},
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 <></>;
}
// File icon SVG
const icon = (
<svg
Expand Down Expand Up @@ -392,6 +407,14 @@ export const createReactEmailBlockMappingForDefaultSchema = (
);
},
image: (block) => {
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 (
<Img
src={block.props.url}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,6 @@
</table:table-cell>
</table:table-row>
</table:table>
<text:p style:style-name="Standard">
Open file
</text:p>
<text:p text:style-name="Standard">
<draw:frame draw:style-name="Frame" style:rel-height="scale" style:rel-width="332px" svg:width="332px" svg:height="342px" text:anchor-type="as-char">
<draw:text-box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,9 +403,6 @@
</table:table-cell>
</table:table-row>
</table:table>
<text:p style:style-name="Standard">
Open file
</text:p>
<text:p text:style-name="Standard">
<draw:frame draw:style-name="Frame" style:rel-height="scale" style:rel-width="332px" svg:width="332px" svg:height="342px" text:anchor-type="as-char">
<draw:text-box>
Expand Down
Loading
Loading