Skip to content

Commit 7ccc61d

Browse files
committed
✨(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>
1 parent f391178 commit 7ccc61d

File tree

7 files changed

+189
-9
lines changed

7 files changed

+189
-9
lines changed
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+
}

src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './calloutODT';
33
export * from './calloutPDF';
44
export * from './headingPDF';
55
export * from './imageDocx';
6+
export * from './imageODT';
67
export * from './imagePDF';
78
export * from './paragraphPDF';
89
export * from './quoteDocx';

src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
22
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
3+
import { ODTExporter } from '@blocknote/xl-odt-exporter';
34
import {
45
Button,
56
Loader,
@@ -24,11 +25,13 @@ import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
2425
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
2526
import { docxDocsSchemaMappings } from '../mappingDocx';
2627
import { pdfDocsSchemaMappings } from '../mappingPDF';
28+
import { odtDocsSchemaMappings } from '../mappingODT';
2729
import { downloadFile } from '../utils';
2830

2931
enum DocDownloadFormat {
3032
PDF = 'pdf',
3133
DOCX = 'docx',
34+
ODT = 'odt',
3235
}
3336

3437
interface ModalExportProps {
@@ -124,7 +127,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
124127
: rawPdfDocument;
125128

126129
blobExport = await pdf(pdfDocument).toBlob();
127-
} else {
130+
} else if (format === DocDownloadFormat.DOCX) {
128131
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
129132
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
130133
});
@@ -133,6 +136,16 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
133136
documentOptions: { title: documentTitle },
134137
sectionOptions: {},
135138
});
139+
} else if (format === DocDownloadFormat.ODT) {
140+
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
141+
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
142+
});
143+
144+
blobExport = await exporter.toODTDocument(exportDocument);
145+
} else {
146+
toast(t('The export failed'), VariantType.ERROR);
147+
setIsExporting(false);
148+
return;
136149
}
137150

138151
downloadFile(blobExport, `${filename}.${format}`);
@@ -213,7 +226,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
213226
className="--docs--modal-export-content"
214227
>
215228
<Text $variation="600" $size="sm" as="p">
216-
{t('Download your document in a .docx or .pdf format.')}
229+
{t('Download your document in a .docx, .odt or .pdf format.')}
217230
</Text>
218231
<Select
219232
clearable={false}
@@ -231,6 +244,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
231244
label={t('Format')}
232245
options={[
233246
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
247+
{ label: t('ODT'), value: DocDownloadFormat.ODT },
234248
{ label: t('PDF'), value: DocDownloadFormat.PDF },
235249
]}
236250
value={format}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './interlinkingLinkPDF';
22
export * from './interlinkingLinkDocx';
3+
export * from './interlinkingLinkODT';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from 'react';
2+
3+
import { DocsExporterODT } from '../types';
4+
5+
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
6+
(inline) => {
7+
const url = window.location.origin + inline.props.url;
8+
const title = inline.props.title;
9+
10+
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
11+
// Uses the same structure as BlockNote's default link mapping
12+
return React.createElement(
13+
'text:a',
14+
{
15+
'xlink:type': 'simple',
16+
'text:style-name': 'Internet_20_link',
17+
'office:target-frame-name': '_top',
18+
'xlink:show': 'replace',
19+
'xlink:href': url,
20+
},
21+
`📄${title}`,
22+
);
23+
};

src/frontend/apps/impress/src/features/docs/doc-export/mappingODT.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,27 @@ import {
55
blockMappingImageODT,
66
blockMappingUploadLoaderODT,
77
} from './blocks-mapping';
8+
import { inlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
9+
import { DocsExporterODT } from './types';
810

9-
// Note: Using `as any` due to strict typing constraints in @blocknote/xl-odt-exporter
10-
// when extending with custom block mappings. The mappings are functionally correct.
11-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12-
export const odtDocsSchemaMappings: any = {
11+
export const odtDocsSchemaMappings: DocsExporterODT['mappings'] = {
1312
...odtDefaultSchemaMappings,
1413
blockMapping: {
1514
...odtDefaultSchemaMappings.blockMapping,
16-
// Custom mappings for our custom blocks
1715
callout: blockMappingCalloutODT,
1816
image: blockMappingImageODT,
1917
// We're reusing the file block mapping for PDF blocks
20-
pdf: odtDefaultSchemaMappings.blockMapping.file,
18+
// The types don't match exactly but the implementation is compatible
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
pdf: odtDefaultSchemaMappings.blockMapping.file as any,
2121
uploadLoader: blockMappingUploadLoaderODT,
2222
},
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2324
inlineContentMapping: {
2425
...odtDefaultSchemaMappings.inlineContentMapping,
25-
},
26+
interlinkingSearchInline: () => null,
27+
interlinkingLinkInline: inlineContentMappingInterlinkingLinkODT,
28+
} as any,
2629
styleMapping: {
2730
...odtDefaultSchemaMappings.styleMapping,
2831
},

src/frontend/apps/impress/src/features/docs/doc-export/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,13 @@ export type DocsExporterDocx = Exporter<
5151
IRunPropertiesOptions,
5252
TextRun
5353
>;
54+
55+
export type DocsExporterODT = Exporter<
56+
DocsBlockSchema,
57+
DocsInlineContentSchema,
58+
DocsStyleSchema,
59+
React.ReactNode,
60+
React.ReactNode,
61+
Record<string, string>,
62+
React.ReactNode
63+
>;

0 commit comments

Comments
 (0)