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.')} - { { label: t('Docx'), value: DocDownloadFormat.DOCX }, { label: t('ODT'), value: DocDownloadFormat.ODT }, { label: t('PDF'), value: DocDownloadFormat.PDF }, + { label: t('HTML'), value: DocDownloadFormat.HTML }, ]} value={format} onChange={(options) => setFormat(options.target.value as DocDownloadFormat) } /> +