diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts
new file mode 100644
index 00000000..1ac59492
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMediaFilename.test.ts
@@ -0,0 +1,67 @@
+import { deriveMediaFilename } from '../utils';
+
+describe('deriveMediaFilename', () => {
+ test('uses last URL segment when src is a valid URL', () => {
+ const result = deriveMediaFilename({
+ src: 'https://example.com/path/video.mp4',
+ index: 0,
+ blob: new Blob([], { type: 'video/mp4' }),
+ });
+ expect(result).toBe('1-video.mp4');
+ });
+
+ test('handles URLs with query/hash and keeps the last segment', () => {
+ const result = deriveMediaFilename({
+ src: 'https://site.com/assets/file.name.svg?x=1#test',
+ index: 0,
+ blob: new Blob([], { type: 'image/svg+xml' }),
+ });
+ expect(result).toBe('1-file.name.svg');
+ });
+
+ test('handles relative URLs using last segment', () => {
+ const result = deriveMediaFilename({
+ src: 'not a valid url',
+ index: 0,
+ blob: new Blob([], { type: 'image/png' }),
+ });
+ // "not a valid url" becomes a relative URL, so we get the last segment
+ expect(result).toBe('1-not%20a%20valid%20url.png');
+ });
+
+ test('data URLs always use media-{index+1}', () => {
+ const result = deriveMediaFilename({
+ src: 'data:image/png;base64,xxx',
+ index: 0,
+ blob: new Blob([], { type: 'image/png' }),
+ });
+ expect(result).toBe('media-1.png');
+ });
+
+ test('adds extension from MIME when baseName has no extension', () => {
+ const result = deriveMediaFilename({
+ src: 'https://a.com/abc',
+ index: 0,
+ blob: new Blob([], { type: 'image/webp' }),
+ });
+ expect(result).toBe('1-abc.webp');
+ });
+
+ test('does not override extension if baseName already contains one', () => {
+ const result = deriveMediaFilename({
+ src: 'https://a.com/image.png',
+ index: 0,
+ blob: new Blob([], { type: 'image/jpeg' }),
+ });
+ expect(result).toBe('1-image.png');
+ });
+
+ test('handles complex MIME types (e.g., audio/mpeg)', () => {
+ const result = deriveMediaFilename({
+ src: 'https://a.com/song',
+ index: 1,
+ blob: new Blob([], { type: 'audio/mpeg' }),
+ });
+ expect(result).toBe('2-song.mpeg');
+ });
+});
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 4d0338a2..51d95a4f 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
@@ -26,9 +26,14 @@ import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
-import { downloadFile } from '../utils';
+import {
+ deriveMediaFilename,
+ downloadFile,
+ generateHtmlDocument,
+} from '../utils';
enum DocDownloadFormat {
+ HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
@@ -142,6 +147,59 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
});
blobExport = await exporter.toODTDocument(exportDocument);
+ } else if (format === DocDownloadFormat.HTML) {
+ // Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
+ const fullHtml = await editor.blocksToFullHTML();
+
+ // Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
+ const domParser = new DOMParser();
+ const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
+
+ const mediaFiles: { filename: string; blob: Blob }[] = [];
+ const mediaElements = Array.from(
+ parsedDocument.querySelectorAll<
+ | HTMLImageElement
+ | HTMLVideoElement
+ | HTMLAudioElement
+ | HTMLSourceElement
+ >('img, video, audio, source'),
+ );
+
+ await Promise.all(
+ mediaElements.map(async (element, index) => {
+ const src = element.getAttribute('src');
+
+ if (!src) {
+ return;
+ }
+
+ const fetched = await exportCorsResolveFileUrl(doc.id, src);
+
+ if (!(fetched instanceof Blob)) {
+ return;
+ }
+
+ const filename = deriveMediaFilename({
+ src,
+ index,
+ blob: fetched,
+ });
+ element.setAttribute('src', filename);
+ mediaFiles.push({ filename, blob: fetched });
+ }),
+ );
+
+ const lang = i18next.language || 'fr';
+
+ const htmlContent = generateHtmlDocument(
+ documentTitle,
+ editorHtmlWithLocalMedia,
+ lang,
+ );
+
+ blobExport = new Blob([htmlContent], {
+ type: 'text/html;charset=utf-8',
+ });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
@@ -227,16 +285,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{t('Download your document in a .docx, .odt or .pdf format.')}
-