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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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) enable ODT export for documents #1524

### Changed

Expand Down
111 changes: 110 additions & 1 deletion src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,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' }),
Expand Down Expand Up @@ -138,6 +138,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
Expand Down Expand Up @@ -451,4 +496,68 @@ test.describe('Doc Export', () => {

expect(pdfData.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`);
});
});
1 change: 1 addition & 0 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"@blocknote/xl-odt-exporter": "^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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from 'react';

import { DocsExporterODT } from '../types';
import { convertSvgToPng } 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;

// 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':
block.props.textAlignment === 'center'
? 'center'
: block.props.textAlignment === 'right'
? 'right'
: 'left',
},
React.createElement(
Comment on lines +74 to +82
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Images seems everytime centered, same with svg btw.

'draw:frame',
{
'draw:name': `Image${Date.now()}`,
'text:anchor-type': 'paragraph',
'svg:width': `${widthCm}cm`,
'svg:height': `${heightCm}cm`,
},
React.createElement(
'draw:image',
{
'xlink:type': 'simple',
'xlink:show': 'embed',
'xlink:actuate': 'onLoad',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We get warning in the console:

Suggested change
'xlink:actuate': 'onLoad',
xlinkActuate: 'onLoad',

},
React.createElement('office:binary-data', {}, base64),
),
),
);

// Add caption if present
if (block.props.caption) {
return [
frame,
React.createElement(
'text:p',
{ 'text:style-name': 'Caption' },
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 };
}
}
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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}`,
);
};
Loading
Loading