Skip to content

Commit 3e410e3

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 aba7959 commit 3e410e3

File tree

16 files changed

+495
-15
lines changed

16 files changed

+495
-15
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- ✨(frontend) enable ODT export for documents #1524
12+
913
### Fixed
1014

1115
- ♿(frontend) improve accessibility:

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
@@ -31,7 +31,7 @@ test.describe('Doc Export', () => {
3131

3232
await expect(page.getByTestId('modal-export-title')).toBeVisible();
3333
await expect(
34-
page.getByText('Download your document in a .docx or .pdf format.'),
34+
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
3535
).toBeVisible();
3636
await expect(
3737
page.getByRole('combobox', { name: 'Template' }),
@@ -142,6 +142,51 @@ test.describe('Doc Export', () => {
142142
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
143143
});
144144

145+
test('it exports the doc to odt', async ({ page, browserName }) => {
146+
const [randomDoc] = await createDoc(page, 'doc-editor-odt', browserName, 1);
147+
148+
await verifyDocName(page, randomDoc);
149+
150+
await page.locator('.ProseMirror.bn-editor').click();
151+
await page.locator('.ProseMirror.bn-editor').fill('Hello World ODT');
152+
153+
await page.keyboard.press('Enter');
154+
await page.locator('.bn-block-outer').last().fill('/');
155+
await page.getByText('Resizable image with caption').click();
156+
157+
const fileChooserPromise = page.waitForEvent('filechooser');
158+
await page.getByText('Upload image').click();
159+
160+
const fileChooser = await fileChooserPromise;
161+
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
162+
163+
const image = page
164+
.locator('.--docs--editor-container img.bn-visual-media')
165+
.first();
166+
167+
await expect(image).toBeVisible();
168+
169+
await page
170+
.getByRole('button', {
171+
name: 'Export the document',
172+
})
173+
.click();
174+
175+
await page.getByRole('combobox', { name: 'Format' }).click();
176+
await page.getByRole('option', { name: 'Odt' }).click();
177+
178+
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
179+
180+
const downloadPromise = page.waitForEvent('download', (download) => {
181+
return download.suggestedFilename().includes(`${randomDoc}.odt`);
182+
});
183+
184+
void page.getByTestId('doc-export-download-button').click();
185+
186+
const download = await downloadPromise;
187+
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
188+
});
189+
145190
/**
146191
* This test tell us that the export to pdf is working with images
147192
* but it does not tell us if the images are being displayed correctly
@@ -442,4 +487,68 @@ test.describe('Doc Export', () => {
442487
const pdfText = await pdfParse.getText();
443488
expect(pdfText.text).toContain(randomDoc);
444489
});
490+
491+
test('it exports the doc with interlinking to odt', async ({
492+
page,
493+
browserName,
494+
}) => {
495+
const [randomDoc] = await createDoc(
496+
page,
497+
'export-interlinking-odt',
498+
browserName,
499+
1,
500+
);
501+
502+
await verifyDocName(page, randomDoc);
503+
504+
const { name: docChild } = await createRootSubPage(
505+
page,
506+
browserName,
507+
'export-interlink-child-odt',
508+
);
509+
510+
await verifyDocName(page, docChild);
511+
512+
await page.locator('.bn-block-outer').last().fill('/');
513+
await page.getByText('Link a doc').first().click();
514+
515+
const input = page.locator(
516+
"span[data-inline-content-type='interlinkingSearchInline'] input",
517+
);
518+
const searchContainer = page.locator('.quick-search-container');
519+
520+
await input.fill('export-interlink');
521+
522+
await expect(searchContainer).toBeVisible();
523+
await expect(searchContainer.getByText(randomDoc)).toBeVisible();
524+
525+
// We are in docChild, we want to create a link to randomDoc (parent)
526+
await searchContainer.getByText(randomDoc).click();
527+
528+
// Search the interlinking link in the editor (not in the document tree)
529+
const editor = page.locator('.ProseMirror.bn-editor');
530+
const interlink = editor.getByRole('button', {
531+
name: randomDoc,
532+
});
533+
534+
await expect(interlink).toBeVisible();
535+
536+
await page
537+
.getByRole('button', {
538+
name: 'Export the document',
539+
})
540+
.click();
541+
542+
await page.getByRole('combobox', { name: 'Format' }).click();
543+
await page.getByRole('option', { name: 'Odt' }).click();
544+
545+
const downloadPromise = page.waitForEvent('download', (download) => {
546+
return download.suggestedFilename().includes(`${docChild}.odt`);
547+
});
548+
549+
void page.getByTestId('doc-export-download-button').click();
550+
551+
const download = await downloadPromise;
552+
expect(download.suggestedFilename()).toBe(`${docChild}.odt`);
553+
});
445554
});

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 { DocsExporterODT } from '../types';
4+
import { odtRegisterParagraphStyleForBlock } from '../utils';
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: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import React from 'react';
2+
3+
import { DocsExporterODT } from '../types';
4+
import { convertSvgToPng, odtRegisterParagraphStyleForBlock } 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+
const baseParagraphProps = {
68+
backgroundColor: block.props.backgroundColor,
69+
textAlignment: block.props.textAlignment,
70+
};
71+
72+
const paragraphStyleName = odtRegisterParagraphStyleForBlock(
73+
exporter,
74+
baseParagraphProps,
75+
{ paddingCm: 0 },
76+
);
77+
78+
// Convert pixels to cm (ODT uses cm for dimensions)
79+
const widthCm = finalWidth / 37.795275591;
80+
const heightCm = finalHeight / 37.795275591;
81+
82+
// Create ODT image structure using React.createElement
83+
const frame = React.createElement(
84+
'text:p',
85+
{
86+
'text:style-name': paragraphStyleName,
87+
},
88+
React.createElement(
89+
'draw:frame',
90+
{
91+
'draw:name': `Image${Date.now()}`,
92+
'text:anchor-type': 'as-char',
93+
'svg:width': `${widthCm}cm`,
94+
'svg:height': `${heightCm}cm`,
95+
},
96+
React.createElement(
97+
'draw:image',
98+
{
99+
xlinkType: 'simple',
100+
xlinkShow: 'embed',
101+
xlinkActuate: 'onLoad',
102+
},
103+
React.createElement('office:binary-data', {}, base64),
104+
),
105+
),
106+
);
107+
108+
// Add caption if present
109+
if (block.props.caption) {
110+
const captionStyleName = odtRegisterParagraphStyleForBlock(
111+
exporter,
112+
baseParagraphProps,
113+
{ paddingCm: 0, parentStyleName: 'Caption' },
114+
);
115+
116+
return [
117+
frame,
118+
React.createElement(
119+
'text:p',
120+
{ 'text:style-name': captionStyleName },
121+
block.props.caption,
122+
),
123+
];
124+
}
125+
126+
return frame;
127+
} catch (error) {
128+
console.error(`Error processing image for ODT export:`, error);
129+
return null;
130+
}
131+
};
132+
133+
async function getImageDimensions(blob: Blob) {
134+
if (typeof window !== 'undefined') {
135+
const bmp = await createImageBitmap(blob);
136+
const { width, height } = bmp;
137+
bmp.close();
138+
return { width, height };
139+
}
140+
}
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)