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.')}