Skip to content

Commit 59bacbd

Browse files
committed
✨(frontend) enable ODT export for documents
provides ODT export with support for callout, upload, interlinking and tests Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(frontend) add image and interlinking support for odt export Added image mapping with SVG conversion and clickable document links. Signed-off-by: Cyril <c.gromoff@gmail.com> ✅(e2e) add e2e tests for odt export and interlinking features covers odt document export and cross-section interlinking use cases Signed-off-by: Cyril <c.gromoff@gmail.com> ✨(odt) add generic helper and style callout block for odt export create odtRegisterParagraphStyleForBlock and apply background/padding styles Signed-off-by: Cyril <c.gromoff@gmail.com>
1 parent 2f010cf commit 59bacbd

File tree

16 files changed

+511
-293
lines changed

16 files changed

+511
-293
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to
1010

1111
- ✨(frontend) create skeleton component for DocEditor #1491
1212
- ✨(frontend) add an EmojiPicker in the document tree and title #1381
13+
- ✨(frontend) enable ODT export for documents #1524
1314

1415
### Changed
1516

src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ test.describe('Doc Export', () => {
3232

3333
await expect(page.getByTestId('modal-export-title')).toBeVisible();
3434
await expect(
35-
page.getByText('Download your document in a .docx or .pdf format.'),
35+
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
3636
).toBeVisible();
3737
await expect(
3838
page.getByRole('combobox', { name: 'Template' }),
@@ -138,6 +138,51 @@ test.describe('Doc Export', () => {
138138
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
139139
});
140140

141+
test('it exports the doc to odt', async ({ page, browserName }) => {
142+
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
143+
144+
await verifyDocName(page, randomDoc);
145+
146+
await page.locator('.ProseMirror.bn-editor').click();
147+
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
148+
149+
await page.keyboard.press('Enter');
150+
await page.locator('.bn-block-outer').last().fill('/');
151+
await page.getByText('Resizable image with caption').click();
152+
153+
const fileChooserPromise = page.waitForEvent('filechooser');
154+
await page.getByText('Upload image').click();
155+
156+
const fileChooser = await fileChooserPromise;
157+
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
158+
159+
const image = page
160+
.locator('.--docs--editor-container img.bn-visual-media')
161+
.first();
162+
163+
await expect(image).toBeVisible();
164+
165+
await page
166+
.getByRole('button', {
167+
name: 'Export the document',
168+
})
169+
.click();
170+
171+
await page.getByRole('combobox', { name: 'Format' }).click();
172+
await page.getByRole('option', { name: 'Odt' }).click();
173+
174+
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
175+
176+
const downloadPromise = page.waitForEvent('download', (download) => {
177+
return download.suggestedFilename().includes(`${randomDoc}.odt`);
178+
});
179+
180+
void page.getByTestId('doc-export-download-button').click();
181+
182+
const download = await downloadPromise;
183+
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
184+
});
185+
141186
/**
142187
* This test tell us that the export to pdf is working with images
143188
* but it does not tell us if the images are being displayed correctly
@@ -451,4 +496,68 @@ test.describe('Doc Export', () => {
451496

452497
expect(pdfData.text).toContain(randomDoc);
453498
});
499+
500+
test('it exports the doc with interlinking to odt', async ({
501+
page,
502+
browserName,
503+
}) => {
504+
const [randomDoc] = await createDoc(
505+
page,
506+
'export-interlinking-odt',
507+
browserName,
508+
1,
509+
);
510+
511+
await verifyDocName(page, randomDoc);
512+
513+
const { name: docChild } = await createRootSubPage(
514+
page,
515+
browserName,
516+
'export-interlink-child-odt',
517+
);
518+
519+
await verifyDocName(page, docChild);
520+
521+
await page.locator('.bn-block-outer').last().fill('/');
522+
await page.getByText('Link a doc').first().click();
523+
524+
const input = page.locator(
525+
"span[data-inline-content-type='interlinkingSearchInline'] input",
526+
);
527+
const searchContainer = page.locator('.quick-search-container');
528+
529+
await input.fill('export-interlink');
530+
531+
await expect(searchContainer).toBeVisible();
532+
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
533+
534+
// We are in docChild, we want to create a link to randomDoc (parent)
535+
await searchContainer.getByText(randomDoc).click();
536+
537+
// Search the interlinking link in the editor (not in the document tree)
538+
const editor = page.locator('.ProseMirror.bn-editor');
539+
const interlink = editor.getByRole('button', {
540+
name: randomDoc,
541+
});
542+
543+
await expect(interlink).toBeVisible();
544+
545+
await page
546+
.getByRole('button', {
547+
name: 'Export the document',
548+
})
549+
.click();
550+
551+
await page.getByRole('combobox', { name: 'Format' }).click();
552+
await page.getByRole('option', { name: 'Odt' }).click();
553+
554+
const downloadPromise = page.waitForEvent('download', (download) => {
555+
return download.suggestedFilename().includes(`${docChild}.odt`);
556+
});
557+
558+
void page.getByTestId('doc-export-download-button').click();
559+
560+
const download = await downloadPromise;
561+
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
562+
});
454563
});

src/frontend/apps/impress/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@blocknote/react": "0.41.1",
2626
"@blocknote/xl-docx-exporter": "0.41.1",
2727
"@blocknote/xl-multi-column": "0.41.1",
28+
"@blocknote/xl-odt-exporter": "^0.41.1",
2829
"@blocknote/xl-pdf-exporter": "0.41.1",
2930
"@dnd-kit/core": "6.3.1",
3031
"@dnd-kit/modifiers": "9.0.0",
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from 'react';
2+
3+
import { odtRegisterParagraphStyleForBlock } from '../utils';
4+
import { DocsExporterODT } from '../types';
5+
6+
export const blockMappingCalloutODT: DocsExporterODT['mappings']['blockMapping']['callout'] =
7+
(block, exporter) => {
8+
// Map callout to paragraph with emoji prefix
9+
const emoji = block.props.emoji || '💡';
10+
11+
// Transform inline content (text, bold, links, etc.)
12+
const inlineContent = exporter.transformInlineContent(block.content);
13+
14+
// Resolve background and alignment → create a dedicated paragraph style
15+
const styleName = odtRegisterParagraphStyleForBlock(
16+
exporter,
17+
{
18+
backgroundColor: block.props.backgroundColor,
19+
textAlignment: block.props.textAlignment,
20+
},
21+
{ paddingCm: 0.42 },
22+
);
23+
24+
return React.createElement(
25+
'text:p',
26+
{
27+
'text:style-name': styleName,
28+
},
29+
`${emoji} `,
30+
...inlineContent,
31+
);
32+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React from 'react';
2+
3+
import { DocsExporterODT } from '../types';
4+
import { convertSvgToPng } from '../utils';
5+
6+
const MAX_WIDTH = 600;
7+
8+
export const blockMappingImageODT: DocsExporterODT['mappings']['blockMapping']['image'] =
9+
async (block, exporter) => {
10+
try {
11+
const blob = await exporter.resolveFile(block.props.url);
12+
13+
if (!blob || !blob.type) {
14+
console.warn(`Failed to resolve image: ${block.props.url}`);
15+
return null;
16+
}
17+
18+
let pngConverted: string | undefined;
19+
let dimensions: { width: number; height: number } | undefined;
20+
let previewWidth = block.props.previewWidth || undefined;
21+
22+
if (!blob.type.includes('image')) {
23+
console.warn(`Not an image type: ${blob.type}`);
24+
return null;
25+
}
26+
27+
if (blob.type.includes('svg')) {
28+
const svgText = await blob.text();
29+
const FALLBACK_SIZE = 536;
30+
previewWidth = previewWidth || blob.size || FALLBACK_SIZE;
31+
pngConverted = await convertSvgToPng(svgText, previewWidth);
32+
const img = new Image();
33+
img.src = pngConverted;
34+
await new Promise((resolve) => {
35+
img.onload = () => {
36+
dimensions = { width: img.width, height: img.height };
37+
resolve(null);
38+
};
39+
});
40+
} else {
41+
dimensions = await getImageDimensions(blob);
42+
}
43+
44+
if (!dimensions) {
45+
return null;
46+
}
47+
48+
const { width, height } = dimensions;
49+
50+
if (previewWidth && previewWidth > MAX_WIDTH) {
51+
previewWidth = MAX_WIDTH;
52+
}
53+
54+
// Convert image to base64 for ODT embedding
55+
const arrayBuffer = pngConverted
56+
? await (await fetch(pngConverted)).arrayBuffer()
57+
: await blob.arrayBuffer();
58+
const base64 = btoa(
59+
Array.from(new Uint8Array(arrayBuffer))
60+
.map((byte) => String.fromCharCode(byte))
61+
.join(''),
62+
);
63+
64+
const finalWidth = previewWidth || width;
65+
const finalHeight = ((previewWidth || width) / width) * height;
66+
67+
// Convert pixels to cm (ODT uses cm for dimensions)
68+
const widthCm = finalWidth / 37.795275591;
69+
const heightCm = finalHeight / 37.795275591;
70+
71+
// Create ODT image structure using React.createElement
72+
const frame = React.createElement(
73+
'text:p',
74+
{
75+
'text:style-name':
76+
block.props.textAlignment === 'center'
77+
? 'center'
78+
: block.props.textAlignment === 'right'
79+
? 'right'
80+
: 'left',
81+
},
82+
React.createElement(
83+
'draw:frame',
84+
{
85+
'draw:name': `Image${Date.now()}`,
86+
'text:anchor-type': 'paragraph',
87+
'svg:width': `${widthCm}cm`,
88+
'svg:height': `${heightCm}cm`,
89+
},
90+
React.createElement(
91+
'draw:image',
92+
{
93+
'xlink:type': 'simple',
94+
'xlink:show': 'embed',
95+
'xlink:actuate': 'onLoad',
96+
},
97+
React.createElement('office:binary-data', {}, base64),
98+
),
99+
),
100+
);
101+
102+
// Add caption if present
103+
if (block.props.caption) {
104+
return [
105+
frame,
106+
React.createElement(
107+
'text:p',
108+
{ 'text:style-name': 'Caption' },
109+
block.props.caption,
110+
),
111+
];
112+
}
113+
114+
return frame;
115+
} catch (error) {
116+
console.error(`Error processing image for ODT export:`, error);
117+
return null;
118+
}
119+
};
120+
121+
async function getImageDimensions(blob: Blob) {
122+
if (typeof window !== 'undefined') {
123+
const bmp = await createImageBitmap(blob);
124+
const { width, height } = bmp;
125+
bmp.close();
126+
return { width, height };
127+
}
128+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
export * from './calloutDocx';
2+
export * from './calloutODT';
23
export * from './calloutPDF';
34
export * from './headingPDF';
45
export * from './imageDocx';
6+
export * from './imageODT';
57
export * from './imagePDF';
68
export * from './paragraphPDF';
79
export * from './quoteDocx';
810
export * from './quotePDF';
911
export * from './tablePDF';
10-
export * from './uploadLoaderPDF';
1112
export * from './uploadLoaderDocx';
13+
export * from './uploadLoaderODT';
14+
export * from './uploadLoaderPDF';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
3+
import { DocsExporterODT } from '../types';
4+
5+
export const blockMappingUploadLoaderODT: DocsExporterODT['mappings']['blockMapping']['uploadLoader'] =
6+
(block) => {
7+
// Map uploadLoader to paragraph with information text
8+
const information = block.props.information || '';
9+
const type = block.props.type || 'loading';
10+
const prefix = type === 'warning' ? '⚠️ ' : '⏳ ';
11+
12+
return React.createElement(
13+
'text:p',
14+
{ 'text:style-name': 'Text_20_body' },
15+
`${prefix}${information}`,
16+
);
17+
};

0 commit comments

Comments
 (0)