diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0d931c0d0c..1b8038df0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ and this project adheres to
- ✨(frontend) create skeleton component for DocEditor #1491
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
- ✨(frontend) ajustable left panel #1456
+- ✨(frontend) enable ODT export for documents #1524
### Changed
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
index 0289f33633..52af85ad4f 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
@@ -31,7 +31,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
- page.getByText('Download your document in a .docx or .pdf format.'),
+ page.getByText('Download your document in a .docx, .odt or .pdf format.'),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -142,6 +142,51 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
});
+ test('it exports the doc to odt', async ({ page, browserName }) => {
+ const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
+
+ await verifyDocName(page, randomDoc);
+
+ await page.locator('.ProseMirror.bn-editor').click();
+ await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
+
+ await page.keyboard.press('Enter');
+ await page.locator('.bn-block-outer').last().fill('/');
+ await page.getByText('Resizable image with caption').click();
+
+ const fileChooserPromise = page.waitForEvent('filechooser');
+ await page.getByText('Upload image').click();
+
+ const fileChooser = await fileChooserPromise;
+ await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
+
+ const image = page
+ .locator('.--docs--editor-container img.bn-visual-media')
+ .first();
+
+ await expect(image).toBeVisible();
+
+ await page
+ .getByRole('button', {
+ name: 'Export the document',
+ })
+ .click();
+
+ await page.getByRole('combobox', { name: 'Format' }).click();
+ await page.getByRole('option', { name: 'Odt' }).click();
+
+ await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
+
+ const downloadPromise = page.waitForEvent('download', (download) => {
+ return download.suggestedFilename().includes(`${randomDoc}.odt`);
+ });
+
+ void page.getByTestId('doc-export-download-button').click();
+
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
+ });
+
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -442,4 +487,68 @@ test.describe('Doc Export', () => {
const pdfText = await pdfParse.getText();
expect(pdfText.text).toContain(randomDoc);
});
+
+ test('it exports the doc with interlinking to odt', async ({
+ page,
+ browserName,
+ }) => {
+ const [randomDoc] = await createDoc(
+ page,
+ 'export-interlinking-odt',
+ browserName,
+ 1,
+ );
+
+ await verifyDocName(page, randomDoc);
+
+ const { name: docChild } = await createRootSubPage(
+ page,
+ browserName,
+ 'export-interlink-child-odt',
+ );
+
+ await verifyDocName(page, docChild);
+
+ await page.locator('.bn-block-outer').last().fill('/');
+ await page.getByText('Link a doc').first().click();
+
+ const input = page.locator(
+ "span[data-inline-content-type='interlinkingSearchInline'] input",
+ );
+ const searchContainer = page.locator('.quick-search-container');
+
+ await input.fill('export-interlink');
+
+ await expect(searchContainer).toBeVisible();
+ await expect(searchContainer.getByText(randomDoc)).toBeVisible();
+
+ // We are in docChild, we want to create a link to randomDoc (parent)
+ await searchContainer.getByText(randomDoc).click();
+
+ // Search the interlinking link in the editor (not in the document tree)
+ const editor = page.locator('.ProseMirror.bn-editor');
+ const interlink = editor.getByRole('button', {
+ name: randomDoc,
+ });
+
+ await expect(interlink).toBeVisible();
+
+ await page
+ .getByRole('button', {
+ name: 'Export the document',
+ })
+ .click();
+
+ await page.getByRole('combobox', { name: 'Format' }).click();
+ await page.getByRole('option', { name: 'Odt' }).click();
+
+ const downloadPromise = page.waitForEvent('download', (download) => {
+ return download.suggestedFilename().includes(`${docChild}.odt`);
+ });
+
+ void page.getByTestId('doc-export-download-button').click();
+
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
+ });
});
diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json
index f58200e7ba..f8a648db63 100644
--- a/src/frontend/apps/impress/package.json
+++ b/src/frontend/apps/impress/package.json
@@ -25,6 +25,7 @@
"@blocknote/react": "0.41.1",
"@blocknote/xl-docx-exporter": "0.41.1",
"@blocknote/xl-multi-column": "0.41.1",
+ "@blocknote/xl-odt-exporter": "0.41.1",
"@blocknote/xl-pdf-exporter": "0.41.1",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutODT.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutODT.tsx
new file mode 100644
index 0000000000..98e25100e3
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/calloutODT.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { DocsExporterODT } from '../types';
+import { odtRegisterParagraphStyleForBlock } from '../utils';
+
+export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
+ (block, exporter) => {
+ // Map callout to paragraph with emoji prefix
+ const emoji = block.props.emoji || '💡';
+
+ // Transform inline content (text, bold, links, etc.)
+ const inlineContent = exporter.transformInlineContent(block.content);
+
+ // Resolve background and alignment → create a dedicated paragraph style
+ const styleName = odtRegisterParagraphStyleForBlock(
+ exporter,
+ {
+ backgroundColor: block.props.backgroundColor,
+ textAlignment: block.props.textAlignment,
+ },
+ { paddingCm: 0.42 },
+ );
+
+ return React.createElement(
+ 'text:p',
+ {
+ 'text:style-name': styleName,
+ },
+ `${emoji} `,
+ ...inlineContent,
+ );
+ };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx
new file mode 100644
index 0000000000..22f1197216
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/imageODT.tsx
@@ -0,0 +1,140 @@
+import React from 'react';
+
+import { DocsExporterODT } from '../types';
+import { convertSvgToPng, odtRegisterParagraphStyleForBlock } from '../utils';
+
+const MAX_WIDTH = 600;
+
+export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
+ async (block, exporter) => {
+ try {
+ const blob = await exporter.resolveFile(block.props.url);
+
+ if (!blob || !blob.type) {
+ console.warn(`Failed to resolve image: ${block.props.url}`);
+ return null;
+ }
+
+ let pngConverted: string | undefined;
+ let dimensions: { width: number; height: number } | undefined;
+ let previewWidth = block.props.previewWidth || undefined;
+
+ if (!blob.type.includes('image')) {
+ console.warn(`Not an image type: ${blob.type}`);
+ return null;
+ }
+
+ if (blob.type.includes('svg')) {
+ const svgText = await blob.text();
+ const FALLBACK_SIZE = 536;
+ previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
+ pngConverted = await convertSvgToPng(svgText, previewWidth);
+ const img = new Image();
+ img.src = pngConverted;
+ await new Promise((resolve) => {
+ img.onload = () => {
+ dimensions = { width: img.width, height: img.height };
+ resolve(null);
+ };
+ });
+ } else {
+ dimensions = await getImageDimensions(blob);
+ }
+
+ if (!dimensions) {
+ return null;
+ }
+
+ const { width, height } = dimensions;
+
+ if (previewWidth && previewWidth > MAX_WIDTH) {
+ previewWidth = MAX_WIDTH;
+ }
+
+ // Convert image to base64 for ODT embedding
+ const arrayBuffer = pngConverted
+ ? await (await fetch(pngConverted)).arrayBuffer()
+ : await blob.arrayBuffer();
+ const base64 = btoa(
+ Array.from(new Uint8Array(arrayBuffer))
+ .map((byte) => String.fromCharCode(byte))
+ .join(''),
+ );
+
+ const finalWidth = previewWidth || width;
+ const finalHeight = ((previewWidth || width) / width) * height;
+
+ const baseParagraphProps = {
+ backgroundColor: block.props.backgroundColor,
+ textAlignment: block.props.textAlignment,
+ };
+
+ const paragraphStyleName = odtRegisterParagraphStyleForBlock(
+ exporter,
+ baseParagraphProps,
+ { paddingCm: 0 },
+ );
+
+ // Convert pixels to cm (ODT uses cm for dimensions)
+ const widthCm = finalWidth / 37.795275591;
+ const heightCm = finalHeight / 37.795275591;
+
+ // Create ODT image structure using React.createElement
+ const frame = React.createElement(
+ 'text:p',
+ {
+ 'text:style-name': paragraphStyleName,
+ },
+ React.createElement(
+ 'draw:frame',
+ {
+ 'draw:name': `Image${Date.now()}`,
+ 'text:anchor-type': 'as-char',
+ 'svg:width': `${widthCm}cm`,
+ 'svg:height': `${heightCm}cm`,
+ },
+ React.createElement(
+ 'draw:image',
+ {
+ xlinkType: 'simple',
+ xlinkShow: 'embed',
+ xlinkActuate: 'onLoad',
+ },
+ React.createElement('office:binary-data', {}, base64),
+ ),
+ ),
+ );
+
+ // Add caption if present
+ if (block.props.caption) {
+ const captionStyleName = odtRegisterParagraphStyleForBlock(
+ exporter,
+ baseParagraphProps,
+ { paddingCm: 0, parentStyleName: 'Caption' },
+ );
+
+ return [
+ frame,
+ React.createElement(
+ 'text:p',
+ { 'text:style-name': captionStyleName },
+ block.props.caption,
+ ),
+ ];
+ }
+
+ return frame;
+ } catch (error) {
+ console.error(`Error processing image for ODT export:`, error);
+ return null;
+ }
+ };
+
+async function getImageDimensions(blob: Blob) {
+ if (typeof window !== 'undefined') {
+ const bmp = await createImageBitmap(blob);
+ const { width, height } = bmp;
+ bmp.close();
+ return { width, height };
+ }
+}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
index 0eabf226bd..1d98efe889 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts
@@ -1,11 +1,14 @@
export * from './calloutDocx';
+export * from './calloutODT';
export * from './calloutPDF';
export * from './headingPDF';
export * from './imageDocx';
+export * from './imageODT';
export * from './imagePDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
-export * from './uploadLoaderPDF';
export * from './uploadLoaderDocx';
+export * from './uploadLoaderODT';
+export * from './uploadLoaderPDF';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/uploadLoaderODT.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/uploadLoaderODT.tsx
new file mode 100644
index 0000000000..dfc3e9d6b4
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/uploadLoaderODT.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import { DocsExporterODT } from '../types';
+
+export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
+ (block) => {
+ // Map uploadLoader to paragraph with information text
+ const information = block.props.information || '';
+ const type = block.props.type || 'loading';
+ const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
+
+ return React.createElement(
+ 'text:p',
+ { 'text:style-name': 'Text_20_body' },
+ `${prefix}${information}`,
+ );
+ };
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
index a23cd4e1af..64bb077d92 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
@@ -1,4 +1,5 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
+import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
@@ -23,12 +24,14 @@ import { Doc, useTrans } from '@/docs/doc-management';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
+import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
+ ODT = 'odt',
}
interface ModalExportProps {
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
- } else {
+ } else if (format === DocDownloadFormat.DOCX) {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
+ } else if (format === DocDownloadFormat.ODT) {
+ const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
+ resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
+ });
+
+ blobExport = await exporter.toODTDocument(exportDocument);
+ } else {
+ toast(t('The export failed'), VariantType.ERROR);
+ setIsExporting(false);
+ return;
}
downloadFile(blobExport, `${filename}.${format}`);
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
- {t('Download your document in a .docx or .pdf format.')}
+ {t('Download your document in a .docx, .odt or .pdf format.')}